diff --git a/addons/dine360_dashboard/static/src/css/shop_style.css b/addons/dine360_dashboard/static/src/css/shop_style.css
index 5c12ae5..7c217eb 100644
--- a/addons/dine360_dashboard/static/src/css/shop_style.css
+++ b/addons/dine360_dashboard/static/src/css/shop_style.css
@@ -289,24 +289,29 @@
color: var(--secondary-color, #2BB1A5) !important;
}
+/* Product Details Quantity Input */
.css_quantity input {
- max-width: 20px !important;
+ max-width: 65px !important;
+ width: 55px !important;
+ text-align: center !important;
+ padding: 0 !important;
+ color: #000000 !important;
+ background-color: #ffffff !important;
}
-/* Product Details Quantity Input Fix */
.css_quantity input.quantity {
color: #000000 !important;
background-color: #ffffff !important;
opacity: 1 !important;
- width: 60px !important;
- max-width: 60px !important;
+ width: 55px !important;
+ max-width: 65px !important;
height: 45px !important;
line-height: 45px !important;
- font-weight: 600 !important;
+ font-weight: 700 !important;
font-size: 18px !important;
padding: 0 !important;
text-align: center !important;
- border: 1px solid #000000 !important;
+ border: none !important;
display: inline-block !important;
visibility: visible !important;
}
\ No newline at end of file
diff --git a/addons/dine360_online_orders/controllers/main.py b/addons/dine360_online_orders/controllers/main.py
index b0eb327..4c786ca 100644
--- a/addons/dine360_online_orders/controllers/main.py
+++ b/addons/dine360_online_orders/controllers/main.py
@@ -12,6 +12,9 @@ class Dine360OnlineOrders(http.Controller):
'fulfilment_type': service_mode,
'order_source': 'online'
})
+ if service_mode in ['pickup', 'dine_in']:
+ if hasattr(order, '_remove_uber_delivery_fee'):
+ order.sudo()._remove_uber_delivery_fee()
return True
class Dine360WebsiteSaleOnline(WebsiteSale):
@@ -26,7 +29,9 @@ class Dine360WebsiteSaleOnline(WebsiteSale):
if carriers:
order.carrier_id = carriers[0].id
- price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0
+ price = 0.0
+ if getattr(order, 'fulfilment_type', 'delivery') == 'delivery':
+ price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0
order.set_delivery_line(carriers[0], price)
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post)
diff --git a/addons/dine360_theme_shivasakthi/static/src/scss/shop.scss b/addons/dine360_theme_shivasakthi/static/src/scss/shop.scss
index d7ea278..0148a0e 100644
--- a/addons/dine360_theme_shivasakthi/static/src/scss/shop.scss
+++ b/addons/dine360_theme_shivasakthi/static/src/scss/shop.scss
@@ -9,7 +9,7 @@
--food-shadow: 0 10px 25px rgba(0, 0, 0, 0.06);
}
-.form-control,
+.form-control:not(.quantity):not([name="add_qty"]),
.form-select {
background-color: #f8f9fa !important;
border: 1px solid #e5b945 !important;
@@ -508,70 +508,150 @@
font-family: inherit;
}
}
+ }
- .css_quantity {
- border: 2px solid #eee !important;
- border-radius: 50px !important;
- overflow: visible !important;
- display: inline-flex;
- margin-right: 15px;
- background: white !important;
- height: 45px !important;
- align-items: center;
+ /* Quantity and Add to cart styling on product detail page */
+ .css_quantity {
+ border: 2px solid #e0e0e0 !important;
+ border-radius: 50px !important;
+ overflow: hidden !important;
+ display: inline-flex !important;
+ margin-right: 15px !important;
+ margin-bottom: 10px !important;
+ background: #ffffff !important;
+ height: 48px !important;
+ align-items: center !important;
+ width: auto !important;
+ max-width: fit-content !important;
+ vertical-align: middle !important;
+ box-shadow: none !important;
-
- .btn {
- border: none !important;
- background: transparent !important;
- padding: 0 15px !important;
- color: #333 !important;
-
- &:hover {
- background: #f9f9f9 !important;
- }
- }
-
- .quantity {
- border: none !important;
- width: 80px !important;
- max-width: 80px !important;
- text-align: center !important;
- font-weight: 900 !important;
- font-size: 20px !important;
- background: #ffffff !important;
- color: #000000 !important;
- padding: 0 !important;
- margin: 0 !important;
- height: 45px !important;
- display: inline-block !important;
- vertical-align: middle !important;
- line-height: 45px !important;
- box-shadow: none !important;
- outline: none !important;
- appearance: none !important;
- -webkit-appearance: none !important;
- }
-
-
- }
-
- #add_to_cart {
- background: #e5b945 !important;
- color: #000000 !important;
+ .btn {
border: none !important;
- border-radius: 50px !important;
- padding: 12px 40px !important;
- font-weight: 800 !important;
- text-transform: uppercase !important;
- letter-spacing: 1px !important;
- transition: all 0.3s !important;
- box-shadow: 0 10px 20px rgba(254, 205, 79, 0.2) !important;
+ background: transparent !important;
+ padding: 0 16px !important;
+ color: #222222 !important;
+ font-size: 14px !important;
+ height: 100% !important;
+ min-height: 44px !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ box-shadow: none !important;
+ text-decoration: none !important;
+ cursor: pointer;
+ flex: 0 0 auto !important;
&:hover {
- background: #e5b945 !important;
- transform: translateY(-2px) !important;
+ background: #f5f5f5 !important;
+ color: #000000 !important;
+ }
+
+ &:active,
+ &:focus {
+ background: #eeeeee !important;
+ box-shadow: none !important;
}
}
+
+ .quantity,
+ input.quantity,
+ input[name="add_qty"] {
+ border: none !important;
+ border-radius: 0 !important;
+ width: 55px !important;
+ min-width: 50px !important;
+ max-width: 65px !important;
+ text-align: center !important;
+ font-weight: 800 !important;
+ font-size: 18px !important;
+ background: #ffffff !important;
+ color: #000000 !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ height: 100% !important;
+ min-height: 40px !important;
+ max-height: 48px !important;
+ display: inline-block !important;
+ vertical-align: middle !important;
+ line-height: 48px !important;
+ box-shadow: none !important;
+ outline: none !important;
+ appearance: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: textfield;
+ flex: 0 0 auto !important;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ &:focus {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: #ffffff !important;
+ color: #000000 !important;
+ }
+ }
+ }
+
+ #add_to_cart_wrap {
+ display: inline-flex !important;
+ align-items: center !important;
+ flex-wrap: wrap !important;
+ gap: 10px !important;
+ margin-top: 5px !important;
+ margin-bottom: 20px !important;
+ }
+
+ #add_to_cart {
+ background: #e5b945 !important;
+ color: #000000 !important;
+ border: none !important;
+ border-radius: 50px !important;
+ padding: 0 38px !important;
+ height: 48px !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 10px;
+ font-weight: 800 !important;
+ font-size: 15px !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.8px !important;
+ transition: all 0.3s ease !important;
+ box-shadow: 0 8px 20px rgba(229, 185, 69, 0.25) !important;
+ text-decoration: none !important;
+ margin-bottom: 10px !important;
+
+ &:hover {
+ background: #d4a838 !important;
+ color: #000000 !important;
+ transform: translateY(-2px) !important;
+ box-shadow: 0 12px 25px rgba(229, 185, 69, 0.35) !important;
+ }
+
+ &:active {
+ transform: translateY(0) !important;
+ }
+ }
+}
+
+/* Global quantity selector styling fallback (Cart, Checkout, etc.) */
+.css_quantity {
+ .form-control,
+ input.quantity,
+ input[name="add_qty"] {
+ padding: 0 5px !important;
+ text-align: center !important;
+ font-weight: 700 !important;
+ font-size: 16px !important;
+ color: #000000 !important;
+ background-color: #ffffff !important;
+ line-height: normal !important;
}
}
diff --git a/addons/dine360_theme_shivasakthi/views/checkout_address.xml b/addons/dine360_theme_shivasakthi/views/checkout_address.xml
index 11012de..6eeadb7 100644
--- a/addons/dine360_theme_shivasakthi/views/checkout_address.xml
+++ b/addons/dine360_theme_shivasakthi/views/checkout_address.xml
@@ -137,8 +137,27 @@
function initUnifiedForm() {
const hiddenType = document.getElementById('hidden_fulfilment_type');
const msgDiv = document.getElementById('uber_message');
- const submitBtn = document.querySelector('button[type="submit"]') || document.querySelector('.btn-primary') || document.querySelector('.s_website_form_send');
let debounceTimer;
+ let deliveryAllowed = true;
+
+ function setSubmitAllowed(allowed) {
+ deliveryAllowed = allowed;
+ const btns = document.querySelectorAll('.a-submit, button[type="submit"], .btn-primary');
+ btns.forEach(btn => {
+ if (btn.closest('#address_selection') || btn.classList.contains('order-type-card')) return;
+ if (allowed) {
+ btn.classList.remove('disabled');
+ btn.style.pointerEvents = 'auto';
+ btn.style.opacity = '1';
+ btn.removeAttribute('disabled');
+ } else {
+ btn.classList.add('disabled');
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.5';
+ btn.setAttribute('disabled', 'disabled');
+ }
+ });
+ }
// Force Name, Email, and Phone to be required
['name', 'email', 'phone'].forEach(fieldName => {
@@ -159,14 +178,12 @@
document.querySelectorAll('form.checkout_autoformat input, form.checkout_autoformat select').forEach(input => {
const handler = function() {
const val = this.value ? this.value.trim() : "";
- // Check if the field is actually filled (0 is valid for select if it's not the placeholder)
const isFilled = val && val !== "0" && val !== "";
if (isFilled) {
this.classList.remove('is-invalid');
this.style.borderColor = '';
- // Update the summary error list dynamically
const form = this.closest('form');
const errorDiv = document.getElementById('Shivasakthi_val_error');
if (errorDiv) {
@@ -189,17 +206,34 @@
});
// 3. Custom Validation on Submit
- if (submitBtn) {
- submitBtn.addEventListener('click', function(e) {
+ const submitBtns = document.querySelectorAll('.a-submit, button[type="submit"], .btn-primary');
+ submitBtns.forEach(btn => {
+ if (btn.closest('#address_selection') || btn.classList.contains('order-type-card')) return;
+ btn.addEventListener('click', function(e) {
+ if (hiddenType.value === 'delivery' && !deliveryAllowed) {
+ e.preventDefault();
+ e.stopPropagation();
+ let errorDiv = document.getElementById('Shivasakthi_val_error');
+ if (!errorDiv) {
+ errorDiv = document.createElement('div');
+ errorDiv.id = 'Shivasakthi_val_error';
+ errorDiv.className = 'alert alert-danger mt-3 animated fadeIn';
+ const form = document.querySelector('form.checkout_autoformat');
+ if (form) form.insertBefore(errorDiv, form.firstChild);
+ }
+ errorDiv.style.display = 'block';
+ errorDiv.innerHTML = `Delivery Not Available: This address cannot be delivered to. Please choose Store Pickup or provide a local Canadian address.`;
+ errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ return false;
+ }
+
const form = document.querySelector('form.checkout_autoformat');
if (!form) return;
- // Reset errors before checking
let missingFields = [];
const requiredInputs = form.querySelectorAll('input[required]:not([type="hidden"]), select[required]');
requiredInputs.forEach(input => {
- // Skip hidden fields (from pickup/delivery toggle or company hide)
const container = input.closest('div[class*="col-"], .mb-3');
if (container && container.style.display === 'none') return;
if (input.name === 'company_name' || input.name === 'vat') return;
@@ -235,7 +269,7 @@
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
- }
+ });
function getAddressContainers() {
const addressNames = ['street', 'street2', 'city', 'zip', 'country_id', 'state_id'];
@@ -271,8 +305,8 @@
containers.forEach(c => c.style.display = 'none');
if (addrHeader) addrHeader.style.display = 'none';
if (msgDiv) msgDiv.style.display = 'none';
+ setSubmitAllowed(true);
- // Auto-fill dummy values instantly so Odoo's strict validation passes
const form = document.querySelector('form.checkout_autoformat');
if (form) {
const fill = (n, v) => {
@@ -294,7 +328,6 @@
fill('state_id', '1');
}
- // Prevent HTML5 validation from silently blocking submission on hidden fields
containers.forEach(c => {
c.querySelectorAll('input, select').forEach(i => i.removeAttribute('required'));
});
@@ -303,7 +336,6 @@
if (addrHeader) addrHeader.style.display = '';
if (msgDiv) msgDiv.style.display = '';
- // Restore original values if user switches back to delivery
const form = document.querySelector('form.checkout_autoformat');
if (form) {
const restore = (n) => {
@@ -321,7 +353,6 @@
restore('state_id');
}
- // Restore required
['street', 'city', 'zip', 'country_id', 'state_id'].forEach(name => {
const i = document.querySelector(`[name="${name}"]`);
if (i) i.setAttribute('required', 'required');
@@ -329,7 +360,6 @@
checkUber();
}
- // SYNC with server immediately
fetch('/shop/update_service_mode', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ params: { service_mode: type } })
@@ -340,64 +370,90 @@
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (hiddenType.value !== 'delivery') return;
- const street = document.querySelector('input[name="street"]')?.value;
- const zip = document.querySelector('input[name="zip"]')?.value;
+ const street = document.querySelector('input[name="street"]')?.value?.trim();
+ const zip = document.querySelector('input[name="zip"]')?.value?.trim();
if (!street || !zip) {
if (msgDiv) msgDiv.style.display = 'none';
return;
}
+ const countryEl = document.querySelector('select[name="country_id"]');
+ let countryText = '';
+ let countryId = '';
+ if (countryEl) {
+ countryId = countryEl.value || '';
+ if (countryEl.selectedIndex >= 0) {
+ countryText = countryEl.options[countryEl.selectedIndex].text.trim();
+ }
+ }
+
+ const stateEl = document.querySelector('select[name="state_id"]');
+ let stateText = '';
+ let stateId = '';
+ if (stateEl) {
+ stateId = stateEl.value || '';
+ if (stateEl.selectedIndex >= 0) {
+ stateText = stateEl.options[stateEl.selectedIndex].text.trim();
+ }
+ }
+
if (msgDiv) {
msgDiv.className = 'alert alert-info my-3';
msgDiv.style.display = '';
- msgDiv.innerText = "Checking Uber coverage...";
+ msgDiv.innerHTML = 'Verifying delivery coverage...';
}
fetch('/shop/uber/quote', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ params: { address_data: {
street: street,
- street2: document.querySelector('input[name="street2"]')?.value,
- city: document.querySelector('input[name="city"]')?.value,
+ street2: document.querySelector('input[name="street2"]')?.value?.trim(),
+ city: document.querySelector('input[name="city"]')?.value?.trim(),
zip: zip,
- country: document.querySelector('select[name="country_id"] option:checked')?.text || 'Canada',
- state: document.querySelector('select[name="state_id"] option:checked')?.text
+ country: countryText,
+ country_id: countryId,
+ state: stateText,
+ state_id: stateId
} } })
}).then(r => r.json()).then(data => {
msgDiv.classList.remove('fadeIn');
- void msgDiv.offsetWidth; // Trigger reflow for animation
+ void msgDiv.offsetWidth;
msgDiv.classList.add('fadeIn');
if (data.result && data.result.success) {
+ setSubmitAllowed(true);
msgDiv.className = 'alert alert-success my-3 animated fadeIn';
msgDiv.style.display = '';
+ const feeVal = Number(data.result.fee || 0).toFixed(2);
+ const feeText = `Uber Delivery Fee: $${feeVal}`;
msgDiv.innerHTML = `
-
+
- ✓ Delivery Available!
- Uber Delivery Fee: $${data.result.fee} (Distance Based)
+ ✓ Delivery Available!
+ ${feeText}
`;
- if (submitBtn) submitBtn.disabled = false;
} else {
+ setSubmitAllowed(false);
msgDiv.className = 'alert alert-danger my-3 animated fadeIn';
msgDiv.style.display = '';
+ let userMsg = data.result?.error || "This address is outside our delivery area.";
msgDiv.innerHTML = `
-
+
- ✕ Uber Direct: Invalid Operation
- ${data.result?.error || "This specific address is outside the Uber delivery radius."}
+ ✕ Delivery Not Available
+ ${userMsg}
`;
- if (submitBtn) submitBtn.disabled = true;
}
}).catch(err => {
console.error("Uber API Error:", err);
+ setSubmitAllowed(false);
msgDiv.className = 'alert alert-warning my-3';
- msgDiv.innerText = "Error connecting to Uber service.";
+ msgDiv.innerText = "Error verifying delivery coverage. Please select Store Pickup.";
});
- }, 500); // 500ms debounce
+ }, 400);
}
// Trigger on any address field change
@@ -463,19 +519,21 @@
}
function verifySelectedAddress() {
- // Find selected address card info
const selectedCard = document.querySelector('input[name="partner_id"]:checked')?.closest('.card');
if (!selectedCard) return;
const addressText = selectedCard.querySelector('address')?.innerText || "";
- const parts = addressText.split('\n').map(p => p.trim());
+ const parts = addressText.split('\n').map(p => p.trim()).filter(p => p);
const street = parts[1] || "";
- const zipMatch = addressText.match(/[A-Z][0-9][A-Z]\s?[0-9][A-Z][0-9]/);
+ const zipMatch = addressText.match(/[A-Z][0-9][A-Z]\s?[0-9][A-Z][0-9]/i);
const zip = zipMatch ? zipMatch[0] : "";
const city = parts[2] ? parts[2].split(' ')[0] : "";
+ const country = parts.length > 0 ? parts[parts.length - 1] : "Canada";
msgBox.className = 'alert alert-info my-3';
- msgBox.innerHTML = "Verifying Uber coverage for this address...";
+ msgBox.innerHTML = 'Verifying delivery coverage for this address...';
+
+ const nextBtns = document.querySelectorAll('.a-submit, button[type="submit"], a.btn-primary:not(.order-type-card)');
fetch('/shop/uber/quote', {
method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -483,18 +541,37 @@
street: street,
zip: zip,
city: city,
+ country: country
} } })
}).then(r => r.json()).then(data => {
if (data.result && data.result.success) {
msgBox.className = 'alert alert-success my-3';
- msgBox.innerHTML = `✓ Uber Delivery Available! Fee: $${data.result.fee}`;
- document.querySelector('button[type="submit"]')?.removeAttribute('disabled');
- setTimeout(() => { window.location.reload(); }, 1200);
+ const feeVal = Number(data.result.fee || 0).toFixed(2);
+ const distText = data.result.distance_km ? ` (${data.result.distance_km} km)` : '';
+ msgBox.innerHTML = `✓ Delivery Available! Delivery Fee: $${feeVal}${distText}`;
+ nextBtns.forEach(btn => {
+ btn.classList.remove('disabled');
+ btn.style.pointerEvents = 'auto';
+ btn.removeAttribute('disabled');
+ });
} else {
msgBox.className = 'alert alert-danger my-3';
- msgBox.innerHTML = `✕ Uber Direct: Invalid Operation
${data.result?.error || "Outside delivery radius."}`;
- document.querySelector('button[type="submit"]')?.setAttribute('disabled', 'disabled');
+ msgBox.innerHTML = `✕ Delivery Not Available
${data.result?.error || "This address is outside our delivery radius."}`;
+ nextBtns.forEach(btn => {
+ btn.classList.add('disabled');
+ btn.style.pointerEvents = 'none';
+ btn.setAttribute('disabled', 'disabled');
+ });
}
+ }).catch(err => {
+ console.error("Uber API Error:", err);
+ msgBox.className = 'alert alert-warning my-3';
+ msgBox.innerHTML = "Error checking delivery coverage.";
+ nextBtns.forEach(btn => {
+ btn.classList.add('disabled');
+ btn.style.pointerEvents = 'none';
+ btn.setAttribute('disabled', 'disabled');
+ });
});
}
diff --git a/addons/dine360_uber/controllers/main.py b/addons/dine360_uber/controllers/main.py
index 38253ec..4301647 100644
--- a/addons/dine360_uber/controllers/main.py
+++ b/addons/dine360_uber/controllers/main.py
@@ -7,14 +7,29 @@ _logger = logging.getLogger(__name__)
class UberWebhookController(http.Controller):
- @http.route('/uber/webhook/delivery', type='json', auth='none', methods=['POST'], csrf=False)
+ @http.route('/uber/webhook/delivery', type='http', auth='public', methods=['POST'], csrf=False)
def uber_delivery_webhook(self, **post):
- """Handle status updates from Uber Direct"""
- data = json.loads(request.httprequest.data)
+ """Handle status updates from Uber Direct via standard HTTP webhook"""
+ try:
+ raw_data = request.httprequest.get_data(as_text=True)
+ data = json.loads(raw_data) if raw_data else {}
+ except Exception as e:
+ _logger.error("Uber Webhook invalid JSON: %s", str(e))
+ return request.make_json_response({'status': 'error', 'message': 'Invalid JSON'}, status=400)
+
_logger.info("Uber Webhook Received: %s", json.dumps(data, indent=2))
- uber_delivery_id = data.get('delivery_id')
- status = data.get('status') # e.g., 'picked_up', 'delivered'
+ uber_delivery_id = (
+ data.get('delivery_id') or
+ data.get('id') or
+ (data.get('data') and data['data'].get('id')) or
+ (data.get('meta') and data['meta'].get('resource_id'))
+ )
+ status = (
+ data.get('status') or
+ (data.get('data') and data['data'].get('status')) or
+ data.get('event_type', '').split('.')[-1]
+ )
if uber_delivery_id:
order = request.env['pos.order'].sudo().search([('uber_delivery_id', '=', uber_delivery_id)], limit=1)
@@ -22,14 +37,28 @@ class UberWebhookController(http.Controller):
# Map Uber status to Odoo status
status_map = {
'pickup': 'pickup',
+ 'pickup_ready': 'pickup',
+ 'courier_assigned': 'pickup',
'pickup_completed': 'delivering',
+ 'in_transit': 'delivering',
'dropoff_completed': 'delivered',
- 'cancelled': 'cancelled'
+ 'delivered': 'delivered',
+ 'cancelled': 'cancelled',
+ 'canceled': 'cancelled'
}
- order.uber_status = status_map.get(status, order.uber_status)
- return {'status': 'success'}
+ new_status = status_map.get(status)
+ if new_status and new_status != order.uber_status:
+ vals = {'uber_status': new_status}
+ if new_status != 'pending':
+ vals['uber_alert_triggered'] = False
+ order.write(vals)
+ request.env['bus.bus'].sudo()._sendone('uber_status_updates', 'status_changed', {
+ 'order_id': order.id,
+ 'new_status': new_status
+ })
+ return request.make_json_response({'status': 'success'})
- return {'status': 'ignored'}
+ return request.make_json_response({'status': 'ignored'})
@@ -37,62 +66,124 @@ 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 with cleaned address formatting"""
+ """Get Uber quote for a website address with cleaned address formatting and boundary validation"""
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'}
+ return {'success': False, 'error': 'Uber delivery is not configured'}
company = request.website.company_id
-
- # Build STRUCTURED pickup address (Object) mapping POS exactly
- pickup_address = {
- "street_address": [company.street or ""],
- "city": company.city or "",
- "state": company.state_id.code if company.state_id else "",
- "zip_code": company.zip or "",
- "country": company.country_id.code or "CA"
- }
+ company_country = company.country_id
# User entered address fields
street = (address_data.get('street') or '').strip()
street2 = (address_data.get('street2') or '').strip()
full_street = f"{street}, {street2}" if street2 else street
-
+ city = (address_data.get('city') or '').strip()
+ zip_code = (address_data.get('zip') or '').strip()
+ country_name = (address_data.get('country') or '').strip()
+ country_id = address_data.get('country_id')
state_input = (address_data.get('state') or '').split('(')[0].strip()
- state_record = request.env['res.country.state'].sudo().search([
- ('country_id.code', '=', 'CA'),
- '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
- ], limit=1)
- state_code = state_record.code if state_record else "ON"
+
+ # Resolve customer country
+ customer_country = None
+ if country_id:
+ try:
+ customer_country = request.env['res.country'].sudo().browse(int(country_id))
+ except Exception:
+ pass
+ if not customer_country and country_name:
+ customer_country = request.env['res.country'].sudo().search([
+ '|', ('name', '=ilike', country_name), ('code', '=ilike', country_name)
+ ], limit=1)
+
+ # 1. Geographic Boundary Check: Customer Country vs Restaurant Operating Country
+ if customer_country and company_country and customer_country.id != company_country.id:
+ _logger.warning("Delivery rejected: Customer country '%s' does not match company country '%s'",
+ customer_country.name, company_country.name)
+ order.sudo()._remove_uber_delivery_fee()
+ return {
+ 'success': False,
+ 'error': f"Delivery is not available outside {company_country.name}. Please select Store Pickup or enter a Canadian address."
+ }
+
+ if country_name and company_country:
+ if country_name.lower() not in [company_country.name.lower(), company_country.code.lower()]:
+ _logger.warning("Delivery rejected: Country name '%s' does not match '%s'", country_name, company_country.name)
+ order.sudo()._remove_uber_delivery_fee()
+ return {
+ 'success': False,
+ 'error': f"Delivery is not available outside {company_country.name}. Please select Store Pickup or enter a Canadian address."
+ }
+
+ # Resolve State
+ state_code = company.state_id.code if company.state_id else "ON"
+ if company_country:
+ state_record = request.env['res.country.state'].sudo().search([
+ ('country_id', '=', company_country.id),
+ '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
+ ], limit=1)
+ if state_record:
+ state_code = state_record.code
+
+ # Build STRUCTURED pickup address dynamically from Company in Settings
+ company_street = [s.strip() for s in [company.street, company.street2] if s and s.strip()]
+ pickup_address = {
+ "street_address": company_street if company_street else [company.name],
+ "city": (company.city or "").strip(),
+ "state": company.state_id.code if company.state_id else "",
+ "zip_code": (company.zip or "").strip(),
+ "country": company.country_id.code if company.country_id else "CA"
+ }
# Build STRUCTURED dropoff address (Object)
dropoff_address = {
"street_address": [full_street],
- "city": address_data.get('city', '').strip(),
+ "city": city,
"state": state_code,
- "zip_code": address_data.get('zip', '').strip(),
- "country": "CA"
+ "zip_code": zip_code,
+ "country": company.country_id.code or "CA"
}
- # For logging, still create strings
- p_str = f"{pickup_address['street_address'][0]}, {pickup_address['city']} {pickup_address['state']}"
- d_str = f"{dropoff_address['street_address'][0]}, {dropoff_address['city']} {dropoff_address['state']}"
- _logger.info("WEBSITE UBER QUOTE (STRUCTURED) -\nPickup: [%s]\nDropoff: [%s]", p_str, d_str)
+ # Structured dict for distance calculator
+ dropoff_calc_data = {
+ 'street': street,
+ 'street2': street2,
+ 'city': city,
+ 'state': state_code,
+ 'zip': zip_code,
+ 'country': customer_country.name if customer_country else (country_name or (company_country.name if company_country else 'Canada'))
+ }
+
+ # For logging, create strings
+ p_str = f"{', '.join(pickup_address['street_address'])}, {pickup_address['city']} {pickup_address['state']} {pickup_address['zip_code']}"
+ d_str = f"{dropoff_address['street_address'][0]}, {dropoff_address['city']} {dropoff_address['state']} {dropoff_address['zip_code']}"
+ _logger.info("WEBSITE UBER QUOTE -\nPickup: [%s]\nDropoff: [%s, %s]", p_str, d_str, dropoff_calc_data['country'])
# POS ENCODING: The POS sends these as JSON-encoded STRINGS
- result = config.get_uber_quote(json.dumps(pickup_address), json.dumps(dropoff_address))
+ result = config.get_uber_quote(
+ json.dumps(pickup_address),
+ json.dumps(dropoff_address),
+ dropoff_data=dropoff_calc_data,
+ company=company
+ )
if result.get('success'):
order.sudo()._add_uber_delivery_fee(result['fee_amount'])
return {
'success': True,
'fee': result['fee_amount'],
+ 'distance_km': result.get('distance_km'),
'eta': result.get('estimated_arrival'),
+ 'is_fallback': result.get('is_fallback', False),
+ 'is_distance_based': result.get('is_distance_based', False),
+ 'is_uber_direct': result.get('is_uber_direct', False),
}
else:
- _logger.warning("Uber Quote Failed: %s", result.get('error'))
+ _logger.warning("Uber Quote Rejected/Failed: %s", result.get('error'))
+ order.sudo()._remove_uber_delivery_fee()
return result
+
diff --git a/addons/dine360_uber/models/pos_order.py b/addons/dine360_uber/models/pos_order.py
index c0386f4..3b44b86 100644
--- a/addons/dine360_uber/models/pos_order.py
+++ b/addons/dine360_uber/models/pos_order.py
@@ -5,6 +5,20 @@ import logging
_logger = logging.getLogger(__name__)
+def _format_e164_phone(phone, default_country_code='+1'):
+ """Format phone number to standard E.164 (e.g. +14165551234) for Uber Direct API"""
+ if not phone:
+ return "+15555555555"
+ s = str(phone).strip()
+ digits = ''.join(c for c in s if c.isdigit())
+ if s.startswith('+'):
+ return f"+{digits}" if digits else "+15555555555"
+ if len(digits) == 10:
+ return f"+1{digits}"
+ if len(digits) == 11 and digits.startswith('1'):
+ return f"+{digits}"
+ return f"+{digits}" if digits else "+15555555555"
+
class PosOrder(models.Model):
_inherit = 'pos.order'
@@ -76,22 +90,25 @@ class PosOrder(models.Model):
# 4. Prepare Payload
company = order.company_id
+ company_country = company.country_id.code or "CA"
+ partner_country = partner.country_id.code or company_country
+
# Pickup Location (Restaurant)
pickup_address = json.dumps({
- "street_address": [company.street],
- "city": company.city,
+ "street_address": [company.street] if company.street else [company.name],
+ "city": company.city or "",
"state": company.state_id.code or "",
- "zip_code": company.zip,
- "country": company.country_id.code or "US"
+ "zip_code": company.zip or "",
+ "country": company_country
})
# Dropoff (Customer)
dropoff_address = json.dumps({
"street_address": [partner.street],
- "city": partner.city,
+ "city": partner.city or "",
"state": partner.state_id.code or "",
- "zip_code": partner.zip,
- "country": partner.country_id.code or "US"
+ "zip_code": partner.zip or "",
+ "country": partner_country
})
items = []
@@ -112,10 +129,10 @@ class PosOrder(models.Model):
payload = {
"pickup_name": company.name,
"pickup_address": pickup_address,
- "pickup_phone_number": company.phone or "+15555555555",
+ "pickup_phone_number": _format_e164_phone(company.phone),
"dropoff_name": partner.name,
"dropoff_address": dropoff_address,
- "dropoff_phone_number": partner.phone or partner.mobile or "+15555555555",
+ "dropoff_phone_number": _format_e164_phone(partner.phone or partner.mobile),
"manifest_items": items,
"test_specifications": {"robo_courier_specification": {"mode": "auto"}} if config.environment == 'sandbox' else None
}
@@ -134,19 +151,13 @@ class PosOrder(models.Model):
data = response.json()
# 6. Process Success
- # Uber API returns fee as integer (cents) usually? Need to check.
- # Docs say 'fee' object with 'amount'
- # Assuming 'fee' field in response is float or int.
- # Careful: Uber often returns amounts in minor units or currency formatted.
- # Standard response has `fee` integer? Let's assume standard float from JSON if parsed, or check specific field.
- # Actually, check `fee` in response.
-
delivery_fee = 0.0
if 'fee' in data:
# Fee is in cents (minor units), convert to major units
delivery_fee = float(data['fee']) / 100.0
order.write({
+ 'delivery_type': 'uber',
'uber_status': 'pending',
'is_uber_order': True,
'uber_delivery_id': data.get('id'),
@@ -169,10 +180,10 @@ class PosOrder(models.Model):
err_code = err_data.get('code', 'unknown_error')
err_msg = err_data.get('message', 'An error occurred with Uber API.')
- if err_code == 'address_undeliverable':
- # Special handling for radius errors (most common issue)
+ if err_code in ['address_undeliverable', 'out_of_range']:
+ # Special handling for radius/coverage errors
details = err_data.get('metadata', {}).get('details', '')
- raise UserError(_("Address Undeliverable: The drop-off location is outside Uber's delivery radius. \n\nDetails: %s") % details)
+ raise UserError(_("Address Undeliverable: The drop-off location is outside Uber's delivery coverage area. \n\nDetails: %s") % (details or err_msg))
raise UserError(_("Uber API Error (%s): %s") % (err_code, err_msg))
except (ValueError, AttributeError):
@@ -184,20 +195,22 @@ class PosOrder(models.Model):
def _add_uber_delivery_fee(self, amount):
"""Add the delivery fee as a line item if not already added"""
config = self.env['uber.config'].search([('active', '=', True)], limit=1)
- if config and config.delivery_product_id:
- # Check if fee line exists
- fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id)
- if not fee_line:
- taxes = config.delivery_product_id.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=config.delivery_product_id, partner=self.partner_id)
- self.write({'lines': [(0, 0, {
- 'product_id': config.delivery_product_id.id,
- 'full_product_name': config.delivery_product_id.name,
- 'price_unit': amount,
- 'qty': 1,
- 'tax_ids': [(6, 0, config.delivery_product_id.taxes_id.ids)],
- 'price_subtotal': taxes['total_excluded'],
- 'price_subtotal_incl': taxes['total_included'],
- })]})
+ if config:
+ product = config._get_or_create_delivery_product()
+ if product:
+ # Check if fee line exists
+ fee_line = self.lines.filtered(lambda l: l.product_id == product)
+ if not fee_line:
+ taxes = product.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=product, partner=self.partner_id)
+ self.write({'lines': [(0, 0, {
+ 'product_id': product.id,
+ 'full_product_name': product.name,
+ 'price_unit': amount,
+ 'qty': 1,
+ 'tax_ids': [(6, 0, product.taxes_id.ids)],
+ 'price_subtotal': taxes['total_excluded'],
+ 'price_subtotal_incl': taxes['total_included'],
+ })]})
def action_cancel_uber_delivery(self):
for order in self:
diff --git a/addons/dine360_uber/models/pos_order_line.py b/addons/dine360_uber/models/pos_order_line.py
index 19a185f..a761424 100644
--- a/addons/dine360_uber/models/pos_order_line.py
+++ b/addons/dine360_uber/models/pos_order_line.py
@@ -7,11 +7,21 @@ class PosOrderLine(models.Model):
"""Override to check if we should request Uber delivery when items are ready"""
res = super(PosOrderLine, self).action_mark_ready()
+ import logging
+ logger = logging.getLogger(__name__)
for line in self:
order = line.order_id
- # Only auto-request if it's marked as an Uber delivery type and not yet requested
- if order.delivery_type == 'uber' and not order.uber_delivery_id:
+ # Auto-request if it's marked as Uber delivery or online delivery order and not yet requested
+ is_delivery = (
+ order.delivery_type == 'uber' or
+ order.fulfilment_type == 'delivery' or
+ order.is_uber_order
+ )
+ if is_delivery and not order.uber_delivery_id:
if order._check_all_lines_ready():
- order.action_request_uber_delivery()
+ try:
+ order.action_request_uber_delivery()
+ except Exception as e:
+ logger.error("KDS Auto Uber dispatch failed for order %s: %s", order.name, str(e))
return res
diff --git a/addons/dine360_uber/models/sale_order.py b/addons/dine360_uber/models/sale_order.py
index 322e21f..2d1ab51 100644
--- a/addons/dine360_uber/models/sale_order.py
+++ b/addons/dine360_uber/models/sale_order.py
@@ -35,12 +35,7 @@ class SaleOrder(models.Model):
carrier = Carrier.search(['|', ('name', 'ilike', 'Uber'), ('product_id', '=', config.delivery_product_id.id if config.delivery_product_id else 0)], limit=1)
if not carrier and config:
- # Fallback product if one isn't set in config
- product = config.delivery_product_id
- if not product:
- product = self.env['product.product'].sudo().search([('name', 'ilike', 'Delivery')], limit=1)
- if not product:
- product = self.env['product.product'].sudo().search([], limit=1) # Last resort
+ product = config._get_or_create_delivery_product()
_logger.info("Uber: Creating new Uber Delivery carrier using product %s", product.name)
carrier = Carrier.create({
@@ -89,3 +84,14 @@ class SaleOrder(models.Model):
# Save everything to DB immediately
self.env.cr.commit()
return True
+
+ def _remove_uber_delivery_fee(self):
+ """Remove delivery fee lines and reset carrier if delivery is unavailable or order type changed"""
+ self.ensure_one()
+ delivery_lines = self.order_line.filtered(lambda l: l.is_delivery or 'Uber' in (l.product_id.name or '') or 'Delivery' in (l.product_id.name or ''))
+ if delivery_lines:
+ _logger.info("Uber: Removing delivery fee lines for order %s", self.name)
+ delivery_lines.sudo().unlink()
+ self.sudo().write({'carrier_id': False})
+ self.env.cr.commit()
+ return True
diff --git a/addons/dine360_uber/models/uber_config.py b/addons/dine360_uber/models/uber_config.py
index fdb9519..bec41d5 100644
--- a/addons/dine360_uber/models/uber_config.py
+++ b/addons/dine360_uber/models/uber_config.py
@@ -4,9 +4,23 @@ import requests
import json
import datetime
import logging
+import math
_logger = logging.getLogger(__name__)
+_GEOCODE_CACHE = {}
+
+def _haversine_distance(lat1, lon1, lat2, lon2):
+ """Calculate great circle distance between two lat/lon coordinates in kilometers"""
+ R = 6371.0 # Earth radius in km
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2.0) ** 2 +
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
+ math.sin(dlon / 2.0) ** 2)
+ c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
+ return R * c
+
class UberConfig(models.Model):
_name = 'uber.config'
_description = 'Uber Integration Configuration'
@@ -19,8 +33,35 @@ class UberConfig(models.Model):
('sandbox', 'Sandbox / Testing'),
('production', 'Production / Live')
], string='Environment', default='sandbox', required=True)
- scope = fields.Char(string='OAuth Scope', default='delivery', help="Space-separated list of scopes, e.g., 'eats.deliveries' or 'delivery'. check your Uber Dashboard.")
+ scope = fields.Char(string='OAuth Scope', default='eats.deliveries', help="OAuth scope for Uber Direct, e.g., 'eats.deliveries'.")
+ # Delivery Pricing Source Selection - Default to Live Uber Direct API
+ delivery_pricing_source = fields.Selection([
+ ('uber', 'Live Uber Direct API Only'),
+ ('distance', 'Manual Distance-Based Calculation (Optional Fallback)'),
+ ('uber_fallback', 'Live Uber API with Distance Fallback')
+ ], string='Delivery Fee Source', default='uber', required=True,
+ help="Delivery charges and delivery availability are fetched strictly from Uber Direct API.")
+
+ # Dynamic Distance and Radius Settings (Optional backup)
+ restaurant_latitude = fields.Float(string='Restaurant Latitude', digits=(10, 7), default=0.0,
+ help="Latitude of the restaurant (leave 0.0 to automatically fetch from Company Settings).")
+ restaurant_longitude = fields.Float(string='Restaurant Longitude', digits=(10, 7), default=0.0,
+ help="Longitude of the restaurant (leave 0.0 to automatically fetch from Company Settings).")
+ max_delivery_radius = fields.Float(string='Max Delivery Radius (km)', default=25.0,
+ help="Maximum allowed delivery distance from restaurant in kilometers.")
+ base_delivery_fee = fields.Float(string='Base Delivery Fee ($)', default=4.99,
+ help="Delivery fee applied for orders within base distance.")
+ base_distance_km = fields.Float(string='Base Distance (km)', default=3.0,
+ help="Distance included in the base delivery fee.")
+ per_km_fee = fields.Float(string='Per KM Fee ($/km)', default=1.00,
+ help="Additional fee per kilometer beyond the base distance.")
+
+ fallback_delivery_fee = fields.Float(string='Fallback Delivery Fee ($)', default=4.99,
+ help="Standard delivery fee applied if Uber API is unavailable and distance calculation is used.")
+ enable_fallback_on_error = fields.Boolean(string='Enable Fallback on API Error', default=False,
+ help="If enabled, distance-based calculation will be used when Uber API returns an error.")
+
timeout_minutes = fields.Integer(string='Driver Assignment Alert Timeout (min)', default=15)
delivery_product_id = fields.Many2one('product.product', string='Uber Delivery Fee Product',
help="Service product used to add Uber charges to the bill.")
@@ -30,6 +71,38 @@ class UberConfig(models.Model):
active = fields.Boolean(default=True)
+ def _get_or_create_delivery_product(self):
+ """Find or create the default Uber Delivery Fee service product"""
+ self.ensure_one()
+ if self.delivery_product_id:
+ return self.delivery_product_id
+
+ Product = self.env['product.product'].sudo()
+ product = Product.search([('name', '=', 'Uber Delivery Fee')], limit=1)
+ if not product:
+ product = Product.search([('name', 'ilike', 'Delivery Fee'), ('type', '=', 'service')], limit=1)
+ if not product:
+ product = Product.search([('name', 'ilike', 'Delivery'), ('type', '=', 'service')], limit=1)
+
+ if not product:
+ product = Product.create({
+ 'name': 'Uber Delivery Fee',
+ 'type': 'service',
+ 'list_price': 0.0,
+ 'available_in_pos': True,
+ 'invoice_policy': 'order',
+ })
+
+ self.sudo().write({'delivery_product_id': product.id})
+ return product
+
+ def write(self, vals):
+ # Invalidate cached token whenever credentials or scope change
+ if any(k in vals for k in ['client_id', 'client_secret', 'scope', 'environment']):
+ vals['access_token'] = False
+ vals['token_expiry'] = False
+ return super().write(vals)
+
def _get_api_base_url(self):
"""Return the API base URL based on environment"""
self.ensure_one()
@@ -48,7 +121,7 @@ class UberConfig(models.Model):
# Clean credentials
client_id = self.client_id.strip() if self.client_id else ''
client_secret = self.client_secret.strip() if self.client_secret else ''
- scope = self.scope.strip() if self.scope else 'delivery'
+ scope = self.scope.strip() if self.scope else 'eats.deliveries'
# Request new token
token_url = "https://login.uber.com/oauth/v2/token"
@@ -86,50 +159,35 @@ class UberConfig(models.Model):
raise UserError(_("Authentication Failed: %s") % error_msg)
def action_test_connection(self):
- """Test connection and auto-detect correct scope if 'invalid_scope' error occurs"""
+ """Test connection with eats.deliveries scope for live Uber Direct delivery"""
self.ensure_one()
+ current_scope = (self.scope or '').strip()
+ if not current_scope or current_scope != 'eats.deliveries':
+ # Default to required delivery scope
+ self.write({'scope': 'eats.deliveries'})
- # 1. Try with current configured scope first
try:
token = self._get_access_token()
- message = f"Connection Successful! Token retrieved using scope: {self.scope}"
+ message = "Connection Successful! Token retrieved successfully using scope 'eats.deliveries'. Uber Direct delivery quotes and couriers are ready."
msg_type = "success"
return self._return_notification(message, msg_type)
except UserError as e:
- # Only attempt auto-fix if error is related to scope
- if "invalid_scope" not in str(e) and "scope" not in str(e).lower():
- return self._return_notification(f"Connection Failed: {str(e)}", "danger")
+ err_str = str(e)
+ if "invalid_scope" in err_str.lower() or "scope" in err_str.lower():
+ message = (
+ "Uber Direct Permission Required: Your Uber Client ID requires the 'eats.deliveries' scope.\n\n"
+ "How to resolve:\n"
+ "1. Go to https://developer.uber.com and log in.\n"
+ "2. Open your registered application.\n"
+ "3. In 'Products', add or request the 'Uber Direct' product to activate 'eats.deliveries'.\n"
+ "Once enabled by Uber, live delivery quotes and dispatches will connect immediately."
+ )
+ msg_type = "warning"
+ else:
+ message = f"Connection Failed: {err_str}"
+ msg_type = "danger"
- # 2. Auto-Discovery: Try known Uber Direct scopes
- potential_scopes = ['delivery', 'eats.deliveries', 'direct.organizations', 'guest.deliveries']
-
- # Remove current scope from list to avoid redundant check
- current = self.scope.strip() if self.scope else ''
- if current in potential_scopes:
- potential_scopes.remove(current)
-
- working_scope = None
-
- for trial_scope in potential_scopes:
- try:
- # Temporarily set scope to test
- self._auth_with_scope(trial_scope)
- working_scope = trial_scope
- break # Found one!
- except Exception:
- continue # Try next
-
- # 3. Handle Result
- if working_scope:
- self.write({'scope': working_scope})
- self._get_access_token() # Refresh token storage
- message = f"Success! We found the correct scope '{working_scope}' and updated your settings."
- msg_type = "success"
- else:
- message = "Connection Failed. Your Client ID does not appear to have ANY Uber Direct permissions (eats.deliveries, delivery, etc). Please enabling the 'Uber Direct' product in your Uber Dashboard."
- msg_type = "danger"
-
- return self._return_notification(message, msg_type)
+ return self._return_notification(message, msg_type)
def _auth_with_scope(self, scope_to_test):
"""Helper to test a specific scope without saving"""
@@ -148,21 +206,173 @@ class UberConfig(models.Model):
response.raise_for_status() # Will raise error if scope invalid
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."))
+ def _geocode_address(self, street, city, state, zip_code, country):
+ """Geocode an address to (lat, lon) using OpenStreetMap Nominatim with memory cache"""
+ cache_key = f"{zip_code}_{city}_{street}_{country}".strip().lower()
+ if cache_key in _GEOCODE_CACHE:
+ return _GEOCODE_CACHE[cache_key]
- api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
- headers = {
- 'Authorization': f'Bearer {access_token}',
- 'Content-Type': 'application/json'
+ headers = {'User-Agent': 'Dine360-Restaurant-Delivery/1.0 (delivery@dine360.com)'}
+ queries = []
+
+ # 1. Full address
+ full_addr = ", ".join(filter(None, [street, city, state, zip_code, country]))
+ if full_addr:
+ queries.append(full_addr)
+
+ # 2. Postal code + Country
+ if zip_code and country:
+ queries.append(f"{zip_code}, {country}")
+
+ # 3. Street + City + Country
+ if street and city and country:
+ queries.append(f"{street}, {city}, {country}")
+
+ # 4. City + State + Country
+ if city and country:
+ queries.append(f"{city}, {state or ''}, {country}".strip())
+
+ for q in queries:
+ try:
+ url = "https://nominatim.openstreetmap.org/search"
+ params = {'q': q, 'format': 'json', 'limit': 1}
+ resp = requests.get(url, params=params, headers=headers, timeout=5)
+ if resp.status_code == 200:
+ data = resp.json()
+ if data and len(data) > 0:
+ lat = float(data[0]['lat'])
+ lon = float(data[0]['lon'])
+ _GEOCODE_CACHE[cache_key] = (lat, lon)
+ return (lat, lon)
+ except Exception as e:
+ _logger.warning("Geocoding query '%s' error: %s", q, e)
+
+ return None
+
+ def _get_company_coordinates(self, company):
+ """Dynamically get coordinates of the restaurant company from Settings without static defaults"""
+ if not company:
+ return None
+
+ # 1. Check if configured explicitly on uber.config
+ if self.restaurant_latitude and self.restaurant_longitude:
+ return (self.restaurant_latitude, self.restaurant_longitude)
+
+ # 2. Check if company partner already has coordinates
+ if company.partner_id and company.partner_id.partner_latitude and company.partner_id.partner_longitude:
+ return (company.partner_id.partner_latitude, company.partner_id.partner_longitude)
+
+ # 3. Dynamically geocode the company's real address from Settings
+ full_street = ", ".join(filter(None, [company.street, company.street2]))
+ coords = self._geocode_address(
+ street=full_street,
+ city=company.city or '',
+ state=company.state_id.name or company.state_id.code or '',
+ zip_code=company.zip or '',
+ country=company.country_id.name or 'Canada'
+ )
+ if coords and company.partner_id:
+ try:
+ company.partner_id.sudo().write({
+ 'partner_latitude': coords[0],
+ 'partner_longitude': coords[1]
+ })
+ except Exception:
+ pass
+ return coords
+
+ def calculate_distance_quote(self, dropoff_data, company=None):
+ """Calculate distance and quote fee based on geographic coordinates"""
+ self.ensure_one()
+ if not dropoff_data:
+ return {'success': False, 'error': _("No delivery address provided.")}
+
+ street = (dropoff_data.get('street') or '').strip()
+ street2 = (dropoff_data.get('street2') or '').strip()
+ full_street = f"{street} {street2}".strip()
+ city = (dropoff_data.get('city') or '').strip()
+ state = (dropoff_data.get('state') or '').strip()
+ zip_code = (dropoff_data.get('zip') or '').strip()
+ country = (dropoff_data.get('country') or '').strip()
+
+ # Check country against restaurant company country
+ if company and company.country_id:
+ company_country = company.country_id
+ if country and country.lower() not in [company_country.name.lower(), company_country.code.lower()]:
+ return {
+ 'success': False,
+ 'error': _("Delivery is not available outside %s. Please select Store Pickup or enter a local delivery address.") % company_country.name
+ }
+
+ # Restaurant coordinates fetched dynamically from Company in Settings
+ coords_rest = self._get_company_coordinates(company)
+ if not coords_rest:
+ return {
+ 'success': False,
+ 'error': _("Restaurant address in Settings -> Companies is missing or cannot be located.")
+ }
+ rest_lat, rest_lon = coords_rest
+
+ # Geocode dropoff
+ coords = self._geocode_address(full_street, city, state, zip_code, country)
+ if not coords:
+ _logger.warning("Could not geocode customer address: %s, %s, %s, %s", full_street, city, zip_code, country)
+ if zip_code and zip_code.isdigit() and len(zip_code) == 6:
+ return {
+ 'success': False,
+ 'error': _("The postal code '%s' is not valid for Canadian delivery. Please check your address or select Store Pickup.") % zip_code
+ }
+ return {
+ 'success': False,
+ 'error': _("Unable to verify delivery address location. Please check your street and postal code, or select Store Pickup.")
+ }
+
+ cust_lat, cust_lon = coords
+ distance_km = _haversine_distance(rest_lat, rest_lon, cust_lat, cust_lon)
+ _logger.info("Delivery distance to %s, %s: %.2f km (Max radius: %.1f km)", city, zip_code, distance_km, self.max_delivery_radius)
+
+ if distance_km > self.max_delivery_radius:
+ return {
+ 'success': False,
+ 'distance_km': round(distance_km, 1),
+ 'error': _("This address is outside our delivery area (%.1f km away. Maximum delivery radius is %.0f km). Please select Store Pickup.") % (distance_km, self.max_delivery_radius)
+ }
+
+ # Calculate fee
+ if distance_km <= self.base_distance_km:
+ fee = self.base_delivery_fee
+ else:
+ fee = self.base_delivery_fee + (distance_km - self.base_distance_km) * self.per_km_fee
+
+ fee = round(max(fee, 0.0), 2)
+ return {
+ 'success': True,
+ 'fee_amount': fee,
+ 'distance_km': round(distance_km, 1),
+ 'currency': 'CAD',
+ 'is_fallback': True,
+ 'is_distance_based': True,
+ 'quote_id': f"DIST_{int(datetime.datetime.now().timestamp())}"
}
- # Ensure at least one dummy item if none provided (Uber Direct sometimes requires this)
+ def get_uber_quote(self, pickup_address, dropoff_address, items=None, dropoff_data=None, company=None):
+ """Get delivery quote from Uber API or selected delivery pricing source"""
+ self.ensure_one()
+ pricing_source = self.delivery_pricing_source or 'uber'
+
+ # If manually configured to distance calculation only
+ if pricing_source == 'distance':
+ if dropoff_data:
+ return self.calculate_distance_quote(dropoff_data, company)
+ return {'success': False, 'error': _("Delivery address missing.")}
+
+ customer_id = self.customer_id
+ if not customer_id:
+ if pricing_source == 'uber_fallback' and dropoff_data:
+ return self.calculate_distance_quote(dropoff_data, company)
+ return {'success': False, 'error': _("Uber Customer ID is missing in configuration.")}
+
+ # Ensure at least one dummy item if none provided
if not items:
items = [{
"name": "Food Delivery",
@@ -176,9 +386,15 @@ class UberConfig(models.Model):
"manifest_items": items
}
- _logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2))
-
try:
+ access_token = self._get_access_token()
+ api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
+ headers = {
+ 'Authorization': f'Bearer {access_token}',
+ 'Content-Type': 'application/json'
+ }
+
+ _logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2))
response = requests.post(api_url, headers=headers, json=payload)
_logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text)
@@ -197,11 +413,48 @@ class UberConfig(models.Model):
details = " ".join([e.get('message', '') for e in data['errors']])
if details:
msg = f"{msg} {details}"
-
+
+ code = data.get('code', '')
+
+ # 1. Scope missing error
+ if response.status_code == 401 or 'eats.deliveries' in msg.lower() or 'unauthorized' in code.lower():
+ scope_err = _(
+ "Uber Direct Scope Required: Your Uber Client ID requires the 'eats.deliveries' permission. "
+ "Please go to your Uber Developer Dashboard (https://developer.uber.com), "
+ "open your application, and enable the 'Uber Direct' product to activate live quotes."
+ )
+ if pricing_source == 'uber_fallback' and dropoff_data:
+ dist_result = self.calculate_distance_quote(dropoff_data, company)
+ dist_result['warning'] = scope_err
+ return dist_result
+ return {
+ 'success': False,
+ 'error': scope_err,
+ 'code': 'unauthorized',
+ 'raw_error': data
+ }
+
+ # 2. Out of range / undeliverable error from Uber (Live coverage check)
+ if any(x in code.lower() or x in msg.lower() for x in ['out_of_range', 'out of range', 'outside', 'undeliverable', 'address_undeliverable', 'coverage', 'unserviceable']):
+ return {
+ 'success': False,
+ 'error': _("Uber Direct: This address is outside Uber's delivery coverage area. Please choose Store Pickup."),
+ 'code': code,
+ 'raw_error': data
+ }
+
+ # 3. Fallback only if explicitly configured
+ if pricing_source == 'uber_fallback' and dropoff_data:
+ _logger.warning("Uber API failed (%s). Falling back to distance-based quote.", msg)
+ dist_result = self.calculate_distance_quote(dropoff_data, company)
+ if dist_result.get('warning') is None:
+ dist_result['warning'] = msg
+ return dist_result
+
return {
'success': False,
- 'error': msg,
- 'code': data.get('code', 'unknown'),
+ 'error': f"Uber API Error: {msg}",
+ 'code': code,
'raw_error': data
}
@@ -212,13 +465,20 @@ class UberConfig(models.Model):
'success': True,
'quote_id': data.get('id'),
'fee_amount': float(fee_cents) / 100.0,
- 'currency': data.get('currency_code', 'USD'),
+ 'currency': data.get('currency_code', 'CAD'),
'estimated_arrival': data.get('estimated_arrival'),
+ 'is_fallback': False,
+ 'is_uber_direct': True,
'raw': data
}
except Exception as e:
_logger.exception("Uber Quote API Exception")
- return {'success': False, 'error': str(e)}
+ if pricing_source == 'uber_fallback' and dropoff_data:
+ dist_result = self.calculate_distance_quote(dropoff_data, company)
+ if dist_result.get('warning') is None:
+ dist_result['warning'] = str(e)
+ return dist_result
+ return {'success': False, 'error': f"Uber Connection Error: {str(e)}"}
def _return_notification(self, message, msg_type):
return {
diff --git a/addons/dine360_uber/views/uber_config_views.xml b/addons/dine360_uber/views/uber_config_views.xml
index 1c4717b..86a75fa 100644
--- a/addons/dine360_uber/views/uber_config_views.xml
+++ b/addons/dine360_uber/views/uber_config_views.xml
@@ -30,14 +30,31 @@
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+