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