add Uber integration module, implement online order management in POS, and customize website checkout address form
This commit is contained in:
parent
2318ea10e8
commit
376f70feb6
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 && 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 && 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 && 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 && 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 && countrySelect && 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 && streetInput.value === 'In-Store Pickup') { streetInput.value = ''; }
|
if(streetInput && streetInput.value === 'In-Store Pickup') { streetInput.value = ''; }
|
||||||
if(cityInput && cityInput.value === 'Brampton') { cityInput.value = ''; }
|
if(cityInput && cityInput.value === 'Brampton') { cityInput.value = ''; }
|
||||||
if(zipInput && zipInput.value === 'L6Y0N1') { zipInput.value = ''; }
|
if(zipInput && 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'));
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
23
addons/dine360_uber/models/sale_order.py
Normal file
23
addons/dine360_uber/models/sale_order.py
Normal 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
|
||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user