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,7 +29,9 @@ class Dine360WebsiteSaleOnline(WebsiteSale):
if carriers: if carriers:
order.carrier_id = carriers[0].id 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) order.set_delivery_line(carriers[0], price)
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post) return super(Dine360WebsiteSaleOnline, self).shop_payment(**post)

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,70 +508,150 @@
font-family: inherit; font-family: inherit;
} }
} }
}
.css_quantity { /* Quantity and Add to cart styling on product detail page */
border: 2px solid #eee !important; .css_quantity {
border-radius: 50px !important; border: 2px solid #e0e0e0 !important;
overflow: visible !important; border-radius: 50px !important;
display: inline-flex; overflow: hidden !important;
margin-right: 15px; display: inline-flex !important;
background: white !important; margin-right: 15px !important;
height: 45px !important; margin-bottom: 10px !important;
align-items: center; 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 {
.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;
border: none !important; border: none !important;
border-radius: 50px !important; background: transparent !important;
padding: 12px 40px !important; padding: 0 16px !important;
font-weight: 800 !important; color: #222222 !important;
text-transform: uppercase !important; font-size: 14px !important;
letter-spacing: 1px !important; height: 100% !important;
transition: all 0.3s !important; min-height: 44px !important;
box-shadow: 0 10px 20px rgba(254, 205, 79, 0.2) !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: #e5b945 !important; background: #f5f5f5 !important;
transform: translateY(-2px) !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;
} }
} }

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()
state_record = request.env['res.country.state'].sudo().search([
('country_id.code', '=', 'CA'), # Resolve customer country
'|', ('name', '=ilike', state_input), ('code', '=ilike', state_input) customer_country = None
], limit=1) if country_id:
state_code = state_record.code if state_record else "ON" 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) # 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,20 +195,22 @@ 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:
# Check if fee line exists product = config._get_or_create_delivery_product()
fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id) if product:
if not fee_line: # Check if fee line exists
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) fee_line = self.lines.filtered(lambda l: l.product_id == product)
self.write({'lines': [(0, 0, { if not fee_line:
'product_id': config.delivery_product_id.id, taxes = product.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=product, partner=self.partner_id)
'full_product_name': config.delivery_product_id.name, self.write({'lines': [(0, 0, {
'price_unit': amount, 'product_id': product.id,
'qty': 1, 'full_product_name': product.name,
'tax_ids': [(6, 0, config.delivery_product_id.taxes_id.ids)], 'price_unit': amount,
'price_subtotal': taxes['total_excluded'], 'qty': 1,
'price_subtotal_incl': taxes['total_included'], '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): def action_cancel_uber_delivery(self):
for order in self: for order in self:

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():
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 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,50 +159,35 @@ 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"
"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 return self._return_notification(message, msg_type)
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)
def _auth_with_scope(self, scope_to_test): def _auth_with_scope(self, scope_to_test):
"""Helper to test a specific scope without saving""" """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 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
}
# 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: 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 {
'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 { return {
'success': False, 'success': False,
'error': msg, 'error': f"Uber API Error: {msg}",
'code': data.get('code', 'unknown'), '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>