add Uber Direct delivery integration models, controllers, and views
This commit is contained in:
parent
43e7007a93
commit
d0024c6b83
@ -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;
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 && container.style.display === 'none') return;
|
if (container && 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 && data.result.success) {
|
if (data.result && 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 && data.result.success) {
|
if (data.result && 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,8 +33,35 @@ 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',
|
||||||
help="Service product used to add Uber charges to the bill.")
|
help="Service product used to add Uber charges to the bill.")
|
||||||
@ -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)
|
||||||
|
|
||||||
@ -197,11 +413,48 @@ class UberConfig(models.Model):
|
|||||||
details = " ".join([e.get('message', '') for e in data['errors']])
|
details = " ".join([e.get('message', '') for e in data['errors']])
|
||||||
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 {
|
||||||
|
|||||||
@ -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 & Fees">
|
<group string="Automation & 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 & 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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user