add Uber Direct delivery integration models, controllers, and views

This commit is contained in:
Alaguraj0361 2026-09-12 21:09:43 +05:30
parent 43e7007a93
commit d0024c6b83
10 changed files with 805 additions and 241 deletions

View File

@ -289,24 +289,29 @@
color: var(--secondary-color, #2BB1A5) !important; color: var(--secondary-color, #2BB1A5) !important;
} }
/* Product Details Quantity Input */
.css_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 { .css_quantity input.quantity {
color: #000000 !important; color: #000000 !important;
background-color: #ffffff !important; background-color: #ffffff !important;
opacity: 1 !important; opacity: 1 !important;
width: 60px !important; width: 55px !important;
max-width: 60px !important; max-width: 65px !important;
height: 45px !important; height: 45px !important;
line-height: 45px !important; line-height: 45px !important;
font-weight: 600 !important; font-weight: 700 !important;
font-size: 18px !important; font-size: 18px !important;
padding: 0 !important; padding: 0 !important;
text-align: center !important; text-align: center !important;
border: 1px solid #000000 !important; border: none !important;
display: inline-block !important; display: inline-block !important;
visibility: visible !important; visibility: visible !important;
} }

View File

@ -12,6 +12,9 @@ class Dine360OnlineOrders(http.Controller):
'fulfilment_type': service_mode, 'fulfilment_type': service_mode,
'order_source': 'online' '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 return True
class Dine360WebsiteSaleOnline(WebsiteSale): class Dine360WebsiteSaleOnline(WebsiteSale):
@ -26,6 +29,8 @@ class Dine360WebsiteSaleOnline(WebsiteSale):
if carriers: if carriers:
order.carrier_id = carriers[0].id order.carrier_id = carriers[0].id
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 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) order.set_delivery_line(carriers[0], price)

View File

@ -9,7 +9,7 @@
--food-shadow: 0 10px 25px rgba(0, 0, 0, 0.06); --food-shadow: 0 10px 25px rgba(0, 0, 0, 0.06);
} }
.form-control, .form-control:not(.quantity):not([name="add_qty"]),
.form-select { .form-select {
background-color: #f8f9fa !important; background-color: #f8f9fa !important;
border: 1px solid #e5b945 !important; border: 1px solid #e5b945 !important;
@ -508,51 +508,103 @@
font-family: inherit; font-family: inherit;
} }
} }
}
/* Quantity and Add to cart styling on product detail page */
.css_quantity { .css_quantity {
border: 2px solid #eee !important; border: 2px solid #e0e0e0 !important;
border-radius: 50px !important; border-radius: 50px !important;
overflow: visible !important; overflow: hidden !important;
display: inline-flex; display: inline-flex !important;
margin-right: 15px; margin-right: 15px !important;
background: white !important; margin-bottom: 10px !important;
height: 45px !important; background: #ffffff !important;
align-items: center; height: 48px !important;
align-items: center !important;
width: auto !important;
max-width: fit-content !important;
vertical-align: middle !important;
box-shadow: none !important;
.btn { .btn {
border: none !important; border: none !important;
background: transparent !important; background: transparent !important;
padding: 0 15px !important; padding: 0 16px !important;
color: #333 !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 { &:hover {
background: #f9f9f9 !important; background: #f5f5f5 !important;
color: #000000 !important;
}
&:active,
&:focus {
background: #eeeeee !important;
box-shadow: none !important;
} }
} }
.quantity { .quantity,
input.quantity,
input[name="add_qty"] {
border: none !important; border: none !important;
width: 80px !important; border-radius: 0 !important;
max-width: 80px !important; width: 55px !important;
min-width: 50px !important;
max-width: 65px !important;
text-align: center !important; text-align: center !important;
font-weight: 900 !important; font-weight: 800 !important;
font-size: 20px !important; font-size: 18px !important;
background: #ffffff !important; background: #ffffff !important;
color: #000000 !important; color: #000000 !important;
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
height: 45px !important; height: 100% !important;
min-height: 40px !important;
max-height: 48px !important;
display: inline-block !important; display: inline-block !important;
vertical-align: middle !important; vertical-align: middle !important;
line-height: 45px !important; line-height: 48px !important;
box-shadow: none !important; box-shadow: none !important;
outline: none !important; outline: none !important;
appearance: none !important; appearance: none !important;
-webkit-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 { #add_to_cart {
@ -560,19 +612,47 @@
color: #000000 !important; color: #000000 !important;
border: none !important; border: none !important;
border-radius: 50px !important; border-radius: 50px !important;
padding: 12px 40px !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-weight: 800 !important;
font-size: 15px !important;
text-transform: uppercase !important; text-transform: uppercase !important;
letter-spacing: 1px !important; letter-spacing: 0.8px !important;
transition: all 0.3s !important; transition: all 0.3s ease !important;
box-shadow: 0 10px 20px rgba(254, 205, 79, 0.2) !important; box-shadow: 0 8px 20px rgba(229, 185, 69, 0.25) !important;
text-decoration: none !important;
margin-bottom: 10px !important;
&:hover { &:hover {
background: #e5b945 !important; background: #d4a838 !important;
color: #000000 !important;
transform: translateY(-2px) !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;
}
} }
/* Hide Duplicate Odoo Elements on Product Page */ /* Hide Duplicate Odoo Elements on Product Page */

View File

@ -137,8 +137,27 @@
function initUnifiedForm() { function initUnifiedForm() {
const hiddenType = document.getElementById('hidden_fulfilment_type'); const hiddenType = document.getElementById('hidden_fulfilment_type');
const msgDiv = document.getElementById('uber_message'); 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 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 // Force Name, Email, and Phone to be required
['name', 'email', 'phone'].forEach(fieldName => { ['name', 'email', 'phone'].forEach(fieldName => {
@ -159,14 +178,12 @@
document.querySelectorAll('form.checkout_autoformat input, form.checkout_autoformat select').forEach(input => { document.querySelectorAll('form.checkout_autoformat input, form.checkout_autoformat select').forEach(input => {
const handler = function() { const handler = function() {
const val = this.value ? this.value.trim() : ""; 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 !== ""; const isFilled = val && val !== "0" && val !== "";
if (isFilled) { if (isFilled) {
this.classList.remove('is-invalid'); this.classList.remove('is-invalid');
this.style.borderColor = ''; this.style.borderColor = '';
// Update the summary error list dynamically
const form = this.closest('form'); const form = this.closest('form');
const errorDiv = document.getElementById('Shivasakthi_val_error'); const errorDiv = document.getElementById('Shivasakthi_val_error');
if (errorDiv) { if (errorDiv) {
@ -189,17 +206,34 @@
}); });
// 3. Custom Validation on Submit // 3. Custom Validation on Submit
if (submitBtn) { const submitBtns = document.querySelectorAll('.a-submit, button[type="submit"], .btn-primary');
submitBtn.addEventListener('click', function(e) { 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 = `<strong>Delivery Not Available:</strong> 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'); const form = document.querySelector('form.checkout_autoformat');
if (!form) return; if (!form) return;
// Reset errors before checking
let missingFields = []; let missingFields = [];
const requiredInputs = form.querySelectorAll('input[required]:not([type="hidden"]), select[required]'); const requiredInputs = form.querySelectorAll('input[required]:not([type="hidden"]), select[required]');
requiredInputs.forEach(input => { requiredInputs.forEach(input => {
// Skip hidden fields (from pickup/delivery toggle or company hide)
const container = input.closest('div[class*="col-"], .mb-3'); const container = input.closest('div[class*="col-"], .mb-3');
if (container &amp;&amp; container.style.display === 'none') return; if (container &amp;&amp; container.style.display === 'none') return;
if (input.name === 'company_name' || input.name === 'vat') return; if (input.name === 'company_name' || input.name === 'vat') return;
@ -235,7 +269,7 @@
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}); });
} });
function getAddressContainers() { function getAddressContainers() {
const addressNames = ['street', 'street2', 'city', 'zip', 'country_id', 'state_id']; const addressNames = ['street', 'street2', 'city', 'zip', 'country_id', 'state_id'];
@ -271,8 +305,8 @@
containers.forEach(c => c.style.display = 'none'); containers.forEach(c => c.style.display = 'none');
if (addrHeader) addrHeader.style.display = 'none'; if (addrHeader) addrHeader.style.display = 'none';
if (msgDiv) msgDiv.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'); const form = document.querySelector('form.checkout_autoformat');
if (form) { if (form) {
const fill = (n, v) => { const fill = (n, v) => {
@ -294,7 +328,6 @@
fill('state_id', '1'); fill('state_id', '1');
} }
// Prevent HTML5 validation from silently blocking submission on hidden fields
containers.forEach(c => { containers.forEach(c => {
c.querySelectorAll('input, select').forEach(i => i.removeAttribute('required')); c.querySelectorAll('input, select').forEach(i => i.removeAttribute('required'));
}); });
@ -303,7 +336,6 @@
if (addrHeader) addrHeader.style.display = ''; if (addrHeader) addrHeader.style.display = '';
if (msgDiv) msgDiv.style.display = ''; if (msgDiv) msgDiv.style.display = '';
// Restore original values if user switches back to delivery
const form = document.querySelector('form.checkout_autoformat'); const form = document.querySelector('form.checkout_autoformat');
if (form) { if (form) {
const restore = (n) => { const restore = (n) => {
@ -321,7 +353,6 @@
restore('state_id'); restore('state_id');
} }
// Restore required
['street', 'city', 'zip', 'country_id', 'state_id'].forEach(name => { ['street', 'city', 'zip', 'country_id', 'state_id'].forEach(name => {
const i = document.querySelector(`[name="${name}"]`); const i = document.querySelector(`[name="${name}"]`);
if (i) i.setAttribute('required', 'required'); if (i) i.setAttribute('required', 'required');
@ -329,7 +360,6 @@
checkUber(); checkUber();
} }
// SYNC with server immediately
fetch('/shop/update_service_mode', { fetch('/shop/update_service_mode', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ params: { service_mode: type } }) body: JSON.stringify({ params: { service_mode: type } })
@ -340,64 +370,90 @@
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
if (hiddenType.value !== 'delivery') return; if (hiddenType.value !== 'delivery') return;
const street = document.querySelector('input[name="street"]')?.value; const street = document.querySelector('input[name="street"]')?.value?.trim();
const zip = document.querySelector('input[name="zip"]')?.value; const zip = document.querySelector('input[name="zip"]')?.value?.trim();
if (!street || !zip) { if (!street || !zip) {
if (msgDiv) msgDiv.style.display = 'none'; if (msgDiv) msgDiv.style.display = 'none';
return; 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) { if (msgDiv) {
msgDiv.className = 'alert alert-info my-3'; msgDiv.className = 'alert alert-info my-3';
msgDiv.style.display = ''; msgDiv.style.display = '';
msgDiv.innerText = "Checking Uber coverage..."; msgDiv.innerHTML = '<i class="fa fa-spinner fa-spin me-2"></i>Verifying delivery coverage...';
} }
fetch('/shop/uber/quote', { fetch('/shop/uber/quote', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ params: { address_data: { body: JSON.stringify({ params: { address_data: {
street: street, street: street,
street2: document.querySelector('input[name="street2"]')?.value, street2: document.querySelector('input[name="street2"]')?.value?.trim(),
city: document.querySelector('input[name="city"]')?.value, city: document.querySelector('input[name="city"]')?.value?.trim(),
zip: zip, zip: zip,
country: document.querySelector('select[name="country_id"] option:checked')?.text || 'Canada', country: countryText,
state: document.querySelector('select[name="state_id"] option:checked')?.text country_id: countryId,
state: stateText,
state_id: stateId
} } }) } } })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
msgDiv.classList.remove('fadeIn'); msgDiv.classList.remove('fadeIn');
void msgDiv.offsetWidth; // Trigger reflow for animation void msgDiv.offsetWidth;
msgDiv.classList.add('fadeIn'); msgDiv.classList.add('fadeIn');
if (data.result &amp;&amp; data.result.success) { if (data.result &amp;&amp; data.result.success) {
setSubmitAllowed(true);
msgDiv.className = 'alert alert-success my-3 animated fadeIn'; msgDiv.className = 'alert alert-success my-3 animated fadeIn';
msgDiv.style.display = ''; msgDiv.style.display = '';
const feeVal = Number(data.result.fee || 0).toFixed(2);
const feeText = `Uber Delivery Fee: $${feeVal}`;
msgDiv.innerHTML = `<div class="d-flex align-items-center"> msgDiv.innerHTML = `<div class="d-flex align-items-center">
<i class="fa fa-check-circle me-3" style="font-size: 24px;"></i> <i class="fa fa-check-circle me-3" style="font-size: 24px; color: #00A67E;"></i>
<div> <div>
<strong>✓ Delivery Available!</strong><br/> <strong style="color: #00A67E;">✓ Delivery Available!</strong><br/>
Uber Delivery Fee: $${data.result.fee} (Distance Based) <span>${feeText}</span>
</div> </div>
</div>`; </div>`;
if (submitBtn) submitBtn.disabled = false;
} else { } else {
setSubmitAllowed(false);
msgDiv.className = 'alert alert-danger my-3 animated fadeIn'; msgDiv.className = 'alert alert-danger my-3 animated fadeIn';
msgDiv.style.display = ''; msgDiv.style.display = '';
let userMsg = data.result?.error || "This address is outside our delivery area.";
msgDiv.innerHTML = `<div class="d-flex align-items-center"> msgDiv.innerHTML = `<div class="d-flex align-items-center">
<i class="fa fa-exclamation-triangle me-3" style="font-size: 24px;"></i> <i class="fa fa-exclamation-triangle me-3" style="font-size: 24px; color: #dc3545;"></i>
<div> <div>
<strong>✕ Uber Direct: Invalid Operation</strong><br/> <strong style="color: #dc3545;">✕ Delivery Not Available</strong><br/>
${data.result?.error || "This specific address is outside the Uber delivery radius."} <span>${userMsg}</span>
</div> </div>
</div>`; </div>`;
if (submitBtn) submitBtn.disabled = true;
} }
}).catch(err => { }).catch(err => {
console.error("Uber API Error:", err); console.error("Uber API Error:", err);
setSubmitAllowed(false);
msgDiv.className = 'alert alert-warning my-3'; 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 // Trigger on any address field change
@ -463,19 +519,21 @@
} }
function verifySelectedAddress() { function verifySelectedAddress() {
// Find selected address card info
const selectedCard = document.querySelector('input[name="partner_id"]:checked')?.closest('.card'); const selectedCard = document.querySelector('input[name="partner_id"]:checked')?.closest('.card');
if (!selectedCard) return; if (!selectedCard) return;
const addressText = selectedCard.querySelector('address')?.innerText || ""; 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 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 zip = zipMatch ? zipMatch[0] : "";
const city = parts[2] ? parts[2].split(' ')[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.className = 'alert alert-info my-3';
msgBox.innerHTML = "Verifying Uber coverage for this address..."; msgBox.innerHTML = '<i class="fa fa-spinner fa-spin me-2"></i>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', { fetch('/shop/uber/quote', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
@ -483,18 +541,37 @@
street: street, street: street,
zip: zip, zip: zip,
city: city, city: city,
country: country
} } }) } } })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
if (data.result &amp;&amp; data.result.success) { if (data.result &amp;&amp; data.result.success) {
msgBox.className = 'alert alert-success my-3'; msgBox.className = 'alert alert-success my-3';
msgBox.innerHTML = `<strong>✓ Uber Delivery Available!</strong> Fee: $${data.result.fee}`; const feeVal = Number(data.result.fee || 0).toFixed(2);
document.querySelector('button[type="submit"]')?.removeAttribute('disabled'); const distText = data.result.distance_km ? ` (${data.result.distance_km} km)` : '';
setTimeout(() => { window.location.reload(); }, 1200); msgBox.innerHTML = `<strong>✓ Delivery Available!</strong> Delivery Fee: $${feeVal}${distText}`;
nextBtns.forEach(btn => {
btn.classList.remove('disabled');
btn.style.pointerEvents = 'auto';
btn.removeAttribute('disabled');
});
} else { } else {
msgBox.className = 'alert alert-danger my-3'; msgBox.className = 'alert alert-danger my-3';
msgBox.innerHTML = `<strong>✕ Uber Direct: Invalid Operation</strong><br/>${data.result?.error || "Outside delivery radius."}`; msgBox.innerHTML = `<strong>✕ Delivery Not Available</strong><br/>${data.result?.error || "This address is outside our delivery radius."}`;
document.querySelector('button[type="submit"]')?.setAttribute('disabled', 'disabled'); 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');
});
}); });
} }

View File

@ -7,14 +7,29 @@ _logger = logging.getLogger(__name__)
class UberWebhookController(http.Controller): 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): def uber_delivery_webhook(self, **post):
"""Handle status updates from Uber Direct""" """Handle status updates from Uber Direct via standard HTTP webhook"""
data = json.loads(request.httprequest.data) 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)) _logger.info("Uber Webhook Received: %s", json.dumps(data, indent=2))
uber_delivery_id = data.get('delivery_id') uber_delivery_id = (
status = data.get('status') # e.g., 'picked_up', 'delivered' 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: if uber_delivery_id:
order = request.env['pos.order'].sudo().search([('uber_delivery_id', '=', uber_delivery_id)], limit=1) 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 # Map Uber status to Odoo status
status_map = { status_map = {
'pickup': 'pickup', 'pickup': 'pickup',
'pickup_ready': 'pickup',
'courier_assigned': 'pickup',
'pickup_completed': 'delivering', 'pickup_completed': 'delivering',
'in_transit': 'delivering',
'dropoff_completed': 'delivered', 'dropoff_completed': 'delivered',
'cancelled': 'cancelled' 'delivered': 'delivered',
'cancelled': 'cancelled',
'canceled': 'cancelled'
} }
order.uber_status = status_map.get(status, order.uber_status) new_status = status_map.get(status)
return {'status': 'success'} 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) @http.route('/shop/uber/quote', type='json', auth='public', website=True, csrf=False)
def uber_quote(self, address_data, **post): 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() order = request.website.sale_get_order()
if not order: if not order:
return {'success': False, 'error': 'No active order'} return {'success': False, 'error': 'No active order'}
config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1) config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config: if not config:
return {'success': False, 'error': 'Uber not configured'} return {'success': False, 'error': 'Uber delivery is not configured'}
company = request.website.company_id company = request.website.company_id
company_country = company.country_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"
}
# User entered address fields # User entered address fields
street = (address_data.get('street') or '').strip() street = (address_data.get('street') or '').strip()
street2 = (address_data.get('street2') or '').strip() street2 = (address_data.get('street2') or '').strip()
full_street = f"{street}, {street2}" if street2 else street 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_input = (address_data.get('state') or '').split('(')[0].strip()
# 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([ state_record = request.env['res.country.state'].sudo().search([
('country_id.code', '=', 'CA'), ('country_id', '=', company_country.id),
'|', ('name', '=ilike', state_input), ('code', '=ilike', state_input) '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
], limit=1) ], limit=1)
state_code = state_record.code if state_record else "ON" 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) # Build STRUCTURED dropoff address (Object)
dropoff_address = { dropoff_address = {
"street_address": [full_street], "street_address": [full_street],
"city": address_data.get('city', '').strip(), "city": city,
"state": state_code, "state": state_code,
"zip_code": address_data.get('zip', '').strip(), "zip_code": zip_code,
"country": "CA" "country": company.country_id.code or "CA"
} }
# For logging, still create strings # Structured dict for distance calculator
p_str = f"{pickup_address['street_address'][0]}, {pickup_address['city']} {pickup_address['state']}" dropoff_calc_data = {
d_str = f"{dropoff_address['street_address'][0]}, {dropoff_address['city']} {dropoff_address['state']}" 'street': street,
_logger.info("WEBSITE UBER QUOTE (STRUCTURED) -\nPickup: [%s]\nDropoff: [%s]", p_str, d_str) '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 # 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'): if result.get('success'):
order.sudo()._add_uber_delivery_fee(result['fee_amount']) order.sudo()._add_uber_delivery_fee(result['fee_amount'])
return { return {
'success': True, 'success': True,
'fee': result['fee_amount'], 'fee': result['fee_amount'],
'distance_km': result.get('distance_km'),
'eta': result.get('estimated_arrival'), '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: 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 return result

View File

@ -5,6 +5,20 @@ import logging
_logger = logging.getLogger(__name__) _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): class PosOrder(models.Model):
_inherit = 'pos.order' _inherit = 'pos.order'
@ -76,22 +90,25 @@ class PosOrder(models.Model):
# 4. Prepare Payload # 4. Prepare Payload
company = order.company_id 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 Location (Restaurant)
pickup_address = json.dumps({ pickup_address = json.dumps({
"street_address": [company.street], "street_address": [company.street] if company.street else [company.name],
"city": company.city, "city": company.city or "",
"state": company.state_id.code or "", "state": company.state_id.code or "",
"zip_code": company.zip, "zip_code": company.zip or "",
"country": company.country_id.code or "US" "country": company_country
}) })
# Dropoff (Customer) # Dropoff (Customer)
dropoff_address = json.dumps({ dropoff_address = json.dumps({
"street_address": [partner.street], "street_address": [partner.street],
"city": partner.city, "city": partner.city or "",
"state": partner.state_id.code or "", "state": partner.state_id.code or "",
"zip_code": partner.zip, "zip_code": partner.zip or "",
"country": partner.country_id.code or "US" "country": partner_country
}) })
items = [] items = []
@ -112,10 +129,10 @@ class PosOrder(models.Model):
payload = { payload = {
"pickup_name": company.name, "pickup_name": company.name,
"pickup_address": pickup_address, "pickup_address": pickup_address,
"pickup_phone_number": company.phone or "+15555555555", "pickup_phone_number": _format_e164_phone(company.phone),
"dropoff_name": partner.name, "dropoff_name": partner.name,
"dropoff_address": dropoff_address, "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, "manifest_items": items,
"test_specifications": {"robo_courier_specification": {"mode": "auto"}} if config.environment == 'sandbox' else None "test_specifications": {"robo_courier_specification": {"mode": "auto"}} if config.environment == 'sandbox' else None
} }
@ -134,19 +151,13 @@ class PosOrder(models.Model):
data = response.json() data = response.json()
# 6. Process Success # 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 delivery_fee = 0.0
if 'fee' in data: if 'fee' in data:
# Fee is in cents (minor units), convert to major units # Fee is in cents (minor units), convert to major units
delivery_fee = float(data['fee']) / 100.0 delivery_fee = float(data['fee']) / 100.0
order.write({ order.write({
'delivery_type': 'uber',
'uber_status': 'pending', 'uber_status': 'pending',
'is_uber_order': True, 'is_uber_order': True,
'uber_delivery_id': data.get('id'), 'uber_delivery_id': data.get('id'),
@ -169,10 +180,10 @@ class PosOrder(models.Model):
err_code = err_data.get('code', 'unknown_error') err_code = err_data.get('code', 'unknown_error')
err_msg = err_data.get('message', 'An error occurred with Uber API.') err_msg = err_data.get('message', 'An error occurred with Uber API.')
if err_code == 'address_undeliverable': if err_code in ['address_undeliverable', 'out_of_range']:
# Special handling for radius errors (most common issue) # Special handling for radius/coverage errors
details = err_data.get('metadata', {}).get('details', '') 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)) raise UserError(_("Uber API Error (%s): %s") % (err_code, err_msg))
except (ValueError, AttributeError): except (ValueError, AttributeError):
@ -184,17 +195,19 @@ class PosOrder(models.Model):
def _add_uber_delivery_fee(self, amount): def _add_uber_delivery_fee(self, amount):
"""Add the delivery fee as a line item if not already added""" """Add the delivery fee as a line item if not already added"""
config = self.env['uber.config'].search([('active', '=', True)], limit=1) config = self.env['uber.config'].search([('active', '=', True)], limit=1)
if config and config.delivery_product_id: if config:
product = config._get_or_create_delivery_product()
if product:
# Check if fee line exists # Check if fee line exists
fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id) fee_line = self.lines.filtered(lambda l: l.product_id == product)
if not fee_line: 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) taxes = product.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=product, partner=self.partner_id)
self.write({'lines': [(0, 0, { self.write({'lines': [(0, 0, {
'product_id': config.delivery_product_id.id, 'product_id': product.id,
'full_product_name': config.delivery_product_id.name, 'full_product_name': product.name,
'price_unit': amount, 'price_unit': amount,
'qty': 1, 'qty': 1,
'tax_ids': [(6, 0, config.delivery_product_id.taxes_id.ids)], 'tax_ids': [(6, 0, product.taxes_id.ids)],
'price_subtotal': taxes['total_excluded'], 'price_subtotal': taxes['total_excluded'],
'price_subtotal_incl': taxes['total_included'], 'price_subtotal_incl': taxes['total_included'],
})]}) })]})

View File

@ -7,11 +7,21 @@ class PosOrderLine(models.Model):
"""Override to check if we should request Uber delivery when items are ready""" """Override to check if we should request Uber delivery when items are ready"""
res = super(PosOrderLine, self).action_mark_ready() res = super(PosOrderLine, self).action_mark_ready()
import logging
logger = logging.getLogger(__name__)
for line in self: for line in self:
order = line.order_id order = line.order_id
# Only auto-request if it's marked as an Uber delivery type and not yet requested # Auto-request if it's marked as Uber delivery or online delivery order and not yet requested
if order.delivery_type == 'uber' and not order.uber_delivery_id: 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(): if order._check_all_lines_ready():
try:
order.action_request_uber_delivery() 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 return res

View File

@ -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) 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: if not carrier and config:
# Fallback product if one isn't set in config product = config._get_or_create_delivery_product()
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
_logger.info("Uber: Creating new Uber Delivery carrier using product %s", product.name) _logger.info("Uber: Creating new Uber Delivery carrier using product %s", product.name)
carrier = Carrier.create({ carrier = Carrier.create({
@ -89,3 +84,14 @@ class SaleOrder(models.Model):
# Save everything to DB immediately # Save everything to DB immediately
self.env.cr.commit() self.env.cr.commit()
return True 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

View File

@ -4,9 +4,23 @@ import requests
import json import json
import datetime import datetime
import logging import logging
import math
_logger = logging.getLogger(__name__) _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): class UberConfig(models.Model):
_name = 'uber.config' _name = 'uber.config'
_description = 'Uber Integration Configuration' _description = 'Uber Integration Configuration'
@ -19,7 +33,34 @@ class UberConfig(models.Model):
('sandbox', 'Sandbox / Testing'), ('sandbox', 'Sandbox / Testing'),
('production', 'Production / Live') ('production', 'Production / Live')
], string='Environment', default='sandbox', required=True) ], 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) timeout_minutes = fields.Integer(string='Driver Assignment Alert Timeout (min)', default=15)
delivery_product_id = fields.Many2one('product.product', string='Uber Delivery Fee Product', delivery_product_id = fields.Many2one('product.product', string='Uber Delivery Fee Product',
@ -30,6 +71,38 @@ class UberConfig(models.Model):
active = fields.Boolean(default=True) 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): def _get_api_base_url(self):
"""Return the API base URL based on environment""" """Return the API base URL based on environment"""
self.ensure_one() self.ensure_one()
@ -48,7 +121,7 @@ class UberConfig(models.Model):
# Clean credentials # Clean credentials
client_id = self.client_id.strip() if self.client_id else '' client_id = self.client_id.strip() if self.client_id else ''
client_secret = self.client_secret.strip() if self.client_secret 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 # Request new token
token_url = "https://login.uber.com/oauth/v2/token" token_url = "https://login.uber.com/oauth/v2/token"
@ -86,47 +159,32 @@ class UberConfig(models.Model):
raise UserError(_("Authentication Failed: %s") % error_msg) raise UserError(_("Authentication Failed: %s") % error_msg)
def action_test_connection(self): 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() 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: try:
token = self._get_access_token() 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" msg_type = "success"
return self._return_notification(message, msg_type) return self._return_notification(message, msg_type)
except UserError as e: except UserError as e:
# Only attempt auto-fix if error is related to scope err_str = str(e)
if "invalid_scope" not in str(e) and "scope" not in str(e).lower(): if "invalid_scope" in err_str.lower() or "scope" in err_str.lower():
return self._return_notification(f"Connection Failed: {str(e)}", "danger") message = (
"Uber Direct Permission Required: Your Uber Client ID requires the 'eats.deliveries' scope.\n\n"
# 2. Auto-Discovery: Try known Uber Direct scopes "How to resolve:\n"
potential_scopes = ['delivery', 'eats.deliveries', 'direct.organizations', 'guest.deliveries'] "1. Go to https://developer.uber.com and log in.\n"
"2. Open your registered application.\n"
# Remove current scope from list to avoid redundant check "3. In 'Products', add or request the 'Uber Direct' product to activate 'eats.deliveries'.\n"
current = self.scope.strip() if self.scope else '' "Once enabled by Uber, live delivery quotes and dispatches will connect immediately."
if current in potential_scopes: )
potential_scopes.remove(current) msg_type = "warning"
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: 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." message = f"Connection Failed: {err_str}"
msg_type = "danger" msg_type = "danger"
return self._return_notification(message, msg_type) return self._return_notification(message, msg_type)
@ -148,21 +206,173 @@ class UberConfig(models.Model):
response.raise_for_status() # Will raise error if scope invalid response.raise_for_status() # Will raise error if scope invalid
return True return True
def get_uber_quote(self, pickup_address, dropoff_address, items=None): def _geocode_address(self, street, city, state, zip_code, country):
"""Get delivery quote from Uber API""" """Geocode an address to (lat, lon) using OpenStreetMap Nominatim with memory cache"""
self.ensure_one() cache_key = f"{zip_code}_{city}_{street}_{country}".strip().lower()
access_token = self._get_access_token() if cache_key in _GEOCODE_CACHE:
customer_id = self.customer_id return _GEOCODE_CACHE[cache_key]
if not customer_id:
raise UserError(_("Uber Customer ID is missing in configuration."))
api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes" headers = {'User-Agent': 'Dine360-Restaurant-Delivery/1.0 (delivery@dine360.com)'}
headers = { queries = []
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json' # 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
} }
# Ensure at least one dummy item if none provided (Uber Direct sometimes requires this) # 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())}"
}
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: if not items:
items = [{ items = [{
"name": "Food Delivery", "name": "Food Delivery",
@ -176,9 +386,15 @@ class UberConfig(models.Model):
"manifest_items": items "manifest_items": items
} }
_logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2))
try: 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) response = requests.post(api_url, headers=headers, json=payload)
_logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text) _logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text)
@ -198,10 +414,47 @@ class UberConfig(models.Model):
if details: if details:
msg = f"{msg} {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 { return {
'success': False, 'success': False,
'error': msg, 'error': scope_err,
'code': data.get('code', 'unknown'), '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': f"Uber API Error: {msg}",
'code': code,
'raw_error': data 'raw_error': data
} }
@ -212,13 +465,20 @@ class UberConfig(models.Model):
'success': True, 'success': True,
'quote_id': data.get('id'), 'quote_id': data.get('id'),
'fee_amount': float(fee_cents) / 100.0, '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'), 'estimated_arrival': data.get('estimated_arrival'),
'is_fallback': False,
'is_uber_direct': True,
'raw': data 'raw': data
} }
except Exception as e: except Exception as e:
_logger.exception("Uber Quote API Exception") _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): def _return_notification(self, message, msg_type):
return { return {

View File

@ -30,14 +30,31 @@
<field name="scope" placeholder="e.g. eats.deliveries"/> <field name="scope" placeholder="e.g. eats.deliveries"/>
</group> </group>
<group string="Settings"> <group string="Settings">
<field name="delivery_pricing_source" widget="radio"/>
<field name="environment"/> <field name="environment"/>
<field name="active"/> <field name="active"/>
</group> </group>
</group> </group>
<group string="Automation &amp; Fees"> <group string="Automation &amp; Fees">
<group> <group string="Driver Dispatch">
<field name="timeout_minutes"/> <field name="timeout_minutes"/>
<field name="delivery_product_id" context="{'default_type': 'service'}"/> <field name="delivery_product_id" context="{'default_type': 'service'}" placeholder="Auto-created if empty"/>
</group>
<group string="Fallback Settings" invisible="delivery_pricing_source == 'uber'">
<field name="enable_fallback_on_error"/>
<field name="fallback_delivery_fee"/>
</group>
</group>
<group string="Manual Distance &amp; Radius (Inactive under Live Uber Mode)" invisible="delivery_pricing_source == 'uber'">
<group string="Restaurant Location">
<field name="restaurant_latitude"/>
<field name="restaurant_longitude"/>
<field name="max_delivery_radius"/>
</group>
<group string="Distance Pricing ($)">
<field name="base_delivery_fee"/>
<field name="base_distance_km"/>
<field name="per_km_fee"/>
</group> </group>
</group> </group>
</sheet> </sheet>