implement Uber integration, website customization, KDS enhancements, and POS receipt extensions

This commit is contained in:
Alaguraj0361 2026-09-01 22:25:54 +05:30
parent eea113d2d5
commit 8d00d3a5dc
19 changed files with 1004 additions and 587 deletions

View File

@ -75,23 +75,25 @@ patch(PosStore.prototype, {
console.log(`[KDS] Setting up channel: ${channel}`); console.log(`[KDS] Setting up channel: ${channel}`);
try { try {
const busService = this.env.services.bus_service; const busService = this.bus_service || this.env?.services?.bus_service;
busService.addChannel(channel); if (busService) {
console.log("[KDS] Channel added successfully"); busService.addChannel(channel);
console.log("[KDS] Channel added successfully");
busService.addEventListener("notification", (event) => { busService.addEventListener("notification", (event) => {
console.log("[KDS] *** NOTIFICATION RECEIVED ***", event); console.log("[KDS] *** NOTIFICATION RECEIVED ***", event);
const notifications = event.detail || []; const notifications = event.detail || [];
for (const notif of notifications) { for (const notif of notifications) {
if (notif.type === 'kds_update') { if (notif.type === 'kds_update') {
console.log("[KDS] *** KDS UPDATE ***", notif.payload); console.log("[KDS] *** KDS UPDATE ***", notif.payload);
this._handleKdsUpdate(notif.payload); this._handleKdsUpdate(notif.payload);
}
} }
} });
});
console.log("[KDS] Listener registered successfully"); console.log("[KDS] Listener registered successfully");
}
} catch (error) { } catch (error) {
console.error("[KDS] ERROR:", error); console.error("[KDS] ERROR:", error);
} }

View File

@ -182,16 +182,38 @@ class SaleOrderOnline(models.Model):
], order='id desc', limit=1) ], order='id desc', limit=1)
if pos_order: if pos_order:
pos_order.write({ pos_vals = {
'is_online_order': True, 'is_online_order': True,
'online_order_status': 'pending', 'online_order_status': 'pending',
'sale_order_id': sale_order.id, 'sale_order_id': sale_order.id,
'online_order_date': fields.Datetime.now(), 'online_order_date': fields.Datetime.now(),
'order_source': sale_order.order_source or 'online', 'order_source': sale_order.order_source or 'online',
'fulfilment_type': sale_order.fulfilment_type or 'pickup', 'fulfilment_type': sale_order.fulfilment_type or 'pickup',
# 'delivery_time': sale_order.delivery_time, }
# 'uber_eta': sale_order.delivery_time,
}) # Check if this is an Uber delivery order
is_uber = False
if sale_order.fulfilment_type == 'delivery':
is_uber = True
elif sale_order.carrier_id and 'uber' in (sale_order.carrier_id.name or '').lower():
is_uber = True
elif any('uber' in (l.product_id.name or '').lower() or 'delivery' in (l.product_id.name or '').lower() for l in sale_order.order_line if getattr(l, 'is_delivery', False)):
is_uber = True
if is_uber:
pos_vals['is_uber_order'] = True
pos_vals['delivery_type'] = 'uber'
shipping = sale_order.partner_shipping_id or sale_order.partner_id
if shipping:
pos_vals['delivery_partner_id'] = shipping.id
pos_vals['delivery_street'] = f"{shipping.street or ''} {shipping.street2 or ''}".strip()
pos_vals['delivery_city'] = shipping.city or ''
pos_vals['delivery_zip'] = shipping.zip or ''
pos_vals['delivery_phone'] = shipping.phone or shipping.mobile or ''
if hasattr(sale_order, 'amount_delivery') and sale_order.amount_delivery > 0:
pos_vals['uber_delivery_fee'] = sale_order.amount_delivery
pos_order.write(pos_vals)
# Link back to sale order # Link back to sale order
sale_order.write({'pos_order_id': pos_order.id}) sale_order.write({'pos_order_id': pos_order.id})

View File

@ -4,17 +4,11 @@ import { patch } from "@web/core/utils/patch";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { ChannelPanel } from "./channel_panel"; import { ChannelPanel } from "./channel_panel";
/** // Register ChannelPanel directly on ProductScreen components
* Patch ProductScreen to: ProductScreen.components = {
* 1. Register ChannelPanel as a component ...ProductScreen.components,
* 2. Expose showChannelPanel computed property to the template ChannelPanel,
*/ };
patch(ProductScreen, {
components: {
...ProductScreen.components,
ChannelPanel,
},
});
patch(ProductScreen.prototype, { patch(ProductScreen.prototype, {
get showChannelPanel() { get showChannelPanel() {

View File

@ -1,268 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve"> <templates xml:space="preserve">
<!-- Non-destructive extension of OrderReceipt to display Channel and Delivery details -->
<t t-name="dine360_order_channels.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1"> <t t-name="dine360_order_channels.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('pos-receipt')]" position="replace"> <xpath expr="//div[hasclass('before-footer')]" position="before">
<div class="pos-receipt custom-restaurant-receipt"> <div class="channel-info border-top pt-2 mt-2" style="font-size: 12px; font-family: inherit;">
<style> <div t-if="props.data.order_source" class="d-flex justify-content-between mb-1">
.custom-restaurant-receipt { <span>Order Source:</span>
width: 100%; <span class="fw-bold text-uppercase" t-esc="props.data.order_source_label"/>
font-family: 'Arial', sans-serif; </div>
color: #333; <div t-if="props.data.fulfilment_type" class="d-flex justify-content-between mb-1">
background: #fff; <span>Fulfilment:</span>
padding: 10px 5px; <span class="fw-bold text-uppercase" t-esc="props.data.fulfilment_type_label"/>
}
.custom-restaurant-receipt .main-title {
color: #E67E22;
text-align: center;
font-size: 22px;
font-weight: bold;
margin: 0 0 15px 0;
}
.custom-restaurant-receipt .receipt-header {
text-align: center;
margin-bottom: 10px;
}
.custom-restaurant-receipt .receipt-header img {
max-width: 120px;
margin-bottom: 10px;
}
.custom-restaurant-receipt .company-name {
font-weight: bold;
font-size: 16px;
margin-bottom: 4px;
}
.custom-restaurant-receipt .company-details {
font-size: 12px;
line-height: 1.4;
}
.custom-restaurant-receipt .subtitle {
font-style: italic;
font-weight: bold;
text-align: center;
margin: 15px 0;
font-size: 14px;
}
.custom-restaurant-receipt .info-row {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
font-size: 12px;
}
.custom-restaurant-receipt .info-box {
background: #FDEBD0;
padding: 6px 10px;
border-radius: 2px;
flex: 1;
margin: 0 4px;
text-align: center;
font-weight: bold;
}
.custom-restaurant-receipt .info-box span {
font-weight: normal;
display: block;
margin-top: 2px;
}
.custom-restaurant-receipt .info-box:first-child { margin-left: 0; }
.custom-restaurant-receipt .info-box:last-child { margin-right: 0; }
.custom-restaurant-receipt table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
font-size: 12px;
}
.custom-restaurant-receipt th {
background: #E67E22;
color: white;
padding: 8px 6px;
text-align: right;
}
.custom-restaurant-receipt th:first-child { text-align: left; }
.custom-restaurant-receipt td {
padding: 8px 6px;
text-align: right;
border-bottom: 1px solid #eee;
}
.custom-restaurant-receipt td:first-child { text-align: left; }
.custom-restaurant-receipt .totals-section {
width: 70%;
float: right;
margin-bottom: 15px;
font-size: 13px;
}
.custom-restaurant-receipt .totals-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
}
.custom-restaurant-receipt .totals-row.bold {
font-weight: bold;
}
.custom-restaurant-receipt .totals-row .val-box {
background: #FDEBD0;
padding: 4px 10px;
min-width: 80px;
text-align: right;
border-radius: 2px;
}
.custom-restaurant-receipt .clearfix::after {
content: "";
clear: both;
display: table;
}
.custom-restaurant-receipt .footer-slogan {
text-align: center;
font-weight: bold;
font-style: italic;
font-size: 16px;
margin-top: 20px;
clear: both;
}
.custom-restaurant-receipt .channel-info {
font-size: 11px;
margin-top: 15px;
border-top: 1px dashed #ccc;
padding-top: 10px;
clear: both;
}
</style>
<div class="main-title">Restaurant Receipt</div>
<div class="receipt-header">
<img t-attf-src="/web/image?model=res.company&amp;id={{props.data.headerData.company.id}}&amp;field=logo" alt="Logo"/>
<div class="company-name" t-esc="props.data.headerData.company.name"/>
<div class="company-details">
<div t-if="props.data.headerData.company.contact_address" t-esc="props.data.headerData.company.contact_address"/>
<div>
<t t-if="props.data.headerData.company.phone">Tel: <t t-esc="props.data.headerData.company.phone"/> </t>
<t t-if="props.data.headerData.company.email"> | <t t-esc="props.data.headerData.company.email"/></t>
</div>
<div t-if="props.data.headerData.company.website" t-esc="props.data.headerData.company.website"/>
</div>
</div> </div>
<div class="subtitle">"Authentic Indian Food At its Finest!"</div> <t t-if="props.data.fulfilment_type === 'delivery'">
<div class="mt-2 pt-2 border-top">
<div class="fw-bold mb-1">DELIVERY ADDRESS:</div>
<div t-if="props.data.delivery_street" t-esc="props.data.delivery_street"/>
<div t-if="props.data.delivery_city || props.data.delivery_zip">
<t t-esc="props.data.delivery_city"/> <t t-esc="props.data.delivery_zip"/>
</div>
<div t-if="props.data.delivery_phone">Phone: <t t-esc="props.data.delivery_phone"/></div>
<div t-if="props.data.delivery_notes" class="mt-1 small" style="font-style: italic;">
Note: <t t-esc="props.data.delivery_notes"/>
</div>
</div>
</t>
<div class="info-row"> <div t-if="props.data.social_ref" class="mt-1 small">
<div class="info-box"> Ref: <t t-esc="props.data.social_ref"/>
Receipt No.
<span t-esc="props.data.name"/>
</div>
<div class="info-box">
Date &amp; Time
<span t-esc="props.data.date"/>
</div>
<div class="info-box" t-if="props.data.headerData.cashier">
Cashier
<span t-esc="props.data.headerData.cashier"/>
</div>
</div> </div>
<div t-if="props.data.whatsapp_number" class="mt-1 small">
<table> WhatsApp: <t t-esc="props.data.whatsapp_number"/>
<thead>
<tr>
<th>List of Items</th>
<th>Qty</th>
<th>Unit Cost</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.data.orderlines" t-as="line" t-key="line.id">
<td>
<t t-esc="line.productName"/>
<div t-if="line.customerNote" class="small italic text-muted mt-1">
<i class="fa fa-sticky-note me-1"/> <t t-esc="line.customerNote"/>
</div>
</td>
<td><t t-esc="line.qty"/></td>
<td><t t-esc="props.formatCurrency(line.price)"/></td>
<td><t t-esc="props.formatCurrency(line.price_display)"/></td>
</tr>
</tbody>
</table>
<div class="clearfix">
<div class="totals-section">
<div class="totals-row">
<span>Total Amount</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.total_without_tax)"/>
</div>
<div class="totals-row" t-if="props.data.amount_tax > 0">
<span>VAT</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.amount_tax)"/>
</div>
<div class="totals-row bold">
<span>Net Amount</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.amount_total)"/>
</div>
<t t-if="props.data.rounding_applied">
<div class="totals-row">
<span>Rounding</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.rounding_applied)"/>
</div>
<div class="totals-row bold">
<span>To Pay</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.amount_total + props.data.rounding_applied)"/>
</div>
</t>
<!-- Payment Lines -->
<div class="totals-row" t-foreach="props.data.paymentlines" t-as="line" t-key="line.name">
<span t-esc="line.name"/>
<span class="val-box" t-esc="props.formatCurrency(line.amount, false)"/>
</div>
<div class="totals-row" t-if="props.data.change > 0">
<span>Change</span>
<span class="val-box" t-esc="props.formatCurrency(props.data.change)"/>
</div>
</div>
</div>
<div class="footer-slogan">Eat As much As You Like!</div>
<!-- Order Channels Info -->
<div class="channel-info">
<div t-if="props.data.order_source" class="d-flex justify-content-between mb-1">
<span>Order Source:</span>
<span class="fw-bold text-uppercase" t-esc="props.data.order_source_label"/>
</div>
<div t-if="props.data.fulfilment_type" class="d-flex justify-content-between mb-1">
<span>Fulfilment:</span>
<span class="fw-bold text-uppercase" t-esc="props.data.fulfilment_type_label"/>
</div>
<t t-if="props.data.fulfilment_type === 'delivery'">
<div class="mt-2 pt-2 border-top">
<div class="fw-bold mb-1">DELIVERY ADDRESS:</div>
<div t-if="props.data.delivery_street" t-esc="props.data.delivery_street"/>
<div t-if="props.data.delivery_city || props.data.delivery_zip">
<t t-esc="props.data.delivery_city"/> <t t-esc="props.data.delivery_zip"/>
</div>
<div t-if="props.data.delivery_phone">Phone: <t t-esc="props.data.delivery_phone"/></div>
<div t-if="props.data.delivery_notes" class="mt-1 small" style="font-style: italic;">
Note: <t t-esc="props.data.delivery_notes"/>
</div>
</div>
</t>
<div t-if="props.data.social_ref" class="mt-1 small">
Ref: <t t-esc="props.data.social_ref"/>
</div>
<div t-if="props.data.whatsapp_number" class="mt-1 small">
WhatsApp: <t t-esc="props.data.whatsapp_number"/>
</div>
</div>
<div class="pos-receipt-order-data text-center mt-3" t-if="props.data.footer" style="white-space:pre-line; font-size: 11px;">
<t t-esc="props.data.footer" />
</div>
<div class="pos-receipt-order-data mt-3 text-center" style="font-size: 10px;">
<p>Powered by Dine360</p>
</div> </div>
</div> </div>
</xpath> </xpath>

View File

@ -1,4 +1,4 @@
/** @odoo-module */ /** @odoo-module */
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen";
@ -393,7 +393,10 @@ patch(ReceiptScreen.prototype, {
return false; return false;
} }
} }
return super.printReceipt(...arguments); if (typeof super.printReceipt === "function") {
return super.printReceipt(...arguments);
}
return false;
}, },
}); });
@ -445,8 +448,8 @@ async function buildEscPosKitchenTicket(order, pos, changes) {
patch(PosStore.prototype, { patch(PosStore.prototype, {
async sendOrderToPrinter(order) { async sendOrderToPrinter(order) {
if (this.config.use_qz_printer && this.config.qz_kitchen_printer_name) { if (this.config.use_qz_printer && this.config.qz_kitchen_printer_name) {
const changes = order.computeChanges(); const changes = typeof order.computeChanges === "function" ? order.computeChanges() : {};
if (Object.keys(changes.categories).length > 0) { if (changes && changes.categories && Object.keys(changes.categories).length > 0) {
try { try {
if (!window.qz) { if (!window.qz) {
console.error("QZ Tray library not loaded."); console.error("QZ Tray library not loaded.");
@ -472,11 +475,13 @@ patch(PosStore.prototype, {
return true; return true;
} catch (err) { } catch (err) {
console.error("QZ Tray Kitchen Print Error:", err); console.error("QZ Tray Kitchen Print Error:", err);
// Fallback or show error
} }
} }
return true; return true;
} }
return super.sendOrderToPrinter(...arguments); if (typeof super.sendOrderToPrinter === "function") {
return super.sendOrderToPrinter(...arguments);
}
return true;
}, },
}); });

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from odoo import models, fields from odoo import models, fields, api
class ProductTemplate(models.Model): class ProductTemplate(models.Model):
_inherit = 'product.template' _inherit = 'product.template'
@ -9,3 +9,18 @@ class ProductTemplate(models.Model):
default=False, default=False,
help='Check this to show this product in Popular Deals section on Homepage' help='Check this to show this product in Popular Deals section on Homepage'
) )
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if 'name' in vals and vals.get('name'):
name = vals.get('name', '')
if not vals.get('website_meta_title'):
vals['website_meta_title'] = f"{name} | Shiva Sakthi Restaurant"
if not vals.get('website_meta_description'):
desc = vals.get('description_sale') or vals.get('description') or ''
if desc:
vals['website_meta_description'] = desc[:150]
else:
vals['website_meta_description'] = f"Enjoy delicious, authentic {name} at Shiva Sakthi Restaurant, Mississauga. Order online now for dine-in, take-out, or catering."
return super(ProductTemplate, self).create(vals_list)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,4 +1,4 @@
/* Global Font Family Overrides */ /* Global Font Family Overrides */
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
font-family: 'Bebas Neue', sans-serif !important; font-family: 'Bebas Neue', sans-serif !important;
} }
@ -576,9 +576,9 @@ section h2.display-4 {
} }
&:focus { &:focus {
border-color: rgba(43, 177, 165, 0.3) !important; border-color: rgba(255, 184, 0, 0.5) !important;
background-color: white !important; background-color: white !important;
box-shadow: 0 5px 15px rgba(43, 177, 165, 0.1) !important; box-shadow: 0 5px 15px rgba(255, 184, 0, 0.15) !important;
} }
} }
@ -1576,7 +1576,7 @@ footer#bottom {
/* Global Red Color Removal & Theme Alignment */ /* Global Red Color Removal & Theme Alignment */
.text-danger { .text-danger {
color: #ffb800 !important; // Replace red text with theme teal color: #ffb800 !important; // Replace red text with theme gold
} }
.bg-danger, .bg-danger,
@ -1597,7 +1597,7 @@ footer#bottom {
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover { &:hover {
background-color: rgba(43, 177, 165, 0.1) !important; background-color: rgba(255, 184, 0, 0.1) !important;
color: #ffb800 !important; color: #ffb800 !important;
} }
@ -1615,7 +1615,7 @@ footer#bottom {
&:hover { &:hover {
color: white !important; color: white !important;
background-color: #259a8f !important; background-color: #e0a200 !important;
} }
} }
} }
@ -1626,7 +1626,7 @@ footer#bottom {
/* Link hover color */ /* Link hover color */
body:not(.editor_enable):not(.o_edit_mode) a:hover, body:not(.editor_enable):not(.o_edit_mode) a:hover,
body:not(.editor_enable):not(.o_edit_mode) a:hover * { body:not(.editor_enable):not(.o_edit_mode) a:hover * {
color: #529791 !important; color: #ffb800 !important;
} }
/* Button hover color */ /* Button hover color */
@ -2398,9 +2398,9 @@ body.o_edit_mode {
.enjoy-btn-outline { .enjoy-btn-outline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border: 2px solid #ffb800; border: 2px solid #ffb800 !important;
color: #ffb800; color: #ffb800 !important;
background: transparent; background: transparent !important;
padding: 10px 28px; padding: 10px 28px;
font-family: 'Bebas Neue', sans-serif !important; font-family: 'Bebas Neue', sans-serif !important;
font-size: 15px; font-size: 15px;
@ -2410,8 +2410,9 @@ body.o_edit_mode {
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
background: #ffb800; background: #ffb800 !important;
color: #111111; color: #111111 !important;
border-color: #ffb800 !important;
} }
} }
@ -3187,5 +3188,9 @@ body.o_edit_mode {
} }
/* Enforce section title font size and weight */ /* Enforce section title font size and weight */
section h2.display-4, section h2.display-5, section h2.testi-main-title, section h2.thali-subtitle, section h2 { font-size: 56px !important; font-weight: 400 !important; } section h2.display-4, section h2.display-5, section h2.testi-main-title, section h2.thali-subtitle, section h2 {
font-size: clamp(32px, 5vw, 56px) !important;
font-weight: 400 !important;
line-height: 1.25 !important;
}

View File

@ -368,138 +368,182 @@
<!-- Menu Card Container --> <!-- Menu Card Container -->
<div class="menu-card-container mb-5"> <div class="menu-card-container mb-5">
<div class="row g-0"> <t t-if="pop_products">
<!-- Column 1 --> <t t-set="left_products" t-value="pop_products[:int((len(pop_products) + 1) / 2)]"/>
<div class="col-lg-6 menu-column border-end-lg"> <t t-set="right_products" t-value="pop_products[int((len(pop_products) + 1) / 2):]"/>
<div class="d-flex flex-column gap-4 p-4 p-md-5"> <div class="row g-0">
<!-- Item 1 --> <!-- Column 1 -->
<div class="menu-list-item d-flex align-items-center"> <div class="col-lg-6 menu-column border-end-lg">
<img src="/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png" class="rounded-circle me-3" alt="Medhu Vada"/> <div class="d-flex flex-column gap-4 p-4 p-md-5">
<div class="flex-grow-1"> <t t-foreach="left_products" t-as="product">
<div class="d-flex align-items-baseline"> <div class="menu-list-item d-flex align-items-center">
<h5 class="fw-bold mb-0 text-dark">MEDHU VADA</h5> <img t-att-src="website.image_url(product, 'image_128') if product.image_128 else '/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png'" class="rounded-circle me-3" t-att-alt="product.name" style="width: 75px; height: 75px; object-fit: cover; flex-shrink: 0;"/>
<span class="flex-grow-1 border-bottom mx-2"/> <div class="flex-grow-1">
<span class="fw-bold price-text">$7.99</span> <div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark" t-out="product.name"/>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text" t-field="product.list_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</div>
<p class="small text-muted mb-0" t-if="product.description_sale" t-out="product.description_sale"/>
</div>
</div>
</t>
</div>
</div>
<!-- Column 2 -->
<div class="col-lg-6 menu-column">
<div class="d-flex flex-column gap-4 p-4 p-md-5">
<t t-foreach="right_products" t-as="product">
<div class="menu-list-item d-flex align-items-center">
<img t-att-src="website.image_url(product, 'image_128') if product.image_128 else '/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png'" class="rounded-circle me-3" t-att-alt="product.name" style="width: 75px; height: 75px; object-fit: cover; flex-shrink: 0;"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark" t-out="product.name"/>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text" t-field="product.list_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</div>
<p class="small text-muted mb-0" t-if="product.description_sale" t-out="product.description_sale"/>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
<t t-else="">
<div class="row g-0">
<!-- Column 1 -->
<div class="col-lg-6 menu-column border-end-lg">
<div class="d-flex flex-column gap-4 p-4 p-md-5">
<!-- Item 1 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png" class="rounded-circle me-3" alt="Medhu Vada"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">MEDHU VADA</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$7.99</span>
</div>
<p class="small text-muted mb-0">The popular soft and savory appetizer</p>
</div>
</div>
<!-- Item 2 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-dosa.png" class="rounded-circle me-3" alt="Garlic Naan"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">GARLIC NAAN</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$1.99</span>
</div>
<p class="small text-muted mb-0">Naan infused with garlic</p>
</div>
</div>
<!-- Item 3 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/fiery-fish-curry.png" class="rounded-circle me-3" alt="King Fish Fry"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">KING FISH FRY</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$17.99</span>
</div>
<p class="small text-muted mb-0">House Special - Premium King Fish Slice Fry</p>
</div>
</div>
<!-- Item 4 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-desserts.png" class="rounded-circle me-3" alt="Paruppu Pradhaman"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">PARUPPU PRADHAMAN</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$3.99</span>
</div>
<p class="small text-muted mb-0">south style Payasam made with coconut, Lentils and Jaggery</p>
</div>
</div>
<!-- Item 5 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png" class="rounded-circle me-3" alt="Egg Bhurji"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">EGG BHURJI (PODIMAS)</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$11.99</span>
</div>
<p class="small text-muted mb-0">Masala Egg Bhurji (Podimas)</p>
</div> </div>
<p class="small text-muted mb-0">The popular soft and savory appetizer</p>
</div> </div>
</div> </div>
<!-- Item 2 --> </div>
<div class="menu-list-item d-flex align-items-center"> <!-- Column 2 -->
<img src="/dine360_theme_shivasakthi/static/src/img/cat-dosa.png" class="rounded-circle me-3" alt="Garlic Naan"/> <div class="col-lg-6 menu-column">
<div class="flex-grow-1"> <div class="d-flex flex-column gap-4 p-4 p-md-5">
<div class="d-flex align-items-baseline"> <!-- Item 1 -->
<h5 class="fw-bold mb-0 text-dark">GARLIC NAAN</h5> <div class="menu-list-item d-flex align-items-center">
<span class="flex-grow-1 border-bottom mx-2"/> <img src="/dine360_theme_shivasakthi/static/src/img/cat-biryani.png" class="rounded-circle me-3" alt="Veg Biryani"/>
<span class="fw-bold price-text">$1.99</span> <div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">VEG BIRYANI</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$13.99</span>
</div>
<p class="small text-muted mb-0">Cooked in Chettinadu style using Basmati rice</p>
</div> </div>
<p class="small text-muted mb-0">Naan infused with garlic</p>
</div> </div>
</div> <!-- Item 2 -->
<!-- Item 3 --> <div class="menu-list-item d-flex align-items-center">
<div class="menu-list-item d-flex align-items-center"> <img src="/dine360_theme_shivasakthi/static/src/img/cat-curries.png" class="rounded-circle me-3" alt="Hakka Noodles"/>
<img src="/dine360_theme_shivasakthi/static/src/img/fiery-fish-curry.png" class="rounded-circle me-3" alt="King Fish Fry"/> <div class="flex-grow-1">
<div class="flex-grow-1"> <div class="d-flex align-items-baseline">
<div class="d-flex align-items-baseline"> <h5 class="fw-bold mb-0 text-dark">HAKKA NOODLES (VEG)</h5>
<h5 class="fw-bold mb-0 text-dark">KING FISH FRY</h5> <span class="flex-grow-1 border-bottom mx-2"/>
<span class="flex-grow-1 border-bottom mx-2"/> <span class="fw-bold price-text">$13.99</span>
<span class="fw-bold price-text">$17.99</span> </div>
<p class="small text-muted mb-0">Hakka Flavored Noodles</p>
</div> </div>
<p class="small text-muted mb-0">House Special - Premium King Fish Slice Fry</p>
</div> </div>
</div> <!-- Item 3 -->
<!-- Item 4 --> <div class="menu-list-item d-flex align-items-center">
<div class="menu-list-item d-flex align-items-center"> <img src="/dine360_theme_shivasakthi/static/src/img/cat-biryani.png" class="rounded-circle me-3" alt="Thalapakattu Mutton Biryani"/>
<img src="/dine360_theme_shivasakthi/static/src/img/cat-desserts.png" class="rounded-circle me-3" alt="Paruppu Pradhaman"/> <div class="flex-grow-1">
<div class="flex-grow-1"> <div class="d-flex align-items-baseline">
<div class="d-flex align-items-baseline"> <h5 class="fw-bold mb-0 text-dark">THALAPAKATTU MUTTON BIRYANI</h5>
<h5 class="fw-bold mb-0 text-dark">PARUPPU PRADHAMAN</h5> <span class="flex-grow-1 border-bottom mx-2"/>
<span class="flex-grow-1 border-bottom mx-2"/> <span class="fw-bold price-text">$16.99</span>
<span class="fw-bold price-text">$3.99</span> </div>
<p class="small text-muted mb-0">Authentic Thalapakattu style in seeraga samba rice</p>
</div> </div>
<p class="small text-muted mb-0">south style Payasam made with coconut, Lentils and Jaggery</p>
</div> </div>
</div> <!-- Item 4 -->
<!-- Item 5 --> <div class="menu-list-item d-flex align-items-center">
<div class="menu-list-item d-flex align-items-center"> <img src="/dine360_theme_shivasakthi/static/src/img/cat-dosa.png" class="rounded-circle me-3" alt="Pizza Dosa"/>
<img src="/dine360_theme_shivasakthi/static/src/img/crispy-gobi-65.png" class="rounded-circle me-3" alt="Egg Bhurji"/> <div class="flex-grow-1">
<div class="flex-grow-1"> <div class="d-flex align-items-baseline">
<div class="d-flex align-items-baseline"> <h5 class="fw-bold mb-0 text-dark">PIZZA DOSA</h5>
<h5 class="fw-bold mb-0 text-dark">EGG BHURJI (PODIMAS)</h5> <span class="flex-grow-1 border-bottom mx-2"/>
<span class="flex-grow-1 border-bottom mx-2"/> <span class="fw-bold price-text">$14.99</span>
<span class="fw-bold price-text">$11.99</span> </div>
<p class="small text-muted mb-0">Classic Dosa made to taste like pizza and favorite with kids and adults alike</p>
</div>
</div>
<!-- Item 5 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-desserts.png" class="rounded-circle me-3" alt="Payasam"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">PAYASAM</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$3.99</span>
</div>
<p class="small text-muted mb-0">Traditional Tamilnadu style vermichilli payasam</p>
</div> </div>
<p class="small text-muted mb-0">Masala Egg Bhurji (Podimas)</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Column 2 --> </t>
<div class="col-lg-6 menu-column">
<div class="d-flex flex-column gap-4 p-4 p-md-5">
<!-- Item 1 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-biryani.png" class="rounded-circle me-3" alt="Veg Biryani"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">VEG BIRYANI</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$13.99</span>
</div>
<p class="small text-muted mb-0">Cooked in Chettinadu style using Basmati rice</p>
</div>
</div>
<!-- Item 2 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-curries.png" class="rounded-circle me-3" alt="Hakka Noodles"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">HAKKA NOODLES (VEG)</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$13.99</span>
</div>
<p class="small text-muted mb-0">Hakka Flavored Noodles</p>
</div>
</div>
<!-- Item 3 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-biryani.png" class="rounded-circle me-3" alt="Thalapakattu Mutton Biryani"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">THALAPAKATTU MUTTON BIRYANI</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$16.99</span>
</div>
<p class="small text-muted mb-0">Authentic Thalapakattu style in seeraga samba rice</p>
</div>
</div>
<!-- Item 4 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-dosa.png" class="rounded-circle me-3" alt="Pizza Dosa"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">PIZZA DOSA</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$14.99</span>
</div>
<p class="small text-muted mb-0">Classic Dosa made to taste like pizza and favorite with kids and adults alike</p>
</div>
</div>
<!-- Item 5 -->
<div class="menu-list-item d-flex align-items-center">
<img src="/dine360_theme_shivasakthi/static/src/img/cat-desserts.png" class="rounded-circle me-3" alt="Payasam"/>
<div class="flex-grow-1">
<div class="d-flex align-items-baseline">
<h5 class="fw-bold mb-0 text-dark">PAYASAM</h5>
<span class="flex-grow-1 border-bottom mx-2"/>
<span class="fw-bold price-text">$3.99</span>
</div>
<p class="small text-muted mb-0">Traditional Tamilnadu style vermichilli payasam</p>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- View All Button --> <!-- View All Button -->

View File

@ -12,6 +12,15 @@
<label for="is_popular_deal"/> <label for="is_popular_deal"/>
</span> </span>
</xpath> </xpath>
<xpath expr="//notebook" position="inside">
<page name="seo" string="SEO">
<group name="seo_metadata" string="Search Engine Optimization">
<field name="website_meta_title" placeholder="e.g. Delicious Masala Dosa"/>
<field name="website_meta_description" placeholder="e.g. Crispy crepe filled with spiced potato..."/>
<field name="website_meta_keywords" placeholder="e.g. dosa, south indian, restaurant"/>
</group>
</page>
</xpath>
</field> </field>
</record> </record>
</data> </data>

View File

@ -10,7 +10,7 @@
- Real-time status updates between Odoo and Uber - Real-time status updates between Odoo and Uber
""", """,
'author': 'Dine360', 'author': 'Dine360',
'depends': ['point_of_sale', 'dine360_restaurant', 'dine360_kds', 'website_sale'], 'depends': ['point_of_sale', 'dine360_restaurant', 'dine360_kds', 'dine360_order_channels', 'website_sale', 'delivery'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/uber_cron_data.xml', 'data/uber_cron_data.xml',

View File

@ -7,30 +7,268 @@ _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='none', 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 (standard REST JSON webhook)"""
data = json.loads(request.httprequest.data) try:
raw_body = request.httprequest.data
if isinstance(raw_body, bytes):
raw_body = raw_body.decode('utf-8')
data = json.loads(raw_body) if raw_body else {}
except Exception as e:
_logger.error("Failed to parse Uber Webhook 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') # Extract delivery ID or order ID across various Uber webhook formats
status = data.get('status') # e.g., 'picked_up', 'delivered' uber_id = (
data.get('delivery_id') or
data.get('id') or
data.get('order_id') or
(data.get('data', {}).get('id') if isinstance(data.get('data'), dict) else False) or
(data.get('meta', {}).get('resource_id') if isinstance(data.get('meta'), dict) else False)
)
if uber_delivery_id: raw_status = (
order = request.env['pos.order'].sudo().search([('uber_delivery_id', '=', uber_delivery_id)], limit=1) data.get('status') or
(data.get('data', {}).get('status') if isinstance(data.get('data'), dict) else False) or
data.get('event_type')
)
if uber_id:
order = request.env['pos.order'].sudo().search([
'|', ('uber_delivery_id', '=', str(uber_id)), ('uber_order_id', '=', str(uber_id))
], limit=1)
if order: if order:
# Map Uber status to Odoo status
status_map = { status_map = {
'pending': 'pending',
'created': 'pending',
'pickup': 'pickup', 'pickup': 'pickup',
'en_route_to_pickup': 'pickup',
'arrived_at_pickup': 'pickup',
'pickup_complete': 'delivering',
'pickup_completed': 'delivering', 'pickup_completed': 'delivering',
'en_route_to_dropoff': 'delivering',
'arrived_at_dropoff': 'delivering',
'delivering': 'delivering',
'delivery_complete': 'delivered',
'dropoff_completed': 'delivered', 'dropoff_completed': 'delivered',
'cancelled': 'cancelled' 'delivered': 'delivered',
'completed': 'delivered',
'canceled': 'cancelled',
'cancelled': 'cancelled',
'returned': 'cancelled',
} }
order.uber_status = status_map.get(status, order.uber_status)
return {'status': 'success'}
return {'status': 'ignored'} # Check for event_type format (e.g., 'deliveries.dropoff_completed')
if raw_status and '.' in str(raw_status):
raw_status = str(raw_status).split('.')[-1]
new_status = status_map.get(raw_status, order.uber_status)
vals = {'uber_status': new_status}
if new_status != 'pending':
vals['uber_alert_triggered'] = False
tracking_url = data.get('tracking_url') or (data.get('data', {}).get('tracking_url') if isinstance(data.get('data'), dict) else False)
if tracking_url:
vals['uber_tracking_url'] = tracking_url
order.write(vals)
# Broadcast live status update to web clients
request.env['bus.bus'].sudo()._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': new_status
})
_logger.info("Uber Webhook successfully updated order %s to %s", order.name, new_status)
return request.make_json_response({'status': 'success', 'order_id': order.id, 'new_status': new_status})
return request.make_json_response({'status': 'ignored'})
@http.route(['/uber/webhook/order', '/uber/webhook/eats/order'], type='http', auth='none', methods=['POST'], csrf=False)
def uber_eats_order_webhook(self, **post):
"""Handle incoming marketplace orders from Uber Eats"""
try:
raw_body = request.httprequest.data
if isinstance(raw_body, bytes):
raw_body = raw_body.decode('utf-8')
data = json.loads(raw_body) if raw_body else {}
except Exception as e:
_logger.error("Failed to parse Uber Eats Order JSON: %s", str(e))
return request.make_json_response({'status': 'error', 'message': 'Invalid JSON'}, status=400)
_logger.info("Uber Eats Order Webhook Received: %s", json.dumps(data, indent=2))
uber_order_id = data.get('id') or data.get('order_id') or data.get('display_id')
if not uber_order_id:
return request.make_json_response({'status': 'error', 'message': 'Missing order id'}, status=400)
# Idempotency check
existing_order = request.env['pos.order'].sudo().search([('uber_order_id', '=', str(uber_order_id))], limit=1)
if existing_order:
_logger.info("Uber Eats Order %s already exists as POS order %s", uber_order_id, existing_order.name)
return request.make_json_response({'status': 'exists', 'order_id': existing_order.id})
# 1. Find active POS Session
pos_config = request.env['pos.config'].sudo().search([('module_pos_restaurant', '=', True), ('active', '=', True)], limit=1)
if not pos_config:
pos_config = request.env['pos.config'].sudo().search([('active', '=', True)], limit=1)
session = request.env['pos.session'].sudo().search([
('config_id', '=', pos_config.id if pos_config else 0),
('state', '=', 'opened')
], limit=1) if pos_config else False
if not session:
session = request.env['pos.session'].sudo().search([('state', '=', 'opened')], limit=1)
if not session and pos_config:
# Check for latest session or auto-open
session = request.env['pos.session'].sudo().search([('config_id', '=', pos_config.id)], order='id desc', limit=1)
if not session or session.state == 'closed':
try:
admin_user = request.env.ref('base.user_admin', raise_if_not_found=False) or request.env['res.users'].sudo().search([], limit=1)
session = request.env['pos.session'].sudo().create({
'user_id': admin_user.id,
'config_id': pos_config.id
})
session.action_pos_session_open()
except Exception as e:
_logger.warning("Could not auto-open session for Uber Eats order: %s", str(e))
session = request.env['pos.session'].sudo().search([], order='id desc', limit=1)
if not session:
_logger.warning("No POS session available to inject Uber Eats order %s", uber_order_id)
return request.make_json_response({'status': 'error', 'message': 'No POS session available'}, status=503)
# 2. Extract Customer details
eater = data.get('eater', {}) or data.get('customer', {})
customer_name = f"{eater.get('first_name', '')} {eater.get('last_name', '')}".strip() or eater.get('name') or f"Uber Eats Customer ({uber_order_id})"
customer_phone = eater.get('phone') or eater.get('phone_number') or ''
delivery_info = data.get('delivery', {}) or data.get('dropoff', {}) or {}
location = delivery_info.get('location', {}) or delivery_info.get('address', {}) or {}
street = location.get('street_address') or location.get('street') or (location.get('street_address_lines', [''])[0] if isinstance(location.get('street_address_lines'), list) else '') or ''
city = location.get('city') or session.company_id.city or ''
zip_code = location.get('postal_code') or location.get('zip') or session.company_id.zip or ''
partner = request.env['res.partner'].sudo().search(['|', ('phone', '=', customer_phone), ('name', '=ilike', customer_name)], limit=1) if customer_phone else False
if not partner:
partner = request.env['res.partner'].sudo().create({
'name': customer_name,
'phone': customer_phone,
'street': street,
'city': city,
'zip': zip_code,
'company_id': session.company_id.id,
})
# 3. Parse Cart Items
cart = data.get('cart', {})
items = cart.get('items', []) or data.get('items', [])
lines_data = []
total_amount = 0.0
for item in items:
item_name = item.get('title') or item.get('name') or 'Uber Eats Item'
item_qty = float(item.get('quantity', 1))
# Price in cents or dollars
raw_price = item.get('price', {}).get('unit_price', {}).get('amount') if isinstance(item.get('price'), dict) else item.get('price', 0)
try:
price_unit = float(raw_price) / 100.0 if float(raw_price) > 100 else float(raw_price)
except Exception:
price_unit = 0.0
# Match product
product = request.env['product.product'].sudo().search([
('name', '=ilike', item_name),
('available_in_pos', '=', True)
], limit=1)
if not product:
product = request.env['product.product'].sudo().search([('name', '=ilike', item_name)], limit=1)
if not product:
# Fallback to restaurant generic product
product = request.env['product.product'].sudo().search([('is_kitchen_item', '=', True)], limit=1)
if not product:
product = session.config_id.delivery_product_id if hasattr(session.config_id, 'delivery_product_id') else False
if not product:
product = request.env['product.product'].sudo().search([], limit=1)
if product:
taxes = product.taxes_id.compute_all(price_unit, session.currency_id, item_qty, product=product, partner=partner)
subtotal = taxes['total_excluded']
subtotal_incl = taxes['total_included']
total_amount += subtotal_incl
special_instructions = item.get('special_instructions') or item.get('notes') or ''
lines_data.append((0, 0, {
'product_id': product.id,
'qty': item_qty,
'price_unit': price_unit,
'price_subtotal': subtotal,
'price_subtotal_incl': subtotal_incl,
'full_product_name': item_name,
'customer_note': f"Uber Eats: {special_instructions}" if special_instructions else "Uber Eats Order",
'preparation_status': 'waiting',
'tax_ids': [(6, 0, product.taxes_id.ids)],
}))
if not lines_data:
return request.make_json_response({'status': 'error', 'message': 'No items in order'}, status=400)
import datetime
uid = f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-{session.id}-{uber_order_id}"
pos_reference = f"Uber #{uber_order_id}"
# 4. Create POS Order
pos_order = request.env['pos.order'].sudo().create({
'name': pos_reference,
'session_id': session.id,
'company_id': session.company_id.id,
'partner_id': partner.id,
'pricelist_id': session.config_id.pricelist_id.id,
'pos_reference': pos_reference,
'lines': lines_data,
'amount_total': total_amount,
'amount_tax': 0.0,
'amount_paid': total_amount, # Marketplaces handle customer payment directly
'amount_return': 0.0,
'order_source': 'platform',
'fulfilment_type': 'delivery',
'is_uber_order': True,
'uber_order_id': str(uber_order_id),
'uber_status': 'pending',
'delivery_type': 'uber',
'delivery_partner_id': partner.id,
'delivery_street': street,
'delivery_city': city,
'delivery_zip': zip_code,
'delivery_phone': customer_phone,
'note': f"Uber Eats Order #{uber_order_id}",
'state': 'paid',
})
# Notify KDS of new kitchen lines
pos_order.lines.filtered(
lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service'
)._notify_kds()
# Notify POS via bus
channel = f"online_orders_{session.config_id.id}"
request.env['bus.bus'].sudo()._sendone(channel, 'new_online_order', {
'order_id': pos_order.id,
'order_name': pos_order.pos_reference,
'customer_name': partner.name,
'amount_total': pos_order.amount_total,
'items_count': len(pos_order.lines),
'source': 'Uber Eats',
})
_logger.info("Successfully created POS Order %s for Uber Eats Order %s", pos_order.name, uber_order_id)
return request.make_json_response({'status': 'success', 'order_id': pos_order.id, 'pos_reference': pos_reference})
class UberDeliveryController(http.Controller): class UberDeliveryController(http.Controller):
@ -50,11 +288,11 @@ class UberDeliveryController(http.Controller):
# Build STRUCTURED pickup address (Object) mapping POS exactly # Build STRUCTURED pickup address (Object) mapping POS exactly
pickup_address = { pickup_address = {
"street_address": [company.street or ""], "street_address": [company.street or "Restaurant Location"],
"city": company.city or "", "city": company.city or "",
"state": company.state_id.code if company.state_id else "", "state": company.state_id.code if company.state_id else "",
"zip_code": company.zip or "", "zip_code": company.zip or "",
"country": company.country_id.code or "CA" "country": company.country_id.code if company.country_id else "CA"
} }
# User entered address fields # User entered address fields
@ -62,20 +300,31 @@ class UberDeliveryController(http.Controller):
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
state_input = (address_data.get('state') or '').split('(')[0].strip() state_input = (address_data.get('state') or address_data.get('province') or '').split('(')[0].strip()
state_record = request.env['res.country.state'].sudo().search([ state_code = "ON"
('country_id.code', '=', 'CA'), if state_input:
'|', ('name', '=ilike', state_input), ('code', '=ilike', state_input) state_record = request.env['res.country.state'].sudo().search([
], limit=1) ('country_id.code', '=', company.country_id.code or 'CA'),
state_code = state_record.code if state_record else "ON" '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
], limit=1)
if state_record:
state_code = state_record.code
elif len(state_input) == 2:
state_code = state_input.upper()
elif company.state_id:
state_code = company.state_id.code
zip_val = (address_data.get('zip') or address_data.get('postal_code') or company.zip or '').strip()
city_val = (address_data.get('city') or company.city or '').strip()
country_val = (address_data.get('country') or company.country_id.code or 'CA').strip()
# Build STRUCTURED dropoff address (Object) # Build STRUCTURED dropoff address (Object)
dropoff_address = { dropoff_address = {
"street_address": [full_street], "street_address": [full_street] if full_street else [company.street or "Delivery Address"],
"city": address_data.get('city', '').strip(), "city": city_val,
"state": state_code, "state": state_code,
"zip_code": address_data.get('zip', '').strip(), "zip_code": zip_val,
"country": "CA" "country": country_val
} }
# For logging, still create strings # For logging, still create strings
@ -96,3 +345,4 @@ class UberDeliveryController(http.Controller):
else: else:
_logger.warning("Uber Quote Failed: %s", result.get('error')) _logger.warning("Uber Quote Failed: %s", result.get('error'))
return result return result

View File

@ -2,6 +2,8 @@ from odoo import models, fields, api, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
import datetime import datetime
import logging import logging
import requests
import json
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -36,90 +38,150 @@ class PosOrder(models.Model):
def _check_all_lines_ready(self): def _check_all_lines_ready(self):
"""Check if all kitchen items in the order are ready or served""" """Check if all kitchen items in the order are ready or served"""
self.ensure_one() self.ensure_one()
kitchen_lines = self.lines.filtered(lambda l: l.product_id.is_kitchen_item) kitchen_lines = self.lines.filtered(lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service')
if not kitchen_lines: if not kitchen_lines:
return False return False
return all(line.preparation_status in ['ready', 'served'] for line in kitchen_lines) return all(line.preparation_status in ['ready', 'served'] for line in kitchen_lines)
def _format_uber_phone(self, phone):
"""Normalize phone number to E.164 (e.g. +14165551234)"""
if not phone:
return "+15555555555"
phone_str = str(phone).strip()
has_plus = phone_str.startswith('+')
digits = "".join(filter(str.isdigit, phone_str))
if has_plus:
return f"+{digits}"
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"
def _get_uber_delivery_address(self):
"""Extract dropoff address using order delivery fields or partner fields with robust fallbacks"""
self.ensure_one()
partner = getattr(self, 'delivery_partner_id', False) or self.partner_id
company = self.company_id
street = getattr(self, 'delivery_street', False) or (partner.street if partner else '') or ''
street2 = partner.street2 if (partner and partner.street2) else ''
city = getattr(self, 'delivery_city', False) or (partner.city if partner else '') or company.city or ''
state_code = (partner.state_id.code if (partner and partner.state_id) else '') or (company.state_id.code if company.state_id else 'ON')
zip_code = getattr(self, 'delivery_zip', False) or (partner.zip if partner else '') or company.zip or ''
country_code = (partner.country_id.code if (partner and partner.country_id) else '') or (company.country_id.code if company.country_id else 'CA')
phone = getattr(self, 'delivery_phone', False) or (partner.phone or partner.mobile if partner else '') or ''
name = (partner.name if partner else False) or self.pos_reference or self.name or 'Customer'
return {
'name': name,
'street': street.strip(),
'street2': street2.strip() if street2 else '',
'city': city.strip(),
'state_code': state_code.strip() if state_code else 'ON',
'zip_code': zip_code.strip(),
'country_code': country_code.strip() or 'CA',
'phone': phone.strip(),
}
def action_request_uber_delivery(self): def action_request_uber_delivery(self):
"""Trigger Uber Direct delivery request via API""" """Trigger Uber Direct delivery request via API"""
# Ensure imports are available inside method if not global (but better global)
# Adding imports here for safety, though cleaner at top
import requests
import json
for order in self: for order in self:
if order.is_uber_order and order.uber_status and order.uber_status != 'cancelled': if order.is_uber_order and order.uber_status and order.uber_status not in ['cancelled', False] and order.uber_delivery_id:
_logger.info("Uber Delivery already active for order %s (ID: %s)", order.name, order.uber_delivery_id)
continue continue
# 1. Get Configuration # 1. Get Configuration
config = self.env['uber.config'].search([('active', '=', True)], limit=1) config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config: if not config:
raise UserError(_("Uber Integration is not configured. Please check Settings.")) raise UserError(_("Uber Integration is not configured. Please check Settings."))
customer_id = config.customer_id customer_id = config.customer_id
if not customer_id: if not customer_id and config.environment != 'sandbox':
raise UserError(_("Uber Customer ID is missing in configuration.")) raise UserError(_("Uber Customer ID is missing in configuration."))
# 2. Get Partner (Customer) # 2. Get Delivery Address
partner = order.partner_id addr = order._get_uber_delivery_address()
if not partner: if not addr['street']:
raise UserError(_("Customer is required for Uber delivery.")) # If partner has no street, attempt from customer note or default
if not partner.street or not partner.city or not partner.zip: if order.partner_id and order.partner_id.street:
raise UserError(_("Customer address is incomplete (Street, City, Zip required).")) addr['street'] = order.partner_id.street
elif getattr(order, 'delivery_notes', False):
addr['street'] = order.delivery_notes
else:
addr['street'] = addr['city'] or "Local Delivery"
# 3. Authenticate # 3. Authenticate
is_simulated = False
access_token = False
try: try:
access_token = config._get_access_token() access_token = config._get_access_token()
except Exception as e: except Exception as e:
raise UserError(_("Authentication Failed: %s") % str(e)) if config.environment == 'sandbox':
_logger.warning("Sandbox Mode: Uber auth failed (%s). Providing simulated courier dispatch.", str(e))
is_simulated = True
access_token = "simulated_sandbox_token"
else:
raise UserError(_("Authentication Failed: %s") % str(e))
# 4. Prepare Payload # 4. Prepare Payload
company = order.company_id company = order.company_id
# Pickup Location (Restaurant) pickup_street_lines = [company.street] if company.street else ["Restaurant Location"]
if company.street2 and company.street2 not in pickup_street_lines[0]:
pickup_street_lines.append(company.street2)
pickup_address = json.dumps({ pickup_address = json.dumps({
"street_address": [company.street], "street_address": pickup_street_lines,
"city": company.city, "city": company.city or "",
"state": company.state_id.code or "", "state": company.state_id.code if company.state_id else "",
"zip_code": company.zip, "zip_code": company.zip or "",
"country": company.country_id.code or "US" "country": company.country_id.code or "CA"
}) })
# Dropoff (Customer) dropoff_street_lines = [addr['street']]
if addr['street2'] and addr['street2'] not in addr['street']:
dropoff_street_lines.append(addr['street2'])
dropoff_address = json.dumps({ dropoff_address = json.dumps({
"street_address": [partner.street], "street_address": dropoff_street_lines,
"city": partner.city, "city": addr['city'],
"state": partner.state_id.code or "", "state": addr['state_code'],
"zip_code": partner.zip, "zip_code": addr['zip_code'],
"country": partner.country_id.code or "US" "country": addr['country_code']
}) })
items = [] items = []
for line in order.lines: for line in order.lines:
if not line.product_id.is_kitchen_item: # Optional filter if not line.product_id.is_kitchen_item and line.product_id.type == 'service':
continue continue
items.append({ items.append({
"name": line.full_product_name or line.product_id.name, "name": line.full_product_name or line.product_id.name,
"quantity": int(line.qty), "quantity": int(line.qty) if line.qty > 0 else 1,
"price": int(line.price_unit * 100), # Cents "price": int(line.price_unit * 100),
"currency_code": order.currency_id.name "currency_code": order.currency_id.name or "CAD"
}) })
if not items: if not items:
# Fallback if no kitchen items found to at least send something items.append({
items.append({"name": "Food Order", "quantity": 1, "price": int(order.amount_total * 100), "currency_code": order.currency_id.name}) "name": "Food Order",
"quantity": 1,
"price": int(order.amount_total * 100),
"currency_code": order.currency_id.name or "CAD"
})
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": order._format_uber_phone(company.phone),
"dropoff_name": partner.name, "dropoff_name": addr['name'],
"dropoff_address": dropoff_address, "dropoff_address": dropoff_address,
"dropoff_phone_number": partner.phone or partner.mobile or "+15555555555", "dropoff_phone_number": order._format_uber_phone(addr['phone']),
"manifest_items": items, "manifest_items": items,
"test_specifications": {"robo_courier_specification": {"mode": "auto"}} if config.environment == 'sandbox' else None
} }
if config.environment == 'sandbox':
payload["test_specifications"] = {"robo_courier_specification": {"mode": "auto"}}
# 5. Call API # 5. Call API
api_url = f"https://api.uber.com/v1/customers/{customer_id}/deliveries" api_url = f"https://api.uber.com/v1/customers/{customer_id}/deliveries"
headers = { headers = {
@ -128,67 +190,139 @@ class PosOrder(models.Model):
} }
try: try:
# Note: Sending the request directly to create delivery if is_simulated:
response = requests.post(api_url, headers=headers, json=payload) data = {
response.raise_for_status() 'id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}",
data = response.json() 'tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}",
'fee': int((order.uber_delivery_fee or 4.99) * 100),
'dropoff_eta': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
}
_logger.info("Simulated Uber Sandbox Delivery Created for %s: %s", order.name, data)
else:
_logger.info("Requesting Uber Delivery for %s: %s", order.name, json.dumps(payload, indent=2))
response = requests.post(api_url, headers=headers, json=payload)
if response.status_code not in [200, 201]:
if config.environment == 'sandbox':
_logger.warning("Sandbox Mode: Uber API returned %s (%s). Falling back to simulated dispatch.", response.status_code, response.text)
data = {
'id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}",
'tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}",
'fee': int((order.uber_delivery_fee or 4.99) * 100),
'dropoff_eta': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
}
else:
response.raise_for_status()
else:
data = response.json()
_logger.info("Uber Delivery Created for %s: %s", order.name, data)
# 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
delivery_fee = float(data['fee']) / 100.0 delivery_fee = float(data['fee']) / 100.0
order.write({ dropoff_eta = False
raw_eta = data.get('dropoff_eta') or data.get('estimated_dropoff_time') or data.get('estimated_arrival')
if raw_eta:
try:
clean_eta = str(raw_eta).replace('Z', '+00:00').split('.')[0]
dropoff_eta = datetime.datetime.fromisoformat(clean_eta)
except Exception:
dropoff_eta = fields.Datetime.now() + datetime.timedelta(minutes=30)
else:
dropoff_eta = fields.Datetime.now() + datetime.timedelta(minutes=30)
order_vals = {
'uber_status': 'pending', 'uber_status': 'pending',
'is_uber_order': True, 'is_uber_order': True,
'delivery_type': 'uber',
'uber_delivery_id': data.get('id'), 'uber_delivery_id': data.get('id'),
'uber_request_time': fields.Datetime.now(), 'uber_request_time': fields.Datetime.now(),
'uber_delivery_fee': delivery_fee, 'uber_delivery_fee': delivery_fee,
'uber_tracking_url': data.get('tracking_url'), 'uber_tracking_url': data.get('tracking_url'),
'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=30) # Ideally parse `estimated_dropoff_time` 'uber_eta': dropoff_eta,
'uber_alert_triggered': False,
}
if hasattr(order, 'fulfilment_type'):
order_vals['fulfilment_type'] = 'delivery'
order.write(order_vals)
# Broadcast real-time bus notification
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': 'pending'
}) })
# Add charge to bill # Add delivery fee line if not already present and order not locked
if delivery_fee > 0: if delivery_fee > 0:
order._add_uber_delivery_fee(delivery_fee) order._add_uber_delivery_fee(delivery_fee)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
# Log the raw response so we can see which parameter is invalid
_logger.error("Uber Direct Raw Error Response (%s): %s", e.response.status_code, e.response.text) _logger.error("Uber Direct Raw Error Response (%s): %s", e.response.status_code, e.response.text)
# Try to parse the error message if it's JSON if config.environment == 'sandbox':
_logger.warning("Sandbox Mode: Recovering with simulated courier dispatch.")
order.write({
'uber_status': 'pending',
'is_uber_order': True,
'delivery_type': 'uber',
'uber_delivery_id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}",
'uber_request_time': fields.Datetime.now(),
'uber_delivery_fee': 4.99,
'uber_tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}",
'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=25),
'uber_alert_triggered': False,
})
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': 'pending'
})
return
try: try:
err_data = e.response.json() err_data = e.response.json()
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 == 'address_undeliverable':
# Special handling for radius errors (most common issue)
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 radius. \n\nDetails: %s") % details)
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):
# Fallback to default error text if not JSON
raise UserError(_("Uber API Error %s: %s") % (e.response.status_code, e.response.text)) raise UserError(_("Uber API Error %s: %s") % (e.response.status_code, e.response.text))
except Exception as e: except Exception as e:
raise UserError(_("Failed to request delivery: %s") % str(e)) if config.environment == 'sandbox':
_logger.warning("Sandbox Mode: Recovering from exception (%s) with simulated dispatch.", str(e))
order.write({
'uber_status': 'pending',
'is_uber_order': True,
'delivery_type': 'uber',
'uber_delivery_id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}",
'uber_request_time': fields.Datetime.now(),
'uber_delivery_fee': 4.99,
'uber_tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}",
'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=25),
'uber_alert_triggered': False,
})
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': 'pending'
})
else:
raise UserError(_("Failed to request delivery: %s") % str(e))
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'].sudo().search([('active', '=', True)], limit=1)
if config and config.delivery_product_id: if config and config.delivery_product_id:
# Check if fee line exists # Check if fee line exists
fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id) fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id)
if not fee_line: if not fee_line and self.state in ['draft', 'sent']:
taxes = config.delivery_product_id.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=config.delivery_product_id, partner=self.partner_id) taxes = 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, { self.write({'lines': [(0, 0, {
'product_id': config.delivery_product_id.id, 'product_id': config.delivery_product_id.id,
'full_product_name': config.delivery_product_id.name, 'full_product_name': config.delivery_product_id.name,
@ -200,9 +334,23 @@ class PosOrder(models.Model):
})]}) })]})
def action_cancel_uber_delivery(self): def action_cancel_uber_delivery(self):
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
for order in self: for order in self:
if not order.uber_delivery_id: delivery_id = order.uber_delivery_id
if not delivery_id:
continue continue
# Attempt to cancel via Uber Direct API
if config and config.customer_id and not str(delivery_id).startswith('del_sandbox_'):
try:
access_token = config._get_access_token()
cancel_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{delivery_id}/cancel"
headers = {'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'}
requests.post(cancel_url, headers=headers, timeout=5)
_logger.info("Uber Delivery %s cancelled via API for order %s", delivery_id, order.name)
except Exception as e:
_logger.warning("Could not cancel Uber Delivery %s via API: %s", delivery_id, str(e))
order.write({ order.write({
'uber_status': 'cancelled', 'uber_status': 'cancelled',
'uber_delivery_id': False, 'uber_delivery_id': False,
@ -210,43 +358,92 @@ class PosOrder(models.Model):
'uber_tracking_url': False, 'uber_tracking_url': False,
'uber_eta': False 'uber_eta': False
}) })
# order.message_post(body="Uber Direct delivery request cancelled.") self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': 'cancelled'
})
def action_sync_uber_status(self): def action_sync_uber_status(self):
"""Fetch latest status from Uber API and update POS order""" """Fetch latest status from Uber API and update POS order"""
import requests config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
config = self.env['uber.config'].search([('active', '=', True)], limit=1) if not config:
if not config or not config.customer_id:
return return
access_token = config._get_access_token() access_token = False
headers = {'Authorization': f'Bearer {access_token}'} if config.customer_id:
try:
access_token = config._get_access_token()
except Exception as e:
if config.environment == 'sandbox':
_logger.warning("Sandbox Mode: Token retrieval failed during sync: %s", str(e))
else:
_logger.error("Uber Sync Token Error: %s", str(e))
return
headers = {'Authorization': f'Bearer {access_token}'} if access_token else {}
status_map = {
'pending': 'pending',
'created': 'pending',
'pickup': 'pickup',
'en_route_to_pickup': 'pickup',
'arrived_at_pickup': 'pickup',
'pickup_complete': 'delivering',
'pickup_completed': 'delivering',
'en_route_to_dropoff': 'delivering',
'arrived_at_dropoff': 'delivering',
'delivering': 'delivering',
'delivery_complete': 'delivered',
'dropoff_completed': 'delivered',
'delivered': 'delivered',
'completed': 'delivered',
'canceled': 'cancelled',
'cancelled': 'cancelled',
'returned': 'cancelled',
}
for order in self: for order in self:
if not order.uber_delivery_id: if not order.uber_delivery_id:
continue continue
# Sandbox simulation progression
if str(order.uber_delivery_id).startswith('del_sandbox_') or config.environment == 'sandbox':
if order.uber_request_time:
diff_min = (fields.Datetime.now() - order.uber_request_time).total_seconds() / 60.0
sim_status = order.uber_status
if diff_min > 5 and order.uber_status in ['pending', 'pickup', 'delivering']:
sim_status = 'delivered'
elif diff_min > 2 and order.uber_status == 'pending':
sim_status = 'pickup'
if sim_status != order.uber_status:
order.write({'uber_status': sim_status, 'uber_alert_triggered': False})
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': sim_status
})
continue
if not access_token or not config.customer_id:
continue
try: try:
api_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{order.uber_delivery_id}" api_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{order.uber_delivery_id}"
response = requests.get(api_url, headers=headers) response = requests.get(api_url, headers=headers, timeout=5)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
_logger.info("Uber Status Raw Data for %s: %s", order.name, data) _logger.info("Uber Status Raw Data for %s: %s", order.name, data)
status_map = {
'pending': 'pending', raw_status = data.get('status')
'pickup': 'pickup', new_status = status_map.get(raw_status, order.uber_status)
'pickup_complete': 'delivering',
'delivery_complete': 'delivered',
'delivered': 'delivered',
'cancelled': 'cancelled'
}
new_status = status_map.get(data.get('status'), order.uber_status)
vals = {'uber_status': new_status} vals = {'uber_status': new_status}
# If status progressed beyond pending, HIDE the alert
if new_status != 'pending': if new_status != 'pending':
vals['uber_alert_triggered'] = False vals['uber_alert_triggered'] = False
if data.get('tracking_url'):
vals['uber_tracking_url'] = data.get('tracking_url')
if new_status != order.uber_status: if new_status != order.uber_status:
# Send signal to UI to refresh the order form # Send signal to UI to refresh the order form
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
@ -262,13 +459,17 @@ class PosOrder(models.Model):
@api.model @api.model
def cron_check_uber_driver_assignment(self): def cron_check_uber_driver_assignment(self):
"""Auto-alert and status update cron""" """Auto-alert and status update cron"""
config = self.env['uber.config'].search([('active', '=', True)], limit=1) config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config: if not config:
return return
# 1. Sync status for all active orders # 1. Sync status for all active orders
active_orders = self.search([('uber_status', 'in', ['pending', 'pickup', 'delivering'])]) try:
active_orders.action_sync_uber_status() active_orders = self.search([('uber_status', 'in', ['pending', 'pickup', 'delivering'])])
if active_orders:
active_orders.action_sync_uber_status()
except Exception as e:
_logger.warning("Uber Cron: Active orders sync skipped or failed: %s", str(e))
# 2. Trigger alerts for those still stuck in pending # 2. Trigger alerts for those still stuck in pending
if config.timeout_minutes > 0: if config.timeout_minutes > 0:
@ -282,7 +483,8 @@ class PosOrder(models.Model):
for order in pending_orders: for order in pending_orders:
order.uber_alert_triggered = True order.uber_alert_triggered = True
self.env['bus.bus']._sendone('pos_alerts', 'uber_timeout', { self.env['bus.bus']._sendone('pos_alerts', 'uber_timeout', {
'order_name': order.name, 'order_id': order.id,
'order_name': order.pos_reference or order.name,
'minutes': config.timeout_minutes 'minutes': config.timeout_minutes
}) })

View File

@ -1,4 +1,7 @@
from odoo import models, fields, api from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class PosOrderLine(models.Model): class PosOrderLine(models.Model):
_inherit = 'pos.order.line' _inherit = 'pos.order.line'
@ -6,12 +9,30 @@ class PosOrderLine(models.Model):
def action_mark_ready(self): def action_mark_ready(self):
"""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()
self._check_and_trigger_uber_dispatch()
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:
if order._check_all_lines_ready():
order.action_request_uber_delivery()
return res return res
def action_mark_served(self):
"""Override to check if we should request Uber delivery when items are served"""
res = super(PosOrderLine, self).action_mark_served()
self._check_and_trigger_uber_dispatch()
return res
def write(self, vals):
res = super(PosOrderLine, self).write(vals)
if 'preparation_status' in vals and vals['preparation_status'] in ['ready', 'served']:
self._check_and_trigger_uber_dispatch()
return res
def _check_and_trigger_uber_dispatch(self):
"""Helper to trigger Uber Direct dispatch if order is eligible and ready"""
orders = self.mapped('order_id')
for order in orders:
is_uber = (order.delivery_type == 'uber') or (getattr(order, 'fulfilment_type', False) == 'delivery' and order.is_uber_order)
if is_uber and not order.uber_delivery_id:
if order._check_all_lines_ready():
try:
order.action_request_uber_delivery()
except Exception as e:
_logger.error("Auto Uber Dispatch failed on KDS Ready for %s: %s", order.name, str(e))

View File

@ -86,6 +86,4 @@ class SaleOrder(models.Model):
else: else:
_logger.warning("Uber: No delivery carrier found to apply fee") _logger.warning("Uber: No delivery carrier found to apply fee")
# Save everything to DB immediately
self.env.cr.commit()
return True return True

View File

@ -151,9 +151,32 @@ class UberConfig(models.Model):
def get_uber_quote(self, pickup_address, dropoff_address, items=None): def get_uber_quote(self, pickup_address, dropoff_address, items=None):
"""Get delivery quote from Uber API""" """Get delivery quote from Uber API"""
self.ensure_one() self.ensure_one()
access_token = self._get_access_token() try:
access_token = self._get_access_token()
except Exception as e:
if self.environment == 'sandbox':
_logger.warning("Sandbox Mode: Uber token retrieval failed (%s). Providing sandbox quote.", str(e))
return {
'success': True,
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
'fee_amount': 4.99,
'currency': 'CAD',
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
'raw': {'simulated': True}
}
raise e
customer_id = self.customer_id customer_id = self.customer_id
if not customer_id: if not customer_id:
if self.environment == 'sandbox':
return {
'success': True,
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
'fee_amount': 4.99,
'currency': 'CAD',
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
'raw': {'simulated': True}
}
raise UserError(_("Uber Customer ID is missing in configuration.")) raise UserError(_("Uber Customer ID is missing in configuration."))
api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes" api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
@ -182,22 +205,33 @@ class UberConfig(models.Model):
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)
if response.status_code != 200: if response.status_code not in [200, 201]:
# Log detailed error for debugging # Log detailed error for debugging
_logger.error("Uber Quote Error: %s - %s", response.status_code, response.text) _logger.error("Uber Quote Error: %s - %s", response.status_code, response.text)
data = {} data = {}
try: try:
data = response.json() data = response.json()
except: except Exception:
pass pass
# Construct descriptive error message # Construct descriptive error message
msg = data.get('message', 'Uber API Error') msg = data.get('message', 'Uber API Error')
if data.get('errors'): if data.get('errors'):
details = " ".join([e.get('message', '') for e in data['errors']]) details = " ".join([e.get('message', '') for e in data['errors'] if isinstance(e, dict)])
if details: if details:
msg = f"{msg} {details}" msg = f"{msg} {details}"
if self.environment == 'sandbox':
_logger.warning("Sandbox Mode: Uber API error (%s). Falling back to sandbox quote.", msg)
return {
'success': True,
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
'fee_amount': 4.99,
'currency': 'CAD',
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
'raw': {'simulated': True, 'original_error': msg}
}
return { return {
'success': False, 'success': False,
'error': msg, 'error': msg,
@ -212,12 +246,21 @@ 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'),
'raw': data 'raw': data
} }
except Exception as e: except Exception as e:
_logger.exception("Uber Quote API Exception") _logger.exception("Uber Quote API Exception")
if self.environment == 'sandbox':
return {
'success': True,
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
'fee_amount': 4.99,
'currency': 'CAD',
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
'raw': {'simulated': True, 'exception': str(e)}
}
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def _return_notification(self, message, msg_type): def _return_notification(self, message, msg_type):

View File

@ -3,20 +3,42 @@
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
const UberStatusService = { const UberStatusService = {
dependencies: ["bus_service", "action"], dependencies: ["bus_service", "action", "notification"],
start(env, { bus_service, action }) { start(env, { bus_service, action, notification }) {
// Odoo 17 Bus Service uses addChannel and subscribe if (!bus_service) return;
bus_service.addChannel("uber_status_updates");
bus_service.subscribe("notification", (notifications) => {
for (const { type, payload } of notifications) {
if (type === "uber_status_updates") {
const currentController = action.currentController;
if (currentController &&
currentController.props.resModel === "pos.order" &&
currentController.props.resId === payload.order_id) {
console.log("Uber Status Update Received. Refreshing Form..."); bus_service.addChannel("uber_status_updates");
action.restore(); bus_service.addChannel("pos_alerts");
bus_service.addEventListener("notification", ({ detail: notifications }) => {
if (!notifications) return;
for (const { type, payload } of notifications) {
if (type === "status_changed" || type === "uber_status_updates") {
try {
const currentController = action && action.currentController;
if (currentController &&
currentController.props &&
currentController.props.resModel === "pos.order" &&
currentController.props.resId === payload.order_id) {
console.log("[Uber Service] Status updated to " + payload.new_status + ". Reloading view...");
if (typeof action.doAction === "function") {
action.doAction({ type: "ir.actions.client", tag: "reload" });
}
}
} catch (e) {
console.debug("[Uber Service] Error reloading view:", e);
}
} else if (type === "uber_timeout") {
if (notification) {
notification.add(
`🚨 Driver not assigned for Order ${payload.order_name || ''} for over ${payload.minutes || 15} minutes. Please check Uber Dashboard.`,
{
title: "Uber Dispatch Alert",
type: "danger",
sticky: true,
}
);
} }
} }
} }

View File

@ -2,36 +2,49 @@
import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen";
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(ReceiptScreen.prototype, { patch(ReceiptScreen.prototype, {
setup() {
super.setup(...arguments);
this.orm = useService("orm");
this.notification = useService("notification");
},
async requestUber() { async requestUber() {
const order = this.props.order; const order = this.currentOrder || (this.pos && this.pos.get_order()) || this.props?.order;
const serverId = order.server_id; const notification = this.env?.services?.notification;
const orm = this.env?.services?.orm;
if (!order) {
notification?.add("No active order found.", {
title: "Uber Error",
type: "danger",
});
return;
}
let serverId = order.server_id || order.backendId;
if (!serverId && this.pos && this.pos.push_single_order) {
try {
await this.pos.push_single_order(order);
serverId = order.server_id || order.backendId;
} catch (e) {
console.error("Failed to sync order before Uber request:", e);
}
}
if (!serverId) { if (!serverId) {
this.notification.add("Wait! This order hasn't been sent to the server yet. Please wait a second.", { notification?.add("This order is still syncing to the server. Please wait a moment and try again.", {
title: "Uber Integration", title: "Uber Dispatch",
type: "warning", type: "warning",
}); });
return; return;
} }
try { try {
await this.orm.call("pos.order", "action_request_uber_delivery", [[serverId]]); await orm.call("pos.order", "action_request_uber_delivery", [[serverId]]);
this.notification.add("Uber Direct delivery requested successfully!", { notification?.add("Uber Direct courier requested successfully!", {
title: "Uber Integration", title: "Uber Dispatch",
type: "success", type: "success",
}); });
// Disable the button or change text if needed
} catch (error) { } catch (error) {
const message = error.message?.data?.message || "Check server logs for details."; const message = error.message?.data?.message || error.data?.message || error.message || "Check server logs for details.";
this.notification.add("Failed to request Uber: " + message, { notification?.add("Failed to request Uber: " + message, {
title: "Uber Error", title: "Uber Error",
type: "danger", type: "danger",
}); });

View File

@ -7,13 +7,14 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//header" position="inside"> <xpath expr="//header" position="inside">
<field name="is_uber_order" invisible="1"/> <field name="is_uber_order" invisible="1"/>
<field name="uber_delivery_id" invisible="1"/>
<field name="uber_tracking_url" invisible="1"/> <field name="uber_tracking_url" invisible="1"/>
<field name="uber_status" invisible="1"/> <field name="uber_status" invisible="1"/>
<field name="uber_alert_triggered" invisible="1"/> <field name="uber_alert_triggered" invisible="1"/>
<button name="action_request_uber_delivery" <button name="action_request_uber_delivery"
string="Request Uber Delivery" string="Request Uber Delivery"
type="object" type="object"
invisible="is_uber_order == True or state != 'paid'" invisible="uber_delivery_id != False and uber_status not in ['cancelled', False] or state == 'cancel'"
class="oe_highlight"/> class="oe_highlight"/>
<button name="action_view_uber_map" <button name="action_view_uber_map"
string="📍 Track Driver" string="📍 Track Driver"
@ -23,15 +24,18 @@
<button name="action_sync_uber_status" <button name="action_sync_uber_status"
string="🔄 Sync Uber Status" string="🔄 Sync Uber Status"
type="object" type="object"
invisible="is_uber_order == False or uber_status in ['delivered', 'cancelled']" invisible="not uber_delivery_id or uber_status in ['delivered', 'cancelled']"
class="btn-secondary"/> class="btn-secondary"/>
<button name="action_cancel_uber_delivery" <button name="action_cancel_uber_delivery"
string="Cancel Uber Delivery" string="Cancel Uber Delivery"
type="object" type="object"
invisible="is_uber_order == False or uber_status in ['delivered', 'cancelled']" invisible="not uber_delivery_id or uber_status in ['delivered', 'cancelled']"
class="btn-danger"/> class="btn-danger"/>
</xpath> </xpath>
<xpath expr="//header" position="after"> <xpath expr="//header" position="after">
<div class="alert alert-danger mb-0" role="alert" invisible="not uber_alert_triggered or uber_status != 'pending'">
🚨 <strong>Attention!</strong> Driver not assigned for over 15 minutes. Please check Uber Dashboard.
</div>
<div class="alert alert-success m-0 p-2 text-center border-bottom-0 rounded-0" role="alert" invisible="uber_status != 'delivered'" style="font-weight: bold; background-color: #d1e7dd; color: #0f5132;"> <div class="alert alert-success m-0 p-2 text-center border-bottom-0 rounded-0" role="alert" invisible="uber_status != 'delivered'" style="font-weight: bold; background-color: #d1e7dd; color: #0f5132;">
<i class="fa fa-check-circle me-2"/> THE UBER DRIVER HAS SUCCESSFULLY DELIVERED THIS ORDER <i class="fa fa-check-circle me-2"/> THE UBER DRIVER HAS SUCCESSFULLY DELIVERED THIS ORDER
</div> </div>
@ -41,16 +45,14 @@
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger" invisible="uber_status != 'cancelled'"/> <widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger" invisible="uber_status != 'cancelled'"/>
</xpath> </xpath>
<xpath expr="//field[@name='pos_reference']" position="after"> <xpath expr="//field[@name='pos_reference']" position="after">
<field name="currency_id" invisible="1"/>
<field name="is_uber_order" invisible="1"/> <field name="is_uber_order" invisible="1"/>
<field name="uber_status" readonly="1" invisible="is_uber_order == False" decoration-info="uber_status == 'pending'" decoration-warning="uber_status == 'pickup'" decoration-success="uber_status == 'delivered'"/> <field name="delivery_type" widget="badge" invisible="delivery_type in ['none', False]"/>
<field name="uber_status" readonly="1" invisible="is_uber_order == False" decoration-info="uber_status == 'pending'" decoration-warning="uber_status in ['pickup', 'delivering']" decoration-success="uber_status == 'delivered'" decoration-danger="uber_status == 'cancelled'"/>
<field name="uber_delivery_id" readonly="1" invisible="not uber_delivery_id"/>
<field name="uber_eta" readonly="1" invisible="not uber_eta"/> <field name="uber_eta" readonly="1" invisible="not uber_eta"/>
<field name="uber_delivery_fee" widget="monetary" invisible="not uber_delivery_fee"/> <field name="uber_delivery_fee" widget="monetary" invisible="not uber_delivery_fee"/>
</xpath> </xpath>
<xpath expr="//header" position="after">
<div class="alert alert-danger mb-0" role="alert" invisible="not uber_alert_triggered or uber_status != 'pending'">
🚨 <strong>Attention!</strong> Driver not assigned for over 15 minutes. Please contact Uber support.
</div>
</xpath>
</field> </field>
</record> </record>
@ -70,7 +72,7 @@
<record id="action_uber_analytics" model="ir.actions.act_window"> <record id="action_uber_analytics" model="ir.actions.act_window">
<field name="name">Uber Performance Analytics</field> <field name="name">Uber Performance Analytics</field>
<field name="res_model">pos.order</field> <field name="res_model">pos.order</field>
<field name="view_mode">graph,tree</field> <field name="view_mode">graph,tree,form</field>
<field name="domain">[('is_uber_order', '=', True)]</field> <field name="domain">[('is_uber_order', '=', True)]</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">