add Uber integration module, implement online order management in POS, and customize website checkout address form

This commit is contained in:
Alaguraj0361 2026-04-06 18:03:10 +05:30
parent 2318ea10e8
commit 376f70feb6
8 changed files with 250 additions and 12 deletions

View File

@ -30,6 +30,7 @@ class PosOrder(models.Model):
string='Online Order Date', string='Online Order Date',
default=fields.Datetime.now default=fields.Datetime.now
) )
delivery_time = fields.Datetime(string='Requested Delivery Time')
# Note: order_source and fulfilment_type fields are defined in dine360_order_channels # Note: order_source and fulfilment_type fields are defined in dine360_order_channels
# dine360_online_orders just uses these fields # dine360_online_orders just uses these fields

View File

@ -41,6 +41,8 @@ class SaleOrderOnline(models.Model):
('interac', 'Interac'), ('interac', 'Interac'),
], string='Payment Option', tracking=True) ], string='Payment Option', tracking=True)
delivery_time = fields.Datetime(string='Requested Delivery Time', tracking=True)
telephone_number = fields.Char('Telephone Number') telephone_number = fields.Char('Telephone Number')
reservation_source = fields.Selection([ reservation_source = fields.Selection([
@ -82,6 +84,8 @@ class SaleOrderOnline(models.Model):
'online_order_date': fields.Datetime.now(), 'online_order_date': fields.Datetime.now(),
'order_source': sale_order.order_source or 'online', 'order_source': sale_order.order_source or 'online',
'fulfilment_type': sale_order.fulfilment_type or 'pickup', 'fulfilment_type': sale_order.fulfilment_type or 'pickup',
'delivery_time': sale_order.delivery_time,
'uber_eta': sale_order.delivery_time,
}) })
# Link back to sale order # Link back to sale order

View File

@ -93,6 +93,24 @@
opacity: 0 !important; opacity: 0 !important;
overflow: hidden !important; overflow: hidden !important;
} }
.uber-status-badge {
padding: 10px 15px;
border-radius: 8px;
font-weight: 600;
margin-top: 10px;
display: none;
}
.uber-status-available {
background: #d1f7ec;
color: #0e6245;
border: 1px solid #a2eed9;
}
.uber-status-unavailable {
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
}
</style> </style>
<div class="row align-items-center justify-content-center"> <div class="row align-items-center justify-content-center">
<div class="col-md-6 mb-3 mb-md-0" t-if="website.enable_delivery_option"> <div class="col-md-6 mb-3 mb-md-0" t-if="website.enable_delivery_option">
@ -120,6 +138,15 @@
</label> </label>
</div> </div>
</div> </div>
<!-- Uber Coverage Status Badge -->
<div id="uber_status_badge" class="uber-status-badge">
<div class="d-flex align-items-center">
<div id="uber_loader" class="spinner-border spinner-border-sm me-2" role="status" style="display: none;"></div>
<i id="uber_icon" class="fa fa-info-circle me-2"></i>
<span id="uber_msg">Checking delivery availability...</span>
</div>
</div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@ -132,12 +159,108 @@
const countrySelect = document.querySelector('select[name="country_id"]'); const countrySelect = document.querySelector('select[name="country_id"]');
const stateSelect = document.querySelector('select[name="state_id"]'); const stateSelect = document.querySelector('select[name="state_id"]');
const formElement = document.querySelector('form.checkout_autoformat'); const formElement = document.querySelector('form.checkout_autoformat');
const uberBadge = document.getElementById('uber_status_badge');
const uberMsg = document.getElementById('uber_msg');
const uberIcon = document.getElementById('uber_icon');
const uberLoader = document.getElementById('uber_loader');
// The checkout submit button
const submitBtn = document.querySelector('.a-submit');
const allRequiredInputs = [streetInput, cityInput, zipInput, countrySelect, stateSelect]; const allRequiredInputs = [streetInput, cityInput, zipInput, countrySelect, stateSelect];
let quoteTimer;
const setSubmitDisabled = (disabled) => {
if (!submitBtn) return;
if (disabled) {
submitBtn.classList.add('disabled');
submitBtn.style.pointerEvents = 'none';
submitBtn.style.opacity = '0.5';
} else {
submitBtn.classList.remove('disabled');
submitBtn.style.pointerEvents = 'auto';
submitBtn.style.opacity = '1';
}
};
function runUberQuote() {
if (!typeDelivery || !typeDelivery.checked) {
setSubmitDisabled(false);
return;
}
// Start as disabled for delivery until we get a successful quote
setSubmitDisabled(true);
if (!streetInput || !zipInput || !cityInput) return;
if (!streetInput.value || !zipInput.value || !cityInput.value) {
uberBadge.style.display = 'none';
return;
}
uberBadge.style.display = 'block';
uberBadge.className = 'uber-status-badge alert alert-info';
uberLoader.style.display = 'inline-block';
uberIcon.style.display = 'none';
uberMsg.innerText = 'Calculating delivery fee...';
const addressData = {
street: streetInput.value,
city: cityInput.value,
zip: zipInput.value,
country: countrySelect &amp;&amp; countrySelect.selectedIndex >= 0 ? countrySelect.options[countrySelect.selectedIndex].text : ''
};
fetch('/shop/uber/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params: { address_data: addressData } }),
// Short timeout for better UX
signal: AbortSignal.timeout(10000)
})
.then(res => res.json())
.then(data => {
uberLoader.style.display = 'none';
uberIcon.style.display = 'inline-block';
if (data.result &amp;&amp; data.result.success) {
uberBadge.className = 'uber-status-badge uber-status-available';
uberIcon.className = 'fa fa-check-circle me-2';
uberMsg.innerText = 'Delivery available! Fee: $' + data.result.fee.toFixed(2);
// ENABLE ONLY ON SUCCESS
setSubmitDisabled(false);
} else {
const errorMsg = (data.result &amp;&amp; data.result.error) ? data.result.error : 'Location out of delivery range or Uber address error.';
uberBadge.className = 'uber-status-badge uber-status-unavailable';
uberIcon.className = 'fa fa-times-circle me-2';
uberMsg.innerText = 'Sorry: ' + errorMsg;
// STAY DISABLED ON ERROR
setSubmitDisabled(true);
}
})
.catch(err => {
console.error("Uber Fetch Error:", err);
uberLoader.style.display = 'none';
uberBadge.className = 'uber-status-badge uber-status-unavailable';
uberIcon.className = 'fa fa-exclamation-triangle me-2';
uberMsg.innerText = 'Unable to verify delivery address.';
// STAY DISABLED ON NETWORK ERROR
setSubmitDisabled(true);
});
}
function debounceQuote() {
clearTimeout(quoteTimer);
quoteTimer = setTimeout(runUberQuote, 1000);
}
function applyMethod() { function applyMethod() {
if(typePickup.checked) { if(typePickup &amp;&amp; typePickup.checked) {
if(formElement) formElement.classList.add('is-pickup-mode'); if(formElement) formElement.classList.add('is-pickup-mode');
uberBadge.style.display = 'none';
// Remove "required" so HTML5 validation doesn't block submission // Remove "required" so HTML5 validation doesn't block submission
allRequiredInputs.forEach(input => { allRequiredInputs.forEach(input => {
@ -166,14 +289,8 @@
countrySelect.dispatchEvent(new Event('change')); countrySelect.dispatchEvent(new Event('change'));
} }
// Ensure state is set if country is Canada // ENABLE ALWAYS FOR PICKUP
if(stateSelect &amp;&amp; countrySelect &amp;&amp; countrySelect.options[countrySelect.selectedIndex]?.text.includes('Canada')) { setSubmitDisabled(false);
if(!stateSelect.value || stateSelect.value == "") {
let onOption = Array.from(stateSelect.options).find(o => o.text.includes('Ontario'));
if(onOption) { stateSelect.value = onOption.value; }
else if (stateSelect.options.length > 1) { stateSelect.selectedIndex = 1; }
}
}
} else { } else {
if(formElement) formElement.classList.remove('is-pickup-mode'); if(formElement) formElement.classList.remove('is-pickup-mode');
@ -189,6 +306,10 @@
if(streetInput &amp;&amp; streetInput.value === 'In-Store Pickup') { streetInput.value = ''; } if(streetInput &amp;&amp; streetInput.value === 'In-Store Pickup') { streetInput.value = ''; }
if(cityInput &amp;&amp; cityInput.value === 'Brampton') { cityInput.value = ''; } if(cityInput &amp;&amp; cityInput.value === 'Brampton') { cityInput.value = ''; }
if(zipInput &amp;&amp; zipInput.value === 'L6Y0N1') { zipInput.value = ''; } if(zipInput &amp;&amp; zipInput.value === 'L6Y0N1') { zipInput.value = ''; }
// DISABLE UNTIL QUOTE SUCCESSFUL
setSubmitDisabled(true);
debounceQuote();
} }
} }
@ -196,16 +317,19 @@
if (typeDelivery) typeDelivery.addEventListener('change', applyMethod); if (typeDelivery) typeDelivery.addEventListener('change', applyMethod);
typePickup.addEventListener('change', applyMethod); typePickup.addEventListener('change', applyMethod);
[streetInput, cityInput, zipInput].forEach(el => {
if (el) el.addEventListener('input', debounceQuote);
if (el) el.addEventListener('change', runUberQuote);
});
// Remember state if validation fails // Remember state if validation fails
const savedMethod = sessionStorage.getItem('chennora_checkout_method'); const savedMethod = sessionStorage.getItem('chennora_checkout_method');
if (savedMethod === 'pickup') { if (savedMethod === 'pickup') {
typePickup.checked = true; typePickup.checked = true;
} else if (!typeDelivery) { } else if (!typeDelivery) {
// Force pickup if delivery is disabled by admin
typePickup.checked = true; typePickup.checked = true;
} }
// Initial run
applyMethod(); applyMethod();
typePickup.addEventListener('change', () => sessionStorage.setItem('chennora_checkout_method', 'pickup')); typePickup.addEventListener('change', () => sessionStorage.setItem('chennora_checkout_method', 'pickup'));

View File

@ -10,7 +10,7 @@
- Real-time status updates between Odoo and Uber - Real-time status updates between Odoo and Uber
""", """,
'author': 'Dine360', 'author': 'Dine360',
'depends': ['point_of_sale', 'dine360_restaurant', 'dine360_kds'], 'depends': ['point_of_sale', 'dine360_restaurant', 'dine360_kds', 'website_sale'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/uber_cron_data.xml', 'data/uber_cron_data.xml',

View File

@ -30,3 +30,38 @@ class UberWebhookController(http.Controller):
return {'status': 'success'} return {'status': 'success'}
return {'status': 'ignored'} return {'status': 'ignored'}
class UberDeliveryController(http.Controller):
@http.route('/shop/uber/quote', type='json', auth='public', website=True, csrf=False)
def uber_quote(self, address_data, **post):
"""Get Uber quote for a website address"""
order = request.website.sale_get_order()
if not order:
return {'success': False, 'error': 'No active order'}
config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
return {'success': False, 'error': 'Uber not configured'}
company = request.website.company_id
pickup_address = f"{company.street}, {company.city}, {company.zip}, {company.country_id.name}"
# User entered address
dropoff_address = f"{address_data.get('street')}, {address_data.get('city')}, {address_data.get('zip')}, {address_data.get('country')}"
result = config.get_uber_quote(pickup_address, dropoff_address)
if result.get('success'):
# Clear old delivery fee if different? Or just update.
# In Odoo website_sale, we usually want to add it to the cart
order.sudo()._add_uber_delivery_fee(result['fee_amount'])
return {
'success': True,
'fee': result['fee_amount'],
'eta': result.get('estimated_arrival'),
}
else:
return result

View File

@ -1,3 +1,4 @@
from . import uber_config from . import uber_config
from . import pos_order from . import pos_order
from . import pos_order_line from . import pos_order_line
from . import sale_order

View File

@ -0,0 +1,23 @@
from odoo import models, fields, api, _
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _add_uber_delivery_fee(self, amount):
"""Add the delivery fee as a line item if not already added or update it"""
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if config and config.delivery_product_id:
fee_product = config.delivery_product_id
# Check if fee line exists
fee_line = self.order_line.filtered(lambda l: l.product_id == fee_product)
if fee_line:
fee_line.write({'price_unit': amount})
else:
self.write({'order_line': [(0, 0, {
'product_id': fee_product.id,
'name': fee_product.name,
'price_unit': amount,
'product_uom_qty': 1,
'is_delivery': True, # Mark as delivery line if possible
})]})
return True

View File

@ -3,6 +3,9 @@ from odoo.exceptions import UserError
import requests import requests
import json import json
import datetime import datetime
import logging
_logger = logging.getLogger(__name__)
class UberConfig(models.Model): class UberConfig(models.Model):
_name = 'uber.config' _name = 'uber.config'
@ -145,6 +148,53 @@ class UberConfig(models.Model):
response.raise_for_status() # Will raise error if scope invalid response.raise_for_status() # Will raise error if scope invalid
return True return True
def get_uber_quote(self, pickup_address, dropoff_address, items=None):
"""Get delivery quote from Uber API"""
self.ensure_one()
access_token = self._get_access_token()
customer_id = self.customer_id
if not customer_id:
raise UserError(_("Uber Customer ID is missing in configuration."))
api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
payload = {
"pickup_address": pickup_address,
"dropoff_address": dropoff_address,
}
if items:
payload["manifest_items"] = items
try:
response = requests.post(api_url, headers=headers, json=payload)
if response.status_code != 200:
# Log detailed error for debugging
_logger.error("Uber Quote Error: %s - %s", response.status_code, response.text)
return {
'success': False,
'error': response.json().get('message', 'Uber API Error'),
'code': response.json().get('code', 'unknown')
}
data = response.json()
# Standard fee is in cents
fee_cents = data.get('fee', 0)
return {
'success': True,
'quote_id': data.get('id'),
'fee_amount': float(fee_cents) / 100.0,
'currency': data.get('currency_code', 'USD'),
'estimated_arrival': data.get('estimated_arrival'),
'raw': data
}
except Exception as e:
_logger.exception("Uber Quote API Exception")
return {'success': False, 'error': str(e)}
def _return_notification(self, message, msg_type): def _return_notification(self, message, msg_type):
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.client',