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 @@ + - + - + + + + + + + + + + + + + + + + +