implement order channels panel with Uber delivery integration and POS automation

This commit is contained in:
Alaguraj0361 2026-09-02 22:36:12 +05:30
parent ea4710dd40
commit ceb9ea818e
21 changed files with 2347 additions and 291 deletions

View File

@ -0,0 +1,385 @@
# 🍽️ Dine360 Shivasakthi Restaurant Suite
## Official Client Handbook & Operations Manual (Final Version 17.0)
---
## 📌 Document Control & Information
| Attribute | Details |
| :--- | :--- |
| **Product Name** | Dine360 Restaurant Management & POS Ecosystem |
| **Client / Brand** | Shivasakthi Restaurant |
| **System Version** | 17.0 Final Enterprise Edition |
| **Release Date** | September 2026 |
| **Deployment Mode** | Cloud / On-Premise Dockerized High Availability |
| **Target Audience** | Restaurant Owners, General Managers, Cashiers, Waiters, Kitchen Chefs, Delivery Operators |
---
# 📖 Table of Contents
1. [Executive Summary & System Architecture](#1-executive-summary--system-architecture)
2. [User Roles & Security Access Matrix](#2-user-roles--security-access-matrix)
3. [Master Data & Initial Configuration](#3-master-data--initial-configuration)
4. [Front-of-House (POS) Operations Guide](#4-front-of-house-pos-operations-guide)
5. [Digital Self-Ordering & QR Table Experience](#5-digital-self-ordering--qr-table-experience)
6. [Website Online Orders & Multi-Channel Intake](#6-website-online-orders--multi-channel-intake)
7. [Uber Eats & Third-Party Delivery Integration](#7-uber-eats--third-party-delivery-integration)
8. [Kitchen Display System (KDS) Operations](#8-kitchen-display-system-kds-operations)
9. [Table Reservation & Guest Seating Management](#9-table-reservation--guest-seating-management)
10. [Recipe Management & Automatic Inventory Deduction](#10-recipe-management--automatic-inventory-deduction)
11. [Hardware & Thermal Printing (QZ Tray Integration)](#11-hardware--thermal-printing-qz-tray-integration)
12. [Analytics, Reports & Executive Dashboard](#12-analytics-reports--executive-dashboard)
13. [Standard Operating Procedures (SOPs) & Checklists](#13-standard-operating-procedures-sops--checklists)
14. [Troubleshooting & Support Escalation](#14-troubleshooting--support-escalation)
---
# 1. Executive Summary & System Architecture
Dine360 Shivasakthi is an end-to-end hospitality operations suite built specifically to unify dining operations, digital ordering, kitchen execution, inventory consumption, and delivery logistics into a real-time reactive ecosystem.
```mermaid
flowchart TD
subgraph INTAKE_CHANNELS["Order Intake Channels"]
C1["QR Table Self-Order"]
C2["Dine-In POS / Waiter Tablet"]
C3["Website / eCommerce Shop"]
C4["Uber Eats & Delivery"]
C5["Phone & WhatsApp Orders"]
end
subgraph CORE_ENGINE["Dine360 Central POS Engine"]
POS["POS Session / Order Hub"]
RES["Reservation & Table Allocator"]
REC["Recipe BOM & Stock Deduction"]
QZ["QZ Tray Direct Thermal Print"]
end
subgraph KITCHEN_OPS["Back-of-House Execution"]
KDS["Kitchen Display System (KDS)"]
KOT["Thermal Kitchen KOT Slips"]
end
subgraph ANALYTICS["Management & Insights"]
DASH["Dine360 Central Dashboard"]
REP["Sales, Margin & Prep KPIs"]
end
C1 --> POS
C2 --> POS
C3 --> POS
C4 --> POS
C5 --> POS
POS --> KDS
POS --> KOT
POS --> REC
POS --> QZ
RES --> POS
POS --> DASH
KDS --> DASH
REC --> REP
```
### Key Architectural Strengths:
- **Zero-Friction Synchronization**: Changes made on POS, KDS, or customer phones reflect instantly via WebSocket bus communication.
- **Offline Resilience**: POS terminals maintain billing capability even during intermittent internet dropouts and sync automatically upon reconnection.
- **Unified Inventory**: When a dish is sold, its underlying raw materials (rice, spices, meat, vegetables) are deducted according to predefined recipes.
---
# 2. User Roles & Security Access Matrix
| Role | Permitted Areas | Restrictions | Primary Interface |
| :--- | :--- | :--- | :--- |
| **Super Admin / Owner** | Full system access, company settings, accounting, user permissions, reports | None | Odoo Backend & Dashboard |
| **Restaurant Manager** | Floor layout editor, product & price changes, reservations, shift reports, inventory overrides | Cannot alter system code or database configuration | Backend + POS Screen |
| **Cashier / Head Waiter** | Opening/closing POS sessions, taking orders, split bills, processing payments, applying approved discounts | Cannot delete submitted lines without manager approval | POS Touch UI |
| **Waiter / Floor Staff** | Table seating, order entry on tablets, sending orders to kitchen | Cannot close cash registers or refund payments | POS Mobile/Tablet UI |
| **Kitchen Chef / Cook** | KDS Screen: view order queue, bump orders (Waiting ➔ Preparing ➔ Ready ➔ Served) | No access to financial totals or customer billing | KDS Touch Screen |
---
# 3. Master Data & Initial Configuration
### 3.1 Configuring Restaurant Floors and Tables
1. Navigate to **Point of Sale > Configuration > Floor Plans**.
2. Click **Create** or select an existing floor (e.g., *Ground Floor*, *Rooftop*, *AC Family Hall*).
3. Add tables with specific shapes (Square, Round), seating capacities (2-seater, 4-seater, 6-seater), and table numbers.
4. Click **Edit Plan** to visually drag, rotate, and position tables to mirror the restaurant layout.
### 3.2 Menu Setup & Categorization
- Go to **Point of Sale > Products > Products**.
- Set **Product Type**:
- Cooked dishes: `Consumable` (e.g., *Special Chicken Biryani*, *Paneer Butter Masala*).
- Packed beverages: `Storable Product` (e.g., *Mineral Water Bottle*, *Soft Drinks*).
- Assign **POS Category** & **Multi-Categories** for intuitive tab filtering on the touchscreen.
- Configure **Product Variants & Modifiers** (e.g., *Spiciness: Mild / Medium / Spicy*, *Portion: Half / Full*).
---
# 4. Front-of-House (POS) Operations Guide
```
+-------------------------------------------------------------------------+
| [Dine360 POS NAVBAR] Table: T-04 Guest: 3 Channel: Dine-In (10:45) |
+-------------------------------------------------------------------------+
| [Categories] Starters | Main Course | Biryani | Breads | Desserts | All |
|-------------------------------------------------------------------------|
| [ORDER CART] | [PRODUCT GRID] |
| 1x Chicken Dum Biryani $12.50 | +----------------+ +---------------+ |
| - Extra Raita | | Chicken Biryani| | Mutton Sukka | |
| 2x Garlic Naan $6.00 | +----------------+ +---------------+ |
| 1x Mango Lassi $3.50 | +----------------+ +---------------+ |
| | | Paneer Tikka | | Butter Naan | |
| Total: $22.00 | +----------------+ +---------------+ |
| [Send to Kitchen] [Split] [Pay] | [1][2][3] [4][5][6] [7][8][9] [Qty] |
+-------------------------------------------------------------------------+
```
### 4.1 Opening a POS Shift
1. Navigate to **Point of Sale** and click **New Session** on your designated restaurant register.
2. The **Opening Cash Control** dialog will appear. Enter opening cash drawer float (e.g., `$200.00`).
3. Click **Open Session**.
### 4.2 Dine-In Table Ordering Workflow
1. Click on the designated table on the graphical floor plan.
2. Select menu items from the touch grid or search bar.
3. Tap on an order line to add special kitchen instructions (e.g., *"Less oil"*, *"Gluten free"*).
4. Click **Send to Kitchen (KOT)**:
- Items immediately route to the Kitchen Display System (KDS).
- Thermal KOT prints at the respective kitchen station (if enabled).
### 4.3 Bill Splitting & Payment Settlement
1. Click **Split Bill** to divide items across guests or split totals evenly.
2. Click **Payment**:
- Choose payment method: **Cash**, **Credit/Debit Card**, **UPI / Digital QR**, or **Customer Account**.
- Input tendered amount to calculate change automatically.
3. Click **Validate** to generate the tax receipt and automatically update inventory and financial journals.
### 4.4 Closing the Shift & Daily Z-Report
1. Click the **Close** button in the top-right corner of the POS screen.
2. Perform physical cash count and input under **Closing Cash Control**.
3. Any discrepancy between expected cash and actual cash is logged automatically.
4. Click **Close Session & Post Entries** to print the Z-Report.
---
# 5. Digital Self-Ordering & QR Table Experience
```mermaid
sequenceDiagram
autonumber
actor Customer as Guest (At Table)
participant Phone as Mobile Browser
participant Server as Dine360 Server
participant POS as POS Cashier
participant KDS as Kitchen KDS
Customer->>Phone: Scans Table QR Code
Phone->>Server: Loads Branded Menu (Table # & Floor Auto-locked)
Customer->>Phone: Selects Dishes & Modifiers
Customer->>Phone: Clicks "Send Order to Kitchen"
Phone->>Server: Transmits Order Payload
Server->>POS: Notifies POS Cashier Screen
Server->>KDS: Displays Ticket with "QR Table Order" Badge
KDS-->>Customer: Order Status Updates (Preparing ➔ Ready)
```
### 5.1 Setting up Table QR Codes
1. Go to **Point of Sale > Configuration > Floor Plans**.
2. Select the Floor and click **Print QR Table Stickers**.
3. Affix waterproof high-contrast QR stickers to each dining table.
### 5.2 Customer Order Journey
1. Guest scans QR code with any smartphone camera (no mobile app download required).
2. The branded Shivasakthi digital menu loads with images, allergen alerts, and spice levels.
3. Guest adds items to cart and taps **Send Order**.
4. The ticket appears instantly on the Kitchen KDS with the badge `QR Table Order / Table X`.
---
# 6. Website Online Orders & Multi-Channel Intake
Dine360 unifies online eCommerce orders directly inside the POS interface so staff do not need to check external email or third-party tablets.
### 6.1 Online Orders POS Pipeline
1. When a customer places an order on your website (`/shop`), an audio notification chimes on the POS terminal.
2. A badge counter appears on the POS top navbar under **Online Orders**.
3. The cashier clicks the order to review delivery address, contact number, and requested pickup time.
4. Cashier clicks **Accept & Send to Kitchen**.
### 6.2 Supported Intake Channels
- **Walk-in**: Standard direct counter/table order.
- **QR Table Order**: Scanned from dining table.
- **Online Shop**: Placed via restaurant website.
- **Phone / Call Order**: Tagged with customer telephone and pickup time.
- **WhatsApp Order**: Tagged with customer WhatsApp ID and delivery address.
- **Third-Party Platforms**: Uber Eats / Aggregators.
---
# 7. Uber Eats & Third-Party Delivery Integration
### 7.1 Configuration Settings
1. Go to **Point of Sale > Configuration > Uber Settings**.
2. Enter your credentials:
- **Client ID** & **Client Secret**
- **Customer ID (Uber Direct)**
- **Environment**: Toggle between `Sandbox / Testing` and `Production / Live`.
3. Set default **Uber Delivery Fee Product**.
4. Click **Test Connection** to automatically validate OAuth 2.0 scopes (`eats.deliveries`, `delivery`).
### 7.2 Automated Delivery Quoting & Dispatch
1. When a delivery order is placed, Dine360 queries the Uber API for real-time delivery estimates and fees.
2. Once the kitchen sets order status to **Ready**, driver dispatch is automatically requested from Uber.
3. Live courier tracking updates display directly on the POS order card.
---
# 8. Kitchen Display System (KDS) Operations
The KDS replaces lost paper tickets with a fast, touch-friendly, color-coded kitchen screen.
```
+-----------------------------------------------------------------------------+
| DINE360 KITCHEN DISPLAY (KDS) Station: All [Sound: ON] |
+--------------------+--------------------+--------------------+--------------+
| WAITING (New) | PREPARING | READY FOR PICKUP | SERVED |
+--------------------+--------------------+--------------------+--------------+
| [Ticket #104] 3m | [Ticket #102] 12m | [Ticket #101] 21m | [Ticket #99] |
| Table 4 (Dine-In) | Table 2 (QR Order) | Online Delivery | Table 8 |
| ------------------ | ------------------ | ------------------ | ------------ |
| 2x Mutton Biryani | 1x Paneer Tikka | 1x Chkn 65 | Done at |
| 1x Garlic Naan | 2x Butter Roti | 2x Egg Fried Rice | 10:32 AM |
| [ START PREPARING ]| [ MARK AS READY ] | [ DISPATCH / SERVE]| [ ARCHIVE ] |
+--------------------+--------------------+--------------------+--------------+
```
### 8.1 KDS Stage Progression
| Stage | Color Code | Action Required by Kitchen Staff |
| :--- | :--- | :--- |
| **1. Waiting** | 🟡 Yellow / Orange | New order received. Tap **Start Preparing** when cooking starts. |
| **2. Preparing** | 🔵 Blue | Cooking in progress. Cooking timer starts tracking prep speed. |
| **3. Ready** | 🟢 Green | Food is ready for pickup. Waiter / Courier receives ready notification. |
| **4. Served / Done** | ⚪ Gray / Dark | Dish served to table or handed to courier. Order archives from active view. |
### 8.2 Line-Item Bumping & Course Management
- Chefs can tap individual lines on multi-course tickets (e.g., mark *Starters* as Ready while *Mains* remain Preparing).
- Audio Chimes alert the kitchen whenever an urgent rush order or modification arrives.
---
# 9. Table Reservation & Guest Seating Management
### 9.1 Online Customer Booking Flow
1. Guests visit the reservation portal (`/restaurant/reservation`).
2. Guest enters **Date**, **Time Slot**, **Party Size**, **Name**, **Phone**, and **Email**.
3. System verifies real-time capacity and table availability against existing bookings and peak hour rules.
4. Upon booking, a confirmation message and WhatsApp notification link are generated.
### 9.2 Hostess / POS Table Linking
1. In the backend or POS Reservation tab, upcoming bookings appear organized by time slot.
2. Click **Assign Table** to lock the table on the graphical floor plan.
3. When guests arrive, click **Mark as Seated** to open a POS order automatically for that table.
---
# 10. Recipe Management & Automatic Inventory Deduction
Dine360 eliminates manual stock variances by deducting raw ingredients at the exact moment a dish is rung up.
```
Dish: Chicken Dum Biryani (1 Portion)
├── Raw Chicken: 250g (Deducted from Main Cold Storage)
├── Basmati Rice: 200g (Deducted from Dry Goods Inventory)
├── Ghee / Oil: 30ml (Deducted from Kitchen Stock)
├── Biryani Masala: 15g (Deducted from Spice Pantry)
└── Mint & Coriander: 10g (Deducted from Fresh Produce)
```
### 10.1 Creating a Dish Recipe (BOM)
1. Navigate to **Point of Sale > Products > Recipes**.
2. Click **Create** and select the finished Dish (e.g., *Chicken Dum Biryani*).
3. Under **Ingredients**, add each raw material product along with standard consumption quantities and Units of Measure (kg, g, L, ml, pcs).
4. The system calculates **Total Dish Cost** and dynamic profit margin automatically.
### 10.2 Real-time Stock Depletion
- As orders transition to `Paid` / `Done`, stock moves are automatically recorded in the background.
- Low inventory alerts notify the purchasing manager before stock runs out.
---
# 11. Hardware & Thermal Printing (QZ Tray Integration)
Dine360 uses **QZ Tray** for instant, silent thermal printing without browser print popups.
### 11.1 Supported Hardware
- **Receipt Printers**: Epson TM-T88VI, TM-T20III, Star TSP143, Sunmi POS, Citizen CT-S310.
- **KOT Kitchen Printers**: 80mm Impact / Thermal Network or USB Printers.
- **Cash Drawers**: Standard RJ11/RJ12 24V trigger drawers connected via receipt printer.
- **Barcode / QR Scanners**: USB HID & Bluetooth 2D Scanners.
### 11.2 QZ Tray Setup Checklist
1. Install QZ Tray on the local POS computer ([qz.io](https://qz.io)).
2. In POS Settings, enable **QZ Tray Printing** and input the local printer name (e.g., `EPSON_TM_T88_RECEIPT`).
3. Set **Auto-Print Receipt** and **Auto-Cut Paper** to `True`.
---
# 12. Analytics, Reports & Executive Dashboard
Access the executive analytics hub under **Dine360 Dashboard**:
```
+-----------------------------------------------------------------------------+
| DINE360 MANAGEMENT DASHBOARD [Period: Today] |
+-----------------------------------------------------------------------------+
| Today's Gross Sales | Total Orders | Avg Kitchen Turnaround | Active Tables |
| $4,892.50 | 184 | 14.2 Mins | 18 / 24 |
+-----------------------------------------------------------------------------+
| SALES BY INTAKE CHANNEL | HOURLY SALES DISTRIBUTION |
| ----------------------- | ------------------------- |
| ■ Dine-In: 62% ($3,033.35) | Peak Lunch: 12:30 PM - 02:30 PM |
| ■ QR Orders: 18% ($880.65) | Peak Dinner: 07:30 PM - 10:00 PM |
| ■ Online Shop:12% ($587.10) | |
| ■ Uber Eats: 8% ($391.40) | Top Seller: Chicken Dum Biryani |
+-----------------------------------------------------------------------------+
```
---
# 13. Standard Operating Procedures (SOPs) & Checklists
### 🌅 Morning Opening Checklist (FOH & BOH)
- [ ] Turn on POS Terminals, KDS Kitchen Screens, and Cash Drawers.
- [ ] Verify QZ Tray icon is green in the system tray.
- [ ] Open POS Session and declare initial opening cash float.
- [ ] Verify receipt and kitchen printer paper rolls.
- [ ] Check Table Reservation calendar for lunch bookings.
### 🌙 Night Closing Checklist
- [ ] Ensure all active KDS tickets are marked as `Served`.
- [ ] Ensure all open tables in POS are settled and paid.
- [ ] Count physical cash drawer balance and enter under **Closing Cash Control**.
- [ ] Validate shift close and print Z-Report.
- [ ] Back office auto-synchronizes daily stock moves and ledger entries.
---
# 14. Troubleshooting & Support Escalation
| Issue / Symptom | Root Cause | Immediate Resolution |
| :--- | :--- | :--- |
| **Receipt printer not firing** | QZ Tray disconnected or printer driver offline | 1. Check USB/LAN cable.<br>2. Restart QZ Tray application from Windows taskbar.<br>3. Verify printer test page prints from Windows Settings. |
| **KDS screen not refreshing** | Local network dropout | 1. Check Wi-Fi connection on kitchen tablet.<br>2. Refresh browser tab (Press `F5` or touch reload icon). |
| **Uber API authentication error** | Expired or incorrect OAuth credentials | 1. Go to POS > Configuration > Uber Settings.<br>2. Click **Test Connection** to auto-refresh access token. |
| **Table shows occupied when empty** | Unpaid draft order on table | 1. Click on table in POS floor plan.<br>2. Settle payment or transfer order to another table. |
---
### 📞 Technical Support & Maintenance
For system modifications, custom reports, or hardware onboarding, contact the **Dine360 Support & Engineering Team**:
- **System Portal**: `http://localhost:10000`
- **Database**: `Shivasakthi`
- **Module Version**: `17.0 Final Release`

View File

@ -5,6 +5,6 @@ class ProductTemplate(models.Model):
is_kitchen_item = fields.Boolean( is_kitchen_item = fields.Boolean(
string='Show in KDS', string='Show in KDS',
default=False, default=True,
help="If checked, this product will appear in the Kitchen Display System when ordered." help="If checked, this product will appear in the Kitchen Display System when ordered."
) )

View File

@ -58,7 +58,8 @@ class SaleOrder(models.Model):
# Skip non-kitchen items, but allow delivery lines for accurate total matching # Skip non-kitchen items, but allow delivery lines for accurate total matching
is_delivery_line = getattr(line, 'is_delivery', False) is_delivery_line = getattr(line, 'is_delivery', False)
if not is_delivery_line and (not line.product_id.is_kitchen_item or line.product_id.type == 'service'): is_kitchen = getattr(line.product_id, 'is_kitchen_item', True)
if not is_delivery_line and (not is_kitchen or line.product_id.type == 'service'):
continue continue
lines_data.append((0, 0, { lines_data.append((0, 0, {
@ -86,7 +87,7 @@ class SaleOrder(models.Model):
# 4. Create POS Order (in Draft/New state to avoid double accounting) # 4. Create POS Order (in Draft/New state to avoid double accounting)
# Use skip_kds_notify context to prevent immediate KDS notification # Use skip_kds_notify context to prevent immediate KDS notification
# Online orders will be sent to KDS only after cashier confirmation # Online orders will be sent to KDS only after cashier confirmation
pos_order = PosOrder.with_context(skip_kds_notify=True).create({ pos_vals = {
'session_id': session.id, 'session_id': session.id,
'company_id': sale_order.company_id.id, 'company_id': sale_order.company_id.id,
'partner_id': sale_order.partner_id.id, 'partner_id': sale_order.partner_id.id,
@ -98,8 +99,13 @@ class SaleOrder(models.Model):
'amount_paid': 0.0, # Not processing payment in POS to avoid duplication 'amount_paid': 0.0, # Not processing payment in POS to avoid duplication
'amount_return': 0.0, 'amount_return': 0.0,
'note': f"From Website Order {sale_order.name}", 'note': f"From Website Order {sale_order.name}",
# 'state': 'draft', # Default is draft 'order_source': 'online',
}) 'is_online_order': True,
'sale_order_id': sale_order.id,
'fulfilment_type': getattr(sale_order, 'fulfilment_type', 'pickup'),
}
pos_order = PosOrder.with_context(skip_kds_notify=True).create(pos_vals)
sale_order.write({'pos_order_id': pos_order.id})
# Notification to KDS is deferred until cashier confirms via dine360_online_orders # Notification to KDS is deferred until cashier confirms via dine360_online_orders
_logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} (pending cashier confirmation).") _logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} (pending cashier confirmation).")

View File

@ -15,37 +15,70 @@ class Dine360OnlineOrders(http.Controller):
return True return True
class Dine360WebsiteSaleOnline(WebsiteSale): class Dine360WebsiteSaleOnline(WebsiteSale):
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
def shop_payment(self, **post): @http.route(['/shop/cart'], type='http', auth="public", website=True, sitemap=False)
def cart(self, **post):
"""When user visits cart, remove any delivery fee so cart shows ONLY food items subtotal and tax"""
request.session.pop('address_confirmed_for_order', None)
order = request.website.sale_get_order() order = request.website.sale_get_order()
if order and not order.carrier_id: if order and order.state == 'draft':
# Bypass delivery validation by assigning the first available delivery carrier delivery_lines = order.order_line.filtered(lambda l: l.is_delivery)
carriers = order._get_delivery_methods() if delivery_lines:
if not carriers: delivery_lines.sudo().unlink()
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) order.sudo().write({
'carrier_id': False,
'uber_delivery_fee': 0.0
})
return super(Dine360WebsiteSaleOnline, self).cart(**post)
if carriers: @http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False)
order.carrier_id = carriers[0].id def cart_update_json(self, *args, **kwargs):
price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0 """Ensure delivery fee is not shown when modifying cart quantities"""
order.set_delivery_line(carriers[0], price) res = super(Dine360WebsiteSaleOnline, self).cart_update_json(*args, **kwargs)
order = request.website.sale_get_order()
if order and order.state == 'draft':
delivery_lines = order.order_line.filtered(lambda l: l.is_delivery)
if delivery_lines:
delivery_lines.sudo().unlink()
order.sudo().write({
'carrier_id': False,
'uber_delivery_fee': 0.0
})
return res
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post) @http.route(['/shop/checkout'], type='http', auth="public", website=True, sitemap=False)
def checkout(self, **post):
"""Enforce redirect to address & fulfillment selection page every single time from cart"""
order = request.website.sale_get_order()
if not order or order.state != 'draft':
return request.redirect('/shop')
@http.route('/shop/payment/transaction/<int:order_id>', type='json', auth="public", website=True) if not order.order_line:
def shop_payment_transaction(self, order_id, access_token=None, **kwargs): return request.redirect('/shop/cart')
# Force bypass "No shipping method is selected" error during payment processing
order = request.env['sale.order'].sudo().browse(order_id) # If user has already selected & submitted the address in this flow, proceed to standard checkout/payment
if order and not order.carrier_id: if post.get('express') or request.session.get('address_confirmed_for_order') == order.id:
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) return super(Dine360WebsiteSaleOnline, self).checkout(**post)
if carriers:
order.carrier_id = carriers[0].id # Otherwise, always direct to the unified delivery/pickup address page
order.set_delivery_line(carriers[0], 0.0) partner_id = order.partner_id.id if order.partner_id else 0
return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs) return request.redirect(f'/shop/address?mode=billing&partner_id={partner_id}')
@http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False) @http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False)
def address(self, **post): def address(self, **post):
# Override to inject dummy address values for pickup orders before Odoo validates them """Process address form submission, record fulfillment type, and mark address as confirmed"""
if post.get('submitted') and post.get('fulfilment_type') == 'pickup': order = request.website.sale_get_order()
if post.get('submitted'):
if order:
request.session['address_confirmed_for_order'] = order.id
fulfilment_type = post.get('fulfilment_type', 'delivery')
order.sudo().write({
'fulfilment_type': fulfilment_type,
'order_source': 'online'
})
# For store pickup orders, auto-populate dummy fields so Odoo's backend validation succeeds
if post.get('fulfilment_type') == 'pickup':
if not post.get('street'): post['street'] = 'Store Pickup' if not post.get('street'): post['street'] = 'Store Pickup'
if not post.get('city'): post['city'] = 'Local' if not post.get('city'): post['city'] = 'Local'
if not post.get('zip'): post['zip'] = '00000' if not post.get('zip'): post['zip'] = '00000'
@ -53,7 +86,6 @@ class Dine360WebsiteSaleOnline(WebsiteSale):
country = request.env['res.country'].sudo().search([], limit=1) country = request.env['res.country'].sudo().search([], limit=1)
post['country_id'] = str(country.id) if country else '' post['country_id'] = str(country.id) if country else ''
# Request.params must also be updated for Odoo 17 validation
for k, v in post.items(): for k, v in post.items():
if k in ['street', 'city', 'zip', 'country_id']: if k in ['street', 'city', 'zip', 'country_id']:
if hasattr(request.params, 'update'): if hasattr(request.params, 'update'):
@ -63,3 +95,38 @@ class Dine360WebsiteSaleOnline(WebsiteSale):
pass pass
return super(Dine360WebsiteSaleOnline, self).address(**post) return super(Dine360WebsiteSaleOnline, self).address(**post)
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
def shop_payment(self, **post):
"""Ensure customer came from address selection, and bypass missing delivery carrier validation"""
order = request.website.sale_get_order()
if not order or order.state != 'draft' or not order.order_line:
return request.redirect('/shop/cart')
# If user directly visits /shop/payment without passing the address & fulfillment page, redirect them
if request.session.get('address_confirmed_for_order') != order.id:
partner_id = order.partner_id.id if order.partner_id else 0
return request.redirect(f'/shop/address?mode=billing&partner_id={partner_id}')
# For delivery orders, ensure the delivery line is active and reflects the stored fee
if order.fulfilment_type == 'delivery':
delivery_line = order.order_line.filtered(lambda l: l.is_delivery)
fee = getattr(order, 'uber_delivery_fee', 0.0)
if not delivery_line and fee > 0 and hasattr(order, '_add_uber_delivery_fee'):
order.sudo()._add_uber_delivery_fee(fee)
elif order.fulfilment_type in ['pickup', 'dine_in']:
# Ensure no leftover delivery line for pickup
order.order_line.filtered(lambda l: l.is_delivery).sudo().unlink()
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post)
@http.route('/shop/payment/transaction/<int:order_id>', type='json', auth="public", website=True)
def shop_payment_transaction(self, order_id, access_token=None, **kwargs):
"""Preserve delivery fee during payment transaction initiation"""
order = request.env['sale.order'].sudo().browse(order_id)
if order and order.fulfilment_type == 'delivery':
delivery_line = order.order_line.filtered(lambda l: l.is_delivery)
fee = getattr(order, 'uber_delivery_fee', 0.0)
if not delivery_line and fee > 0 and hasattr(order, '_add_uber_delivery_fee'):
order._add_uber_delivery_fee(fee)
return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs)

View File

@ -90,14 +90,14 @@ class PosOrder(models.Model):
# Set all order lines to 'waiting' so KDS picks them up # Set all order lines to 'waiting' so KDS picks them up
for line in self.lines: for line in self.lines:
if line.product_id.is_kitchen_item and line.product_id.type != 'service': if line.product_id.type != 'service':
line.write({ line.write({
'preparation_status': 'waiting', 'preparation_status': 'waiting',
}) })
# Notify KDS # Notify KDS
self.lines.filtered( self.lines.filtered(
lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service' lambda l: l.product_id.type != 'service'
)._notify_kds() )._notify_kds()
# Notify POS that order was confirmed # Notify POS that order was confirmed

View File

@ -132,13 +132,14 @@ class SaleOrderOnline(models.Model):
def _is_shippable_order(self): def _is_shippable_order(self):
""" """
Treat pickup, delivery and other types as non-shippable for Odoo's standard validation. Pickup and dine-in are non-shippable (no delivery fee).
This enables 'Billing-only' checkout which is more reliable for payment providers. Delivery orders ARE shippable to preserve the delivery fee on payment page.
The Uber delivery line is protected by our _remove_delivery_line override.
""" """
self.ensure_one() self.ensure_one()
if self.fulfilment_type in ['pickup', 'delivery', 'dine_in', 'walk_in']: if self.fulfilment_type in ['pickup', 'dine_in', 'walk_in']:
return False return False
if self.fulfilment_type == 'delivery':
return True
return super()._is_shippable_order() return super()._is_shippable_order()
def _check_carrier_quotation(self, force_carrier_id=None, **kwargs): def _check_carrier_quotation(self, force_carrier_id=None, **kwargs):

View File

@ -18,8 +18,11 @@ class Dine360OrderChannelsController(http.Controller):
'name': p.name, 'name': p.name,
'phone': p.phone or p.mobile or '', 'phone': p.phone or p.mobile or '',
'street': p.street or '', 'street': p.street or '',
'street2': p.street2 or '',
'city': p.city or '', 'city': p.city or '',
'state': p.state_id.name if p.state_id else '',
'zip': p.zip or '', 'zip': p.zip or '',
'country': p.country_id.name if p.country_id else '',
'display_name': p.display_name, 'display_name': p.display_name,
} for p in partners] } for p in partners]

View File

@ -42,8 +42,11 @@ class PosOrderChannels(models.Model):
help='Delivery address for this order' help='Delivery address for this order'
) )
delivery_street = fields.Char('Delivery Street', compute='_compute_delivery_address', store=True, readonly=False) delivery_street = fields.Char('Delivery Street', compute='_compute_delivery_address', store=True, readonly=False)
delivery_street2 = fields.Char('Apartment / Suite', compute='_compute_delivery_address', store=True, readonly=False)
delivery_city = fields.Char('Delivery City', compute='_compute_delivery_address', store=True, readonly=False) delivery_city = fields.Char('Delivery City', compute='_compute_delivery_address', store=True, readonly=False)
delivery_state = fields.Char('Delivery State', compute='_compute_delivery_address', store=True, readonly=False)
delivery_zip = fields.Char('Delivery Zip', compute='_compute_delivery_address', store=True, readonly=False) delivery_zip = fields.Char('Delivery Zip', compute='_compute_delivery_address', store=True, readonly=False)
delivery_country = fields.Char('Delivery Country', compute='_compute_delivery_address', store=True, readonly=False)
delivery_phone = fields.Char('Delivery Phone', compute='_compute_delivery_address', store=True, readonly=False) delivery_phone = fields.Char('Delivery Phone', compute='_compute_delivery_address', store=True, readonly=False)
delivery_notes = fields.Text('Delivery Notes', help='Special delivery instructions') delivery_notes = fields.Text('Delivery Notes', help='Special delivery instructions')
@ -55,7 +58,7 @@ class PosOrderChannels(models.Model):
# ----------------------------------------------------------- # -----------------------------------------------------------
# Social Media: reference # Social Media: reference
# ----------------------------------------------------------- # Social Media: reference # -----------------------------------------------------------
social_ref = fields.Char('Social Ref', help='Instagram/Facebook message/post reference') social_ref = fields.Char('Social Ref', help='Instagram/Facebook message/post reference')
# Telephone: customer phone for phone orders # Telephone: customer phone for phone orders
@ -79,13 +82,19 @@ class PosOrderChannels(models.Model):
p = order.delivery_partner_id p = order.delivery_partner_id
if p: if p:
order.delivery_street = p.street or '' order.delivery_street = p.street or ''
order.delivery_street2 = p.street2 or ''
order.delivery_city = p.city or '' order.delivery_city = p.city or ''
order.delivery_state = p.state_id.name if p.state_id else ''
order.delivery_zip = p.zip or '' order.delivery_zip = p.zip or ''
order.delivery_country = p.country_id.name if p.country_id else ''
order.delivery_phone = p.phone or p.mobile or '' order.delivery_phone = p.phone or p.mobile or ''
else: else:
order.delivery_street = order.delivery_street or '' order.delivery_street = order.delivery_street or ''
order.delivery_street2 = order.delivery_street2 or ''
order.delivery_city = order.delivery_city or '' order.delivery_city = order.delivery_city or ''
order.delivery_state = order.delivery_state or ''
order.delivery_zip = order.delivery_zip or '' order.delivery_zip = order.delivery_zip or ''
order.delivery_country = order.delivery_country or ''
order.delivery_phone = order.delivery_phone or '' order.delivery_phone = order.delivery_phone or ''
# ----------------------------------------------------------- # -----------------------------------------------------------
@ -109,8 +118,11 @@ class PosOrderChannels(models.Model):
fields_dict['order_source'] = ui_order.get('order_source', 'walk_in') fields_dict['order_source'] = ui_order.get('order_source', 'walk_in')
fields_dict['fulfilment_type'] = ui_order.get('fulfilment_type', 'dine_in') fields_dict['fulfilment_type'] = ui_order.get('fulfilment_type', 'dine_in')
fields_dict['delivery_street'] = ui_order.get('delivery_street', '') fields_dict['delivery_street'] = ui_order.get('delivery_street', '')
fields_dict['delivery_street2'] = ui_order.get('delivery_street2', '')
fields_dict['delivery_city'] = ui_order.get('delivery_city', '') fields_dict['delivery_city'] = ui_order.get('delivery_city', '')
fields_dict['delivery_state'] = ui_order.get('delivery_state', '')
fields_dict['delivery_zip'] = ui_order.get('delivery_zip', '') fields_dict['delivery_zip'] = ui_order.get('delivery_zip', '')
fields_dict['delivery_country'] = ui_order.get('delivery_country', '')
fields_dict['delivery_phone'] = ui_order.get('delivery_phone', '') fields_dict['delivery_phone'] = ui_order.get('delivery_phone', '')
fields_dict['delivery_notes'] = ui_order.get('delivery_notes', '') fields_dict['delivery_notes'] = ui_order.get('delivery_notes', '')
fields_dict['whatsapp_number'] = ui_order.get('whatsapp_number', '') fields_dict['whatsapp_number'] = ui_order.get('whatsapp_number', '')
@ -126,8 +138,11 @@ class PosOrderChannels(models.Model):
result['order_source'] = order.order_source result['order_source'] = order.order_source
result['fulfilment_type'] = order.fulfilment_type result['fulfilment_type'] = order.fulfilment_type
result['delivery_street'] = order.delivery_street or '' result['delivery_street'] = order.delivery_street or ''
result['delivery_street2'] = order.delivery_street2 or ''
result['delivery_city'] = order.delivery_city or '' result['delivery_city'] = order.delivery_city or ''
result['delivery_state'] = order.delivery_state or ''
result['delivery_zip'] = order.delivery_zip or '' result['delivery_zip'] = order.delivery_zip or ''
result['delivery_country'] = order.delivery_country or ''
result['delivery_phone'] = order.delivery_phone or '' result['delivery_phone'] = order.delivery_phone or ''
result['delivery_notes'] = order.delivery_notes or '' result['delivery_notes'] = order.delivery_notes or ''
result['whatsapp_number'] = order.whatsapp_number or '' result['whatsapp_number'] = order.whatsapp_number or ''

View File

@ -9,13 +9,14 @@ import { jsonrpc } from "@web/core/network/rpc_service";
* Allows staff to select: * Allows staff to select:
* - Order Source (Walk-in / Phone / WhatsApp / Social / Online) * - Order Source (Walk-in / Phone / WhatsApp / Social / Online)
* - Fulfilment Type (Dine-In / Pickup / Delivery) * - Fulfilment Type (Dine-In / Pickup / Delivery)
* - Delivery address (when Delivery is selected) * - Delivery address with Uber distance calculation & coverage verification
*/ */
export class ChannelPanel extends Component { export class ChannelPanel extends Component {
static template = "dine360_order_channels.ChannelPanel"; static template = "dine360_order_channels.ChannelPanel";
setup() { setup() {
this.pos = usePos(); this.pos = usePos();
this.quoteTimer = null;
this.SOURCE_LABELS = { this.SOURCE_LABELS = {
walk_in: 'Walk-In', walk_in: 'Walk-In',
@ -41,6 +42,7 @@ export class ChannelPanel extends Component {
searchQuery: '', searchQuery: '',
searchResults: [], searchResults: [],
searching: false, searching: false,
quote: null, // { loading: bool, success: bool, fee: float, distance_km: float, eta: str, error: str }
}); });
} }
@ -68,12 +70,19 @@ export class ChannelPanel extends Component {
return this.pos?.config?.show_channel_panel !== false; return this.pos?.config?.show_channel_panel !== false;
} }
formatMoney(amount) {
if (this.pos && typeof this.pos.format_currency === 'function') {
return this.pos.format_currency(amount);
}
const symbol = this.pos?.currency?.symbol || '$';
return `${symbol}${Number(amount || 0).toFixed(2)}`;
}
// --- Source Selector --- // --- Source Selector ---
onSourceChange(source) { onSourceChange(source) {
const order = this.currentOrder; const order = this.currentOrder;
if (!order) return; if (!order) return;
order.setOrderSource(source); order.setOrderSource(source);
// Trigger re-render
this.state.showDelivery = this.isDelivery; this.state.showDelivery = this.isDelivery;
} }
@ -83,6 +92,13 @@ export class ChannelPanel extends Component {
if (!order) return; if (!order) return;
order.setFulfilmentType(type); order.setFulfilmentType(type);
this.state.showDelivery = (type === 'delivery'); this.state.showDelivery = (type === 'delivery');
if (type !== 'delivery') {
this.state.quote = null;
this.removeDeliveryFeeLine();
} else {
this.triggerQuoteCheck(100);
}
} }
// --- Partner Address Search --- // --- Partner Address Search ---
@ -94,36 +110,176 @@ export class ChannelPanel extends Component {
return; return;
} }
this.state.searching = true; this.state.searching = true;
try {
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 }); const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
this.state.searchResults = results; this.state.searchResults = results;
} catch (e) {
console.warn("Partner search error:", e);
} finally {
this.state.searching = false; this.state.searching = false;
} }
}
onSelectPartner(partner) { onSelectPartner(partner) {
const order = this.currentOrder; const order = this.currentOrder;
if (!order) return; if (!order) return;
order.setDeliveryAddress({ order.setDeliveryAddress({
street: partner.street, street: partner.street,
street2: partner.street2,
city: partner.city, city: partner.city,
state: partner.state || 'Ontario',
zip: partner.zip, zip: partner.zip,
country: partner.country || 'Canada',
phone: partner.phone, phone: partner.phone,
}); });
this.state.searchQuery = partner.display_name; this.state.searchQuery = partner.display_name;
this.state.searchResults = []; this.state.searchResults = [];
this.triggerQuoteCheck(0); // instant quote check
} }
onDeliveryFieldChange(field, ev) { onDeliveryFieldChange(field, ev) {
const order = this.currentOrder; const order = this.currentOrder;
if (!order) return; if (!order) return;
// Ad-hoc street/city/zip/phone/notes editing
const current = { const current = {
street: order.delivery_street, street: order.delivery_street,
street2: order.delivery_street2,
city: order.delivery_city, city: order.delivery_city,
state: order.delivery_state,
zip: order.delivery_zip, zip: order.delivery_zip,
country: order.delivery_country,
phone: order.delivery_phone, phone: order.delivery_phone,
notes: order.delivery_notes, notes: order.delivery_notes,
}; };
current[field] = ev.target.value; current[field] = ev.target.value;
order.setDeliveryAddress(current); order.setDeliveryAddress(current);
this.triggerQuoteCheck(600); // 600ms debounce
}
triggerQuoteCheck(delay = 600) {
if (this.quoteTimer) {
clearTimeout(this.quoteTimer);
}
this.quoteTimer = setTimeout(() => {
this.fetchUberQuote();
}, delay);
}
async fetchUberQuote() {
const order = this.currentOrder;
if (!order || order.fulfilment_type !== 'delivery') return;
const street = (order.delivery_street || '').trim();
const street2 = (order.delivery_street2 || '').trim();
const city = (order.delivery_city || '').trim();
const state = (order.delivery_state || '').trim();
const zip = (order.delivery_zip || '').trim();
const country = (order.delivery_country || '').trim();
if (!street && !city && !zip) {
this.state.quote = null;
return;
}
this.state.quote = { loading: true };
try {
const res = await jsonrpc('/pos/uber/quote', {
address_data: {
street: street,
street2: street2,
city: city,
state: state,
zip: zip,
country: country,
phone: order.delivery_phone || '',
}
});
if (res && res.success) {
let formattedEta = '';
if (res.eta) {
try {
const d = new Date(res.eta);
formattedEta = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
formattedEta = res.eta;
}
}
this.state.quote = {
loading: false,
success: true,
fee: res.fee_amount,
distance_km: res.distance_km || 3.5,
eta: formattedEta,
productId: res.delivery_product_id,
};
// Automatically apply fee line to POS order
this.applyDeliveryFeeLine(res.fee_amount, res.delivery_product_id);
} else {
this.state.quote = {
loading: false,
success: false,
error: res?.error || 'Address outside delivery coverage area.',
};
this.removeDeliveryFeeLine();
}
} catch (err) {
console.error("Uber POS Quote Error:", err);
this.state.quote = {
loading: false,
success: false,
error: 'Could not connect to Uber delivery service.',
};
}
}
applyDeliveryFeeLine(feeAmount, productId) {
const order = this.currentOrder;
if (!order) return;
order.uber_delivery_fee = feeAmount;
// Find existing delivery line
const lines = order.get_orderlines();
let deliveryLine = lines.find(line => {
const p = line.get_product();
return (productId && p.id === productId) || (p.name && p.name.toLowerCase().includes('delivery'));
});
if (feeAmount > 0) {
if (deliveryLine) {
deliveryLine.set_unit_price(feeAmount);
} else {
let product = productId ? this.pos.db.get_product_by_id(productId) : null;
if (!product) {
const allProducts = Object.values(this.pos.db.product_by_id || {});
product = allProducts.find(p => p.name && p.name.toLowerCase().includes('delivery')) || allProducts[0];
}
if (product) {
order.add_product(product, {
price: feeAmount,
quantity: 1,
merge: false,
});
}
}
} else if (deliveryLine) {
order.remove_orderline(deliveryLine);
}
}
removeDeliveryFeeLine() {
const order = this.currentOrder;
if (!order) return;
order.uber_delivery_fee = 0.0;
const lines = order.get_orderlines();
const deliveryLine = lines.find(line => {
const p = line.get_product();
return p && p.name && p.name.toLowerCase().includes('delivery');
});
if (deliveryLine) {
order.remove_orderline(deliveryLine);
}
} }
} }

View File

@ -4,7 +4,7 @@ import { patch } from "@web/core/utils/patch";
import { Order } from "@point_of_sale/app/store/models"; import { Order } from "@point_of_sale/app/store/models";
/** /**
* Patch the POS Order model to carry channel data. * Patch the POS Order model to carry channel data and full delivery address.
* These values are sent to the backend via _order_fields which we overrode in Python. * These values are sent to the backend via _order_fields which we overrode in Python.
*/ */
patch(Order.prototype, { patch(Order.prototype, {
@ -15,10 +15,14 @@ patch(Order.prototype, {
this.order_source = config.default_order_source || 'walk_in'; this.order_source = config.default_order_source || 'walk_in';
this.fulfilment_type = config.default_fulfilment_type || 'dine_in'; this.fulfilment_type = config.default_fulfilment_type || 'dine_in';
this.delivery_street = ''; this.delivery_street = '';
this.delivery_street2 = '';
this.delivery_city = ''; this.delivery_city = '';
this.delivery_state = '';
this.delivery_zip = ''; this.delivery_zip = '';
this.delivery_country = '';
this.delivery_phone = ''; this.delivery_phone = '';
this.delivery_notes = ''; this.delivery_notes = '';
this.uber_delivery_fee = 0.0;
this.whatsapp_number = ''; this.whatsapp_number = '';
this.social_ref = ''; this.social_ref = '';
this.telephone_number = ''; this.telephone_number = '';
@ -29,10 +33,14 @@ patch(Order.prototype, {
json.order_source = this.order_source; json.order_source = this.order_source;
json.fulfilment_type = this.fulfilment_type; json.fulfilment_type = this.fulfilment_type;
json.delivery_street = this.delivery_street; json.delivery_street = this.delivery_street;
json.delivery_street2 = this.delivery_street2;
json.delivery_city = this.delivery_city; json.delivery_city = this.delivery_city;
json.delivery_state = this.delivery_state;
json.delivery_zip = this.delivery_zip; json.delivery_zip = this.delivery_zip;
json.delivery_country = this.delivery_country;
json.delivery_phone = this.delivery_phone; json.delivery_phone = this.delivery_phone;
json.delivery_notes = this.delivery_notes; json.delivery_notes = this.delivery_notes;
json.uber_delivery_fee = this.uber_delivery_fee;
json.whatsapp_number = this.whatsapp_number; json.whatsapp_number = this.whatsapp_number;
json.social_ref = this.social_ref; json.social_ref = this.social_ref;
json.telephone_number = this.telephone_number; json.telephone_number = this.telephone_number;
@ -44,10 +52,14 @@ patch(Order.prototype, {
this.order_source = json.order_source || 'walk_in'; this.order_source = json.order_source || 'walk_in';
this.fulfilment_type = json.fulfilment_type || 'dine_in'; this.fulfilment_type = json.fulfilment_type || 'dine_in';
this.delivery_street = json.delivery_street || ''; this.delivery_street = json.delivery_street || '';
this.delivery_street2 = json.delivery_street2 || '';
this.delivery_city = json.delivery_city || ''; this.delivery_city = json.delivery_city || '';
this.delivery_state = json.delivery_state || '';
this.delivery_zip = json.delivery_zip || ''; this.delivery_zip = json.delivery_zip || '';
this.delivery_country = json.delivery_country || '';
this.delivery_phone = json.delivery_phone || ''; this.delivery_phone = json.delivery_phone || '';
this.delivery_notes = json.delivery_notes || ''; this.delivery_notes = json.delivery_notes || '';
this.uber_delivery_fee = json.uber_delivery_fee || 0.0;
this.whatsapp_number = json.whatsapp_number || ''; this.whatsapp_number = json.whatsapp_number || '';
this.social_ref = json.social_ref || ''; this.social_ref = json.social_ref || '';
this.telephone_number = json.telephone_number || ''; this.telephone_number = json.telephone_number || '';
@ -63,8 +75,11 @@ patch(Order.prototype, {
setDeliveryAddress(fields) { setDeliveryAddress(fields) {
this.delivery_street = fields.street || ''; this.delivery_street = fields.street || '';
this.delivery_street2 = fields.street2 || '';
this.delivery_city = fields.city || ''; this.delivery_city = fields.city || '';
this.delivery_state = fields.state || '';
this.delivery_zip = fields.zip || ''; this.delivery_zip = fields.zip || '';
this.delivery_country = fields.country || '';
this.delivery_phone = fields.phone || ''; this.delivery_phone = fields.phone || '';
this.delivery_notes = fields.notes || ''; this.delivery_notes = fields.notes || '';
}, },
@ -94,10 +109,14 @@ patch(Order.prototype, {
result.fulfilment_type = this.fulfilment_type; result.fulfilment_type = this.fulfilment_type;
result.fulfilment_type_label = FULFILMENT_LABELS[this.fulfilment_type] || this.fulfilment_type; result.fulfilment_type_label = FULFILMENT_LABELS[this.fulfilment_type] || this.fulfilment_type;
result.delivery_street = this.delivery_street; result.delivery_street = this.delivery_street;
result.delivery_street2 = this.delivery_street2;
result.delivery_city = this.delivery_city; result.delivery_city = this.delivery_city;
result.delivery_state = this.delivery_state;
result.delivery_zip = this.delivery_zip; result.delivery_zip = this.delivery_zip;
result.delivery_country = this.delivery_country;
result.delivery_phone = this.delivery_phone; result.delivery_phone = this.delivery_phone;
result.delivery_notes = this.delivery_notes; result.delivery_notes = this.delivery_notes;
result.uber_delivery_fee = this.uber_delivery_fee;
result.whatsapp_number = this.whatsapp_number; result.whatsapp_number = this.whatsapp_number;
result.social_ref = this.social_ref; result.social_ref = this.social_ref;
result.telephone_number = this.telephone_number; result.telephone_number = this.telephone_number;

View File

@ -101,36 +101,113 @@
</div> </div>
<!-- Manual fields (hidden by default) --> <!-- Manual fields (hidden by default) -->
<div t-if="state.showDetails" class="manual-address-fields mt-2 p-2 border-top bg-white rounded"> <div t-if="state.showDetails" class="manual-address-fields mt-2 p-2 border-top bg-white rounded shadow-sm">
<input type="text" class="form-control form-control-sm mb-1" <div class="mb-1">
placeholder="Street" <label class="form-label mb-0 text-muted" style="font-size: 10px;">Street and Number</label>
<input type="text" class="form-control form-control-sm"
placeholder="123 Main St"
t-att-value="currentOrder.delivery_street" t-att-value="currentOrder.delivery_street"
t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/> t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/>
<div class="d-flex gap-1 mb-1"> </div>
<div class="mb-1">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Apartment, suite, etc.</label>
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="City" placeholder="Apt 4B, Unit 2 (optional)"
t-att-value="currentOrder.delivery_street2"
t-on-change="(ev) => this.onDeliveryFieldChange('street2', ev)"/>
</div>
<div class="d-flex gap-1 mb-1">
<div class="flex-grow-1">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">City</label>
<input type="text" class="form-control form-control-sm"
placeholder="Toronto"
t-att-value="currentOrder.delivery_city" t-att-value="currentOrder.delivery_city"
t-on-change="(ev) => this.onDeliveryFieldChange('city', ev)"/> t-on-change="(ev) => this.onDeliveryFieldChange('city', ev)"/>
</div>
<div style="max-width: 100px;">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Zip Code</label>
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="Zip" style="max-width:90px" placeholder="M1B 2K4"
t-att-value="currentOrder.delivery_zip" t-att-value="currentOrder.delivery_zip"
t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/> t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/>
</div> </div>
<input type="tel" class="form-control form-control-sm mb-1" </div>
placeholder="Phone" <div class="d-flex gap-1 mb-1">
<div class="flex-grow-1">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Country</label>
<input type="text" class="form-control form-control-sm"
placeholder="Canada"
t-att-value="currentOrder.delivery_country || 'Canada'"
t-on-change="(ev) => this.onDeliveryFieldChange('country', ev)"/>
</div>
<div class="flex-grow-1">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">State / Province</label>
<input type="text" class="form-control form-control-sm"
placeholder="Ontario"
t-att-value="currentOrder.delivery_state || 'Ontario'"
t-on-change="(ev) => this.onDeliveryFieldChange('state', ev)"/>
</div>
</div>
<div class="mb-1">
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Phone</label>
<input type="tel" class="form-control form-control-sm"
placeholder="647-555-0123"
t-att-value="currentOrder.delivery_phone" t-att-value="currentOrder.delivery_phone"
t-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/> t-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/>
<textarea class="form-control form-control-sm" rows="2" </div>
placeholder="Delivery notes (gate code, floor...)" <div>
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Delivery Notes</label>
<textarea class="form-control form-control-sm" rows="1"
placeholder="Gate code, buzzer, special instructions..."
t-att-value="currentOrder.delivery_notes" t-att-value="currentOrder.delivery_notes"
t-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/> t-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/>
</div> </div>
</div>
<!-- Quick Info when hidden --> <!-- Quick Info when hidden -->
<div t-if="!state.showDetails &amp;&amp; currentOrder.delivery_street" class="small mt-1 text-muted fst-italic"> <div t-if="!state.showDetails &amp;&amp; currentOrder.delivery_street" class="small mt-1 text-muted fst-italic">
<i class="fa fa-info-circle"/> <i class="fa fa-info-circle"/>
<t t-esc="currentOrder.delivery_street"/>, <t t-esc="currentOrder.delivery_street"/>,
<t t-esc="currentOrder.delivery_city"/> <t t-esc="currentOrder.delivery_city"/>
<t t-if="currentOrder.delivery_state">, <t t-esc="currentOrder.delivery_state"/></t>
</div>
<!-- Uber Quote & Distance Fee Section -->
<div t-if="state.quote" class="uber-pos-quote mt-2 p-2 rounded border bg-light shadow-sm">
<!-- Loading State -->
<div t-if="state.quote.loading" class="text-primary small d-flex align-items-center gap-2">
<i class="fa fa-spinner fa-spin"/>
<span>Checking Uber delivery availability &amp; fee...</span>
</div>
<!-- Success State: Available -->
<div t-elif="state.quote.success" class="d-flex flex-column gap-1">
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-success text-white px-2 py-1" style="font-size: 11px;">
<i class="fa fa-check-circle me-1"/> Uber Available
</span>
<span class="fw-bold text-success" style="font-size: 14px;">
<t t-esc="formatMoney(state.quote.fee)"/>
</span>
</div>
<div class="d-flex justify-content-between text-muted mt-1" style="font-size: 11px;">
<span><i class="fa fa-map-marker me-1 text-danger"/> <t t-esc="state.quote.distance_km"/> km</span>
<span t-if="state.quote.eta"><i class="fa fa-clock-o me-1 text-primary"/> ETA: <t t-esc="state.quote.eta"/></span>
</div>
<div class="text-success fst-italic mt-1" style="font-size: 10px;">
✓ Delivery fee added to bill
</div>
</div>
<!-- Error State: Outside Radius -->
<div t-elif="!state.quote.success" class="alert alert-warning mb-0 p-2 border border-warning" style="font-size: 11px;">
<div class="fw-bold text-danger mb-1">
<i class="fa fa-exclamation-triangle me-1"/> Outside Delivery Area
</div>
<div class="text-muted">
<t t-esc="state.quote.error"/>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -336,12 +336,35 @@
}); });
}; };
function setButtonState(enabled) {
const btns = document.querySelectorAll('button[type="submit"], .btn-primary, .a-submit, .s_website_form_send');
btns.forEach(b => {
if (enabled) {
b.removeAttribute('disabled');
b.classList.remove('disabled');
b.style.pointerEvents = 'auto';
b.style.opacity = '1';
} else {
b.setAttribute('disabled', 'disabled');
b.classList.add('disabled');
b.style.pointerEvents = 'none';
b.style.opacity = '0.5';
}
});
}
function checkUber() { function checkUber() {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
if (hiddenType.value !== 'delivery') return; if (hiddenType.value !== 'delivery') {
const street = document.querySelector('input[name="street"]')?.value; setButtonState(true);
const zip = document.querySelector('input[name="zip"]')?.value; return;
}
const street = document.querySelector('input[name="street"]')?.value?.trim();
const zip = document.querySelector('input[name="zip"]')?.value?.trim();
const city = document.querySelector('input[name="city"]')?.value?.trim();
const countrySelect = document.querySelector('select[name="country_id"]');
const countryText = (countrySelect &amp;&amp; countrySelect.selectedIndex &gt;= 0) ? countrySelect.options[countrySelect.selectedIndex].text.trim() : '';
if (!street || !zip) { if (!street || !zip) {
if (msgDiv) msgDiv.style.display = 'none'; if (msgDiv) msgDiv.style.display = 'none';
@ -351,22 +374,23 @@
if (msgDiv) { if (msgDiv) {
msgDiv.className = 'alert alert-info my-3'; msgDiv.className = 'alert alert-info my-3';
msgDiv.style.display = ''; msgDiv.style.display = '';
msgDiv.innerText = "Checking Uber coverage..."; msgDiv.innerText = "Checking delivery coverage for your location...";
} }
fetch('/shop/uber/quote', { fetch('/shop/uber/quote', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ params: { address_data: { body: JSON.stringify({ params: { address_data: {
street: street, street: street,
street2: document.querySelector('input[name="street2"]')?.value, street2: document.querySelector('input[name="street2"]')?.value || '',
city: document.querySelector('input[name="city"]')?.value, city: city || '',
zip: zip, zip: zip,
country: document.querySelector('select[name="country_id"] option:checked')?.text || 'Canada', country: countryText || 'Canada',
state: document.querySelector('select[name="state_id"] option:checked')?.text country_id: countrySelect?.value || '',
state: document.querySelector('select[name="state_id"] option:checked')?.text || ''
} } }) } } })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
msgDiv.classList.remove('fadeIn'); msgDiv.classList.remove('fadeIn');
void msgDiv.offsetWidth; // Trigger reflow for animation void msgDiv.offsetWidth;
msgDiv.classList.add('fadeIn'); msgDiv.classList.add('fadeIn');
if (data.result &amp;&amp; data.result.success) { if (data.result &amp;&amp; data.result.success) {
@ -379,31 +403,33 @@
Uber Delivery Fee: $${data.result.fee} (Distance Based) Uber Delivery Fee: $${data.result.fee} (Distance Based)
</div> </div>
</div>`; </div>`;
if (submitBtn) submitBtn.disabled = false; setButtonState(true);
} else { } else {
msgDiv.className = 'alert alert-danger my-3 animated fadeIn'; msgDiv.className = 'alert alert-danger my-3 animated fadeIn';
msgDiv.style.display = ''; msgDiv.style.display = '';
msgDiv.innerHTML = `<div class="d-flex align-items-center"> msgDiv.innerHTML = `<div class="d-flex align-items-center">
<i class="fa fa-exclamation-triangle me-3" style="font-size: 24px;"></i> <i class="fa fa-exclamation-triangle me-3" style="font-size: 24px;"></i>
<div> <div>
<strong>Uber Direct: Invalid Operation</strong><br/> <strong>Delivery Unavailable for this Address</strong><br/>
${data.result?.error || "This specific address is outside the Uber delivery radius."} ${data.result?.error || "This address is outside our delivery radius. Please switch to Store Pickup or enter a valid local delivery address."}
</div> </div>
</div>`; </div>`;
if (submitBtn) submitBtn.disabled = true; setButtonState(false);
} }
}).catch(err => { }).catch(err => {
console.error("Uber API Error:", err); console.error("Uber API Error:", err);
msgDiv.className = 'alert alert-warning my-3'; msgDiv.className = 'alert alert-warning my-3';
msgDiv.innerText = "Error connecting to Uber service."; msgDiv.innerText = "Error connecting to delivery service.";
setButtonState(false);
}); });
}, 500); // 500ms debounce }, 300);
} }
// Trigger on any address field change // Trigger on any address field change
const addressFields = 'input[name="street"], input[name="street2"], input[name="city"], input[name="zip"], select[name="country_id"], select[name="state_id"]'; const addressFields = 'input[name="street"], input[name="street2"], input[name="city"], input[name="zip"], select[name="country_id"], select[name="state_id"]';
const triggers = document.querySelectorAll(addressFields); const triggers = document.querySelectorAll(addressFields);
triggers.forEach(t => { triggers.forEach(t => {
t.addEventListener('input', checkUber);
t.addEventListener('blur', checkUber); t.addEventListener('blur', checkUber);
t.addEventListener('change', checkUber); t.addEventListener('change', checkUber);
}); });

View File

@ -16,6 +16,7 @@
'data/uber_cron_data.xml', 'data/uber_cron_data.xml',
'views/uber_config_views.xml', 'views/uber_config_views.xml',
'views/pos_order_views.xml', 'views/pos_order_views.xml',
'views/sale_order_views.xml',
], ],
'assets': { 'assets': {
'point_of_sale._assets_pos': [ 'point_of_sale._assets_pos': [

View File

@ -1 +1,2 @@
from . import main from . import main
from . import tracking

View File

@ -285,54 +285,66 @@ class UberDeliveryController(http.Controller):
return {'success': False, 'error': 'Uber not configured'} return {'success': False, 'error': 'Uber not configured'}
company = request.website.company_id company = request.website.company_id
company_country_code = company.country_id.code.upper() if company.country_id else "CA"
# Build STRUCTURED pickup address (Object) mapping POS exactly company_country_name = company.country_id.name if company.country_id else "Canada"
pickup_address = {
"street_address": [company.street or "Restaurant Location"],
"city": company.city or "",
"state": company.state_id.code if company.state_id else "",
"zip_code": company.zip or "",
"country": company.country_id.code if company.country_id else "CA"
}
# User entered address fields # User entered address fields
street = (address_data.get('street') or '').strip() street = (address_data.get('street') or '').strip()
street2 = (address_data.get('street2') or '').strip() street2 = (address_data.get('street2') or '').strip()
full_street = f"{street}, {street2}" if street2 else street full_street = f"{street}, {street2}" if street2 else street
country_input = (address_data.get('country') or '').strip()
if country_input:
# Validate if entered country matches restaurant country
country_record = request.env['res.country'].sudo().search([
'|', ('name', '=ilike', country_input), ('code', '=ilike', country_input)
], limit=1)
if country_record and country_record.code.upper() != company_country_code:
return {
'success': False,
'error': f"Delivery is not available in {country_record.name}. We only deliver locally within {company_country_name} ({company.city or ''}). Please select Store Pickup."
}
elif not country_record and country_input.upper() != company_country_code and country_input.lower() != company_country_name.lower():
return {
'success': False,
'error': f"Delivery is not available in {country_input}. We only deliver within {company_country_name}. Please select Store Pickup."
}
state_input = (address_data.get('state') or address_data.get('province') or '').split('(')[0].strip() state_input = (address_data.get('state') or address_data.get('province') or '').split('(')[0].strip()
state_code = "ON" state_code = company.state_id.code if company.state_id else "ON"
if state_input: if state_input:
state_record = request.env['res.country.state'].sudo().search([ state_record = request.env['res.country.state'].sudo().search([
('country_id.code', '=', company.country_id.code or 'CA'), ('country_id.code', '=', company_country_code),
'|', ('name', '=ilike', state_input), ('code', '=ilike', state_input) '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
], limit=1) ], limit=1)
if state_record: if state_record:
state_code = state_record.code state_code = state_record.code
elif len(state_input) == 2: elif len(state_input) == 2:
state_code = state_input.upper() 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() zip_val = (address_data.get('zip') or address_data.get('postal_code') or '').strip()
city_val = (address_data.get('city') or company.city or '').strip() city_val = (address_data.get('city') or '').strip()
country_val = (address_data.get('country') or company.country_id.code or 'CA').strip() country_val = company_country_code
# Build STRUCTURED pickup address (Object)
pickup_address = {
"street_address": [company.street or "Restaurant Location"],
"city": company.city or "",
"state": company.state_id.code if company.state_id else "",
"zip_code": company.zip or "",
"country": company_country_code
}
# Build STRUCTURED dropoff address (Object) # Build STRUCTURED dropoff address (Object)
dropoff_address = { dropoff_address = {
"street_address": [full_street] if full_street else [company.street or "Delivery Address"], "street_address": [full_street] if full_street else [company.street or "Delivery Address"],
"city": city_val, "city": city_val or company.city or "",
"state": state_code, "state": state_code,
"zip_code": zip_val, "zip_code": zip_val or company.zip or "",
"country": country_val "country": country_val
} }
# For logging, still create strings # POS sends these as JSON-encoded 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)
# 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))
if result.get('success'): if result.get('success'):
@ -346,3 +358,81 @@ class UberDeliveryController(http.Controller):
_logger.warning("Uber Quote Failed: %s", result.get('error')) _logger.warning("Uber Quote Failed: %s", result.get('error'))
return result return result
@http.route('/pos/uber/quote', type='json', auth='user')
def pos_uber_quote(self, address_data, **post):
"""Calculate Uber delivery quote and check address coverage for POS orders"""
config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
return {'success': False, 'error': 'Uber Integration is not configured in Settings.'}
company = request.env.company
company_country_code = company.country_id.code.upper() if company.country_id else "CA"
company_country_name = company.country_id.name if company.country_id else "Canada"
street = (address_data.get('street') or '').strip()
street2 = (address_data.get('street2') or '').strip()
full_street = f"{street}, {street2}" if street2 else street
city_val = (address_data.get('city') or company.city or 'Toronto').strip()
zip_val = (address_data.get('zip') or address_data.get('zip_code') or address_data.get('postal_code') or '').strip()
state_code = company.state_id.code if company.state_id else "ON"
country_input = (address_data.get('country') or '').strip()
if country_input:
country_record = request.env['res.country'].sudo().search([
'|', ('name', '=ilike', country_input), ('code', '=ilike', country_input)
], limit=1)
if country_record and country_record.code.upper() != company_country_code:
return {
'success': False,
'error': f"Delivery is not available in {country_record.name}. We only deliver locally within {company_country_name} ({company.city or ''}). Please select Pickup."
}
elif not country_record and country_input.upper() != company_country_code and country_input.lower() != company_country_name.lower():
return {
'success': False,
'error': f"Delivery is not available in {country_input}. We only deliver within {company_country_name}. Please select Pickup."
}
state_input = (address_data.get('state') or address_data.get('province') or '').split('(')[0].strip()
state_code = company.state_id.code if company.state_id else "ON"
if state_input:
state_record = request.env['res.country.state'].sudo().search([
('country_id.code', '=', company_country_code),
'|', ('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()
pickup_address = {
"street_address": [company.street or "Restaurant Location"],
"city": company.city or "Toronto",
"state": company.state_id.code if company.state_id else "ON",
"zip_code": company.zip or "",
"country": company_country_code
}
dropoff_address = {
"street_address": [full_street] if full_street else [city_val],
"city": city_val,
"state": state_code,
"zip_code": zip_val,
"country": company_country_code
}
result = config.get_uber_quote(json.dumps(pickup_address), json.dumps(dropoff_address))
if result.get('success'):
return {
'success': True,
'fee_amount': result.get('fee_amount', 4.99),
'distance_km': result.get('distance_km', 3.5),
'eta': result.get('estimated_arrival'),
'currency': result.get('currency', 'CAD'),
'delivery_product_id': config.delivery_product_id.id if config.delivery_product_id else False,
}
else:
return {
'success': False,
'error': result.get('error', 'Address is outside our delivery coverage zone.'),
}

View File

@ -0,0 +1,445 @@
# -*- coding: utf-8 -*-
"""
Uber Delivery Tracking Page standalone HTML, no API key required.
Uses OpenStreetMap (free, no key) + Leaflet.js for live route map.
"""
from odoo import http
from odoo.http import request, Response
import urllib.parse
import logging
_logger = logging.getLogger(__name__)
class UberTrackingController(http.Controller):
@http.route([
'/uber/track/sale/<int:order_id>',
'/uber/track/pos/<int:order_id>',
], type='http', auth='user', website=False)
def track_delivery(self, order_id, **kwargs):
path = request.httprequest.path
# ── Load order ─────────────────────────────────────────────
if '/sale/' in path:
order = request.env['sale.order'].sudo().browse(order_id)
model_type = 'sale'
partner = order.partner_shipping_id or order.partner_id
order_ref = order.name or f'SO{order_id}'
customer_name = partner.name if partner else 'Customer'
customer_phone = (partner.phone or partner.mobile or '') if partner else ''
dropoff_street = (partner.street or '') if partner else ''
dropoff_city = (partner.city or '') if partner else ''
dropoff_state = (partner.state_id.name if partner and partner.state_id else '')
dropoff_zip = (partner.zip or '') if partner else ''
dropoff_country = (partner.country_id.name if partner and partner.country_id else '')
uber_status = order.uber_status or ''
uber_tracking_url = order.uber_tracking_url or ''
uber_delivery_id = order.uber_delivery_id or ''
uber_eta = str(order.uber_eta)[:16] if order.uber_eta else ''
else:
order = request.env['pos.order'].sudo().browse(order_id)
model_type = 'pos'
partner = order.partner_id
order_ref = order.pos_reference or order.name or f'PO{order_id}'
customer_name = partner.name if partner else 'Customer'
customer_phone = (partner.phone or partner.mobile or '') if partner else ''
dropoff_street = getattr(order, 'delivery_street', '') or ((partner.street or '') if partner else '')
dropoff_city = getattr(order, 'delivery_city', '') or ((partner.city or '') if partner else '')
dropoff_state = (partner.state_id.name if partner and partner.state_id else '') if partner else ''
dropoff_zip = getattr(order, 'delivery_zip', '') or ((partner.zip or '') if partner else '')
dropoff_country = (partner.country_id.name if partner and partner.country_id else '') if partner else ''
uber_status = order.uber_status or ''
uber_tracking_url = order.uber_tracking_url or ''
uber_delivery_id = order.uber_delivery_id or ''
uber_eta = str(order.uber_eta)[:16] if getattr(order, 'uber_eta', False) else ''
# ── Restaurant pickup ───────────────────────────────────────
company = request.env.company
pickup_name = company.name or 'Restaurant'
pickup_street = company.street or ''
pickup_city = company.city or ''
pickup_state = company.state_id.name if company.state_id else ''
pickup_zip = company.zip or ''
pickup_country = company.country_id.name if company.country_id else ''
pickup_phone = company.phone or ''
def _addr(*parts):
return ', '.join(p for p in parts if p)
pickup_addr = _addr(pickup_street, pickup_city, pickup_state, pickup_zip, pickup_country)
dropoff_addr = _addr(dropoff_street, dropoff_city, dropoff_state, dropoff_zip, dropoff_country)
# Google Maps directions URL (opens in browser, no key needed)
gmaps_url = (
'https://www.google.com/maps/dir/?api=1'
f'&origin={urllib.parse.quote(pickup_addr)}'
f'&destination={urllib.parse.quote(dropoff_addr)}'
'&travelmode=driving'
)
# OpenStreetMap static route embed (no API key)
osm_embed = (
'https://www.openstreetmap.org/export/embed.html'
f'?bbox={self._bbox(pickup_addr, dropoff_addr)}'
'&layer=mapnik'
'&marker=yes'
)
# Status info
status_map = {
'pending': ('#f59e0b', '⏳ Waiting for Driver', '⚠️'),
'pickup': ('#3b82f6', '🚗 Driver at Restaurant', ''),
'delivering': ('#06b6d4', '🛵 On the Way to You', '🔵'),
'delivered': ('#10b981', '✅ Order Delivered!', ''),
'cancelled': ('#ef4444', '❌ Delivery Cancelled', ''),
}
st_color, st_label, st_icon = status_map.get(uber_status, ('#64748b', '❓ Unknown', ''))
back_url = f'/odoo/{model_type}-orders/{order_id}' if model_type == 'sale' else f'/odoo/point-of-sale/orders/{order_id}'
html = self._render_page(
order_ref=order_ref, model_type=model_type, order_id=order_id,
pickup_name=pickup_name, pickup_street=pickup_street,
pickup_city=pickup_city, pickup_state=pickup_state,
pickup_zip=pickup_zip, pickup_country=pickup_country,
pickup_phone=pickup_phone, pickup_addr=pickup_addr,
customer_name=customer_name, customer_phone=customer_phone,
dropoff_street=dropoff_street, dropoff_city=dropoff_city,
dropoff_state=dropoff_state, dropoff_zip=dropoff_zip,
dropoff_country=dropoff_country, dropoff_addr=dropoff_addr,
uber_status=uber_status, st_color=st_color, st_label=st_label,
uber_delivery_id=uber_delivery_id, uber_eta=uber_eta,
uber_tracking_url=uber_tracking_url, gmaps_url=gmaps_url,
)
return Response(html, content_type='text/html; charset=utf-8')
def _bbox(self, addr1, addr2):
"""Placeholder bbox — Leaflet geocodes addresses on client side."""
return '-180,-90,180,90'
def _render_page(self, **v):
# Safe helper
def esc(s):
return str(s or '').replace('&', '&amp;').replace('<', '&lt;').replace('"', '&quot;')
show_uber_btn = v['uber_tracking_url'] and 'sandbox' not in v['uber_tracking_url']
# Pre-build strings that contain backslashes (not allowed inside f-string expressions)
pickup_icon_html = '<div style="background:#06b6d4;width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:3px solid #fff;">&#127978;</div>'
dropoff_icon_html = '<div style="background:#f59e0b;width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:3px solid #fff;">&#127968;</div>'
eta_chip = ('<div class="info-chip"><div class="lbl">&#9201; Estimated Delivery</div><div class="val">' + esc(v['uber_eta']) + '</div></div>') if v['uber_eta'] else ''
id_chip = ('<div class="info-chip"><div class="lbl">&#128278; Uber Delivery ID</div><div class="val">' + esc(v['uber_delivery_id']) + '</div></div>') if v['uber_delivery_id'] else ''
pickup_phone_html = ('<div class="card-phone">&#128222; ' + esc(v['pickup_phone']) + '</div>') if v['pickup_phone'] else ''
dropoff_phone_html = ('<div class="card-phone">&#128222; ' + esc(v['customer_phone']) + '</div>') if v['customer_phone'] else ''
pickup_br = '<br/>' if v['pickup_street'] else ''
dropoff_br = '<br/>' if v['dropoff_street'] else ''
pickup_state_part = (', ' + esc(v['pickup_state'])) if v['pickup_state'] else ''
pickup_zip_part = (' &ndash; ' + esc(v['pickup_zip'])) if v['pickup_zip'] else ''
pickup_country_part = ('<br/>' + esc(v['pickup_country'])) if v['pickup_country'] else ''
dropoff_state_part = (', ' + esc(v['dropoff_state'])) if v['dropoff_state'] else ''
dropoff_zip_part = (' &ndash; ' + esc(v['dropoff_zip'])) if v['dropoff_zip'] else ''
dropoff_country_part = ('<br/>' + esc(v['dropoff_country'])) if v['dropoff_country'] else ''
uber_fab = ('<a href="' + esc(v['uber_tracking_url']) + '" target="_blank" class="fab fab-uber">&#9889; Uber Live</a>') if show_uber_btn else ''
uber_btn = ('<a href="' + esc(v['uber_tracking_url']) + '" target="_blank" class="btn btn-dark">&#9889; Live Track on Uber</a>') if show_uber_btn else ''
st_color = esc(v['st_color'])
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Uber Delivery Tracking {esc(v["order_ref"])}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f4f8;min-height:100vh}}
/* Header */
.hdr{{background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);
color:#fff;padding:14px 20px;display:flex;align-items:center;
gap:14px;box-shadow:0 4px 16px rgba(0,0,0,.3)}}
.hdr-logo{{background:#fff;color:#000;font-weight:900;font-size:22px;
letter-spacing:-1px;border-radius:8px;padding:4px 12px}}
.hdr-title{{font-size:13px;opacity:.7;text-transform:uppercase;letter-spacing:.8px}}
.hdr-ref{{margin-left:auto;background:rgba(255,255,255,.15);
border-radius:20px;padding:5px 14px;font-size:13px;font-weight:700}}
/* Status banner */
.status-bar{{margin:14px 16px 0;border-radius:14px;padding:14px 18px;
display:flex;align-items:center;gap:10px;
border:2px solid {st_color}33;
background:{st_color}18;
color:{st_color};font-weight:700;font-size:15px}}
.dot{{width:10px;height:10px;border-radius:50%;background:{st_color};
animation:pulse 1.5s infinite;flex-shrink:0}}
@keyframes pulse{{0%,100%{{opacity:1;transform:scale(1)}}50%{{opacity:.5;transform:scale(1.4)}}}}
.status-bar .rhs{{margin-left:auto;font-size:11px;font-weight:400;opacity:.7}}
/* Info cards */
.cards{{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:12px 16px}}
@media(max-width:580px){{.cards{{grid-template-columns:1fr}}}}
.card{{background:#fff;border-radius:16px;padding:16px 18px;
box-shadow:0 2px 10px rgba(0,0,0,.07)}}
.card.pickup{{border-top:4px solid #06b6d4}}
.card.dropoff{{border-top:4px solid #f59e0b}}
.card-badge{{display:inline-block;font-size:10px;font-weight:700;
letter-spacing:.8px;text-transform:uppercase;
border-radius:20px;padding:3px 10px;margin-bottom:8px}}
.badge-pickup{{background:#cffafe;color:#0e7490}}
.badge-dropoff{{background:#fef3c7;color:#92400e}}
.card-name{{font-size:15px;font-weight:700;color:#0f172a;margin-bottom:4px}}
.card-addr{{font-size:13px;color:#475569;line-height:1.6}}
.card-phone{{font-size:12px;color:#64748b;margin-top:6px}}
/* Map */
.map-wrap{{margin:12px 16px;border-radius:16px;overflow:hidden;
box-shadow:0 4px 20px rgba(0,0,0,.14);position:relative}}
#map{{width:100%;height:400px}}
.map-fab{{position:absolute;top:10px;right:10px;z-index:999;
display:flex;flex-direction:column;gap:6px}}
.fab{{background:#fff;border:none;border-radius:10px;
padding:8px 12px;font-size:12px;font-weight:700;
box-shadow:0 2px 8px rgba(0,0,0,.18);cursor:pointer;
text-decoration:none;color:#0f172a;display:flex;
align-items:center;gap:6px;white-space:nowrap}}
.fab:hover{{box-shadow:0 4px 14px rgba(0,0,0,.22);transform:translateY(-1px)}}
.fab-uber{{background:#000;color:#fff}}
/* ETA / ID */
.info-row{{display:flex;gap:12px;margin:0 16px 12px;flex-wrap:wrap}}
.info-chip{{background:#fff;border-radius:12px;padding:10px 16px;
flex:1;min-width:140px;box-shadow:0 1px 6px rgba(0,0,0,.07)}}
.info-chip .lbl{{font-size:10px;font-weight:700;letter-spacing:.7px;
text-transform:uppercase;color:#64748b;margin-bottom:3px}}
.info-chip .val{{font-size:14px;font-weight:700;color:#0f172a;word-break:break-all}}
/* Action bar */
.actions{{display:flex;gap:10px;margin:0 16px 20px;flex-wrap:wrap}}
.btn{{flex:1;min-width:130px;padding:13px 18px;border-radius:14px;
border:none;cursor:pointer;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;gap:8px;
text-decoration:none;transition:transform .15s,box-shadow .15s}}
.btn:hover{{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.15)}}
.btn-gmaps{{background:linear-gradient(135deg,#4285f4,#0d47a1);color:#fff}}
.btn-dark{{background:#111;color:#fff}}
.btn-back{{background:#f1f5f9;color:#0f172a;border:2px solid #e2e8f0}}
.refresh-note{{text-align:center;font-size:11px;color:#94a3b8;margin-bottom:16px}}
</style>
</head>
<body>
<!-- HEADER -->
<div class="hdr">
<div class="hdr-logo">Uber</div>
<div>
<div class="hdr-title">Delivery Tracking</div>
<div style="font-size:16px;font-weight:700;">Live Order Status</div>
</div>
<div class="hdr-ref"># {esc(v["order_ref"])}</div>
</div>
<!-- STATUS -->
<div class="status-bar" id="status_bar">
<div class="dot" id="status_dot"></div>
<span id="status_label">{esc(v["st_label"])}</span>
<span class="rhs">🔄 Auto-refreshes every 30s</span>
</div>
<!-- ETA + DELIVERY ID -->
<div class="info-row">
{eta_chip}
{id_chip}
</div>
<!-- LOCATION CARDS -->
<div class="cards">
<div class="card pickup">
<div class="card-badge badge-pickup">📍 Pickup Location</div>
<div class="card-name">{esc(v["pickup_name"])}</div>
<div class="card-addr">
{esc(v['pickup_street'])}{pickup_br}
{esc(v['pickup_city'])}{pickup_state_part}{pickup_zip_part}
{pickup_country_part}
</div>
{pickup_phone_html}
</div>
<div class="card dropoff">
<div class="card-badge badge-dropoff">&#127919; Delivering To</div>
<div class="card-name">{esc(v['customer_name'])}</div>
<div class="card-addr">
{esc(v['dropoff_street'])}{dropoff_br}
{esc(v['dropoff_city'])}{dropoff_state_part}{dropoff_zip_part}
{dropoff_country_part}
</div>
{dropoff_phone_html}
</div>
</div>
<!-- MAP -->
<div class="map-wrap">
<div id="map"></div>
<div class="map-fab">
<a href="{esc(v["gmaps_url"])}" target="_blank" class="fab">🗺 Google Maps</a>
{uber_fab}
</div>
</div>
<!-- ACTION BUTTONS -->
<div class="actions">
<a href="{esc(v['gmaps_url'])}" target="_blank" class="btn btn-gmaps">&#128506; Google Maps Directions</a>
{uber_btn}
<a href="javascript:history.back()" class="btn btn-back">&larr; Back to Order</a>
</div>
<div class="refresh-note">Status auto-refreshes every 30 seconds</div>
<!-- Leaflet + routing -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function(){{
// Geocode via Nominatim (OSM free)
var pickupAddr = "{esc(v["pickup_addr"])}";
var dropoffAddr = "{esc(v["dropoff_addr"])}";
var orderId = {v["order_id"]};
var modelType = "{esc(v["model_type"])}";
var map = L.map("map", {{zoomControl:true}}).setView([20, 0], 2);
L.tileLayer("https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png", {{
attribution: "© OpenStreetMap contributors",
maxZoom: 19
}}).addTo(map);
// Custom icons
var pickupIcon = L.divIcon({{
html: '{pickup_icon_html}',
iconSize:[36,36], iconAnchor:[18,18], popupAnchor:[0,-18], className:''
}});
var dropoffIcon = L.divIcon({{
html: '{dropoff_icon_html}',
iconSize:[36,36], iconAnchor:[18,18], popupAnchor:[0,-18], className:''
}});
var pickupMarker = null;
var dropoffMarker = null;
var routeLine = null;
function geocode(addr, callback){{
var url = "https://nominatim.openstreetmap.org/search?format=json&q=" + encodeURIComponent(addr) + "&limit=1";
fetch(url, {{headers:{{"Accept-Language":"en", "User-Agent":"Dine360/1.0"}}}} )
.then(function(r){{return r.json();}})
.then(function(data){{
if(data && data.length > 0){{
callback(parseFloat(data[0].lat), parseFloat(data[0].lon), data[0].display_name);
}} else {{
callback(null, null, addr);
}}
}}).catch(function(){{callback(null, null, addr);}});
}}
function drawRoute(lat1, lon1, lat2, lon2){{
// OSRM free routing
var url = "https://router.project-osrm.org/route/v1/driving/"
+ lon1 + "," + lat1 + ";" + lon2 + "," + lat2
+ "?overview=simplified&geometries=geojson";
fetch(url)
.then(function(r){{return r.json();}})
.then(function(data){{
if(data.routes && data.routes.length > 0){{
if(routeLine) map.removeLayer(routeLine);
routeLine = L.geoJSON(data.routes[0].geometry, {{
style:{{color:"#3b82f6", weight:5, opacity:.85,
dashArray:null, lineCap:"round", lineJoin:"round"}}
}}).addTo(map);
}}
}}).catch(function(){{}});
}}
// Geocode pickup first, then dropoff
geocode(pickupAddr, function(plat, plon, pname){{
if(plat !== null){{
pickupMarker = L.marker([plat, plon], {{icon:pickupIcon}})
.addTo(map)
.bindPopup("<b>📍 Pickup: {esc(v["pickup_name"])}</b><br/>" + (pname||pickupAddr));
geocode(dropoffAddr, function(dlat, dlon, dname){{
if(dlat !== null){{
dropoffMarker = L.marker([dlat, dlon], {{icon:dropoffIcon}})
.addTo(map)
.bindPopup("<b>🏠 Delivery: {esc(v["customer_name"])}</b><br/>" + (dname||dropoffAddr));
// Fit map to both markers
var bounds = L.latLngBounds([[plat,plon],[dlat,dlon]]);
map.fitBounds(bounds.pad(0.2));
// Draw driving route
drawRoute(plat, plon, dlat, dlon);
}} else {{
map.setView([plat, plon], 13);
pickupMarker.openPopup();
}}
}});
}} else {{
// Try just dropoff
geocode(dropoffAddr, function(dlat, dlon, dname){{
if(dlat !== null){{
dropoffMarker = L.marker([dlat, dlon], {{icon:dropoffIcon}}).addTo(map)
.bindPopup("<b>🏠 Delivery: {esc(v["customer_name"])}</b><br/>" + (dname||dropoffAddr));
map.setView([dlat, dlon], 13);
dropoffMarker.openPopup();
}} else {{
// Default world view
map.setView([20, 80], 4);
}}
}});
}}
}});
// Auto-refresh status every 30s
var statusColors = {{
pending: ["#f59e0b", "⏳ Waiting for Driver"],
pickup: ["#3b82f6", "🚗 Driver at Restaurant"],
delivering: ["#06b6d4", "🛵 On the Way to You"],
delivered: ["#10b981", "✅ Order Delivered!"],
cancelled: ["#ef4444", "❌ Delivery Cancelled"],
}};
function refreshStatus(){{
fetch("/web/dataset/call_kw", {{
method:"POST",
headers:{{"Content-Type":"application/json"}},
body:JSON.stringify({{
jsonrpc:"2.0",method:"call",id:1,
params:{{
model: modelType + ".order",
method:"read",
args:[[orderId],["uber_status","uber_tracking_url","uber_eta"]],
kwargs:{{}}
}}
}})
}}).then(function(r){{return r.json();}}).then(function(d){{
if(!d.result || !d.result[0]) return;
var rec = d.result[0];
var status = rec.uber_status || "";
var info = statusColors[status] || ["#64748b", "❓ Unknown"];
var bar = document.getElementById("status_bar");
var dot = document.getElementById("status_dot");
var lbl = document.getElementById("status_label");
if(bar){{
bar.style.borderColor = info[0] + "33";
bar.style.background = info[0] + "18";
bar.style.color = info[0];
}}
if(dot) dot.style.background = info[0];
if(lbl) lbl.textContent = info[1];
if(["delivered","cancelled"].includes(status)) clearInterval(timer);
}}).catch(function(){{}});
}}
var timer = setInterval(refreshStatus, 30000);
}})();
</script>
</body>
</html>'''

View File

@ -489,12 +489,11 @@ class PosOrder(models.Model):
}) })
def action_view_uber_map(self): def action_view_uber_map(self):
"""Open Uber Live Tracking Link""" """Open custom Odoo delivery tracking page with map and auto-filled pickup/dropoff addresses."""
self.ensure_one() self.ensure_one()
if not self.uber_tracking_url: # Our custom page works even in sandbox mode and always shows both locations
raise UserError(_("No tracking link available yet."))
return { return {
'type': 'ir.actions.act_url', 'type': 'ir.actions.act_url',
'url': self.uber_tracking_url, 'url': f'/uber/track/pos/{self.id}',
'target': 'new', 'target': 'new',
} }

View File

@ -1,13 +1,340 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging import logging
import datetime
import requests
import json
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class SaleOrder(models.Model): class SaleOrder(models.Model):
_inherit = 'sale.order' _inherit = 'sale.order'
# --- Uber Delivery Fields ---
uber_delivery_fee = fields.Float('Uber Delivery Fee', default=0.0)
is_uber_order = fields.Boolean('Is Uber Order', default=False)
uber_order_id = fields.Char('Uber Order ID')
uber_delivery_id = fields.Char('Uber Delivery ID')
uber_tracking_url = fields.Char('Driver Tracking Link')
uber_eta = fields.Datetime('Predicted Delivery Time')
uber_request_time = fields.Datetime('Uber Request Time')
uber_alert_triggered = fields.Boolean('Driver Timeout Alert Sent', default=False)
uber_status = fields.Selection([
('pending', 'Pending Uber Pickup'),
('pickup', 'Uber Driver Picked Up'),
('delivering', 'In Transit'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
], string='Uber Delivery Status', readonly=True)
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def _format_uber_phone(self, phone):
"""Normalize phone to E.164 format (+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 drop-off address from shipping partner or order partner."""
self.ensure_one()
partner = self.partner_shipping_id or self.partner_id
company = self.company_id
street = (partner.street or '') if partner else ''
street2 = (partner.street2 or '') if partner else ''
city = (partner.city or '') if partner else (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 = (partner.zip or '') if partner else (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 = (partner.phone or partner.mobile or '') if partner else ''
name = (partner.name if partner else False) or self.name or 'Customer'
return {
'name': name,
'street': street.strip(),
'street2': street2.strip(),
'city': city.strip(),
'state_code': state_code.strip() or 'ON',
'zip_code': zip_code.strip(),
'country_code': country_code.strip() or 'CA',
'phone': phone.strip(),
}
# ---------------------------------------------------------------
# Button Actions
# ---------------------------------------------------------------
def action_request_uber_delivery(self):
"""Request Uber Direct courier for a Sale Order (website order)."""
for order in self:
if order.is_uber_order and order.uber_delivery_id and \
order.uber_status and order.uber_status not in ('cancelled',):
_logger.info('Uber already active for %s (%s)', order.name, order.uber_delivery_id)
continue
# 1. Config
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
raise UserError(_('Uber Integration is not configured. Please set it up in the Uber menu.'))
customer_id = config.customer_id
if not customer_id and config.environment != 'sandbox':
raise UserError(_('Uber Customer ID is missing in configuration.'))
# 2. Delivery Address
addr = order._get_uber_delivery_address()
if not addr['street']:
addr['street'] = addr['city'] or 'Local Delivery'
# 3. Auth
is_simulated = False
access_token = False
try:
access_token = config._get_access_token()
except Exception as e:
if config.environment == 'sandbox':
_logger.warning('Sandbox: auth failed (%s) using simulated token.', e)
is_simulated = True
access_token = 'simulated_sandbox_token'
else:
raise UserError(_('Uber Authentication Failed: %s') % str(e))
# 4. Payload
company = order.company_id
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({
'street_address': pickup_street_lines,
'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',
})
dropoff_street = [addr['street']]
if addr['street2'] and addr['street2'] not in addr['street']:
dropoff_street.append(addr['street2'])
dropoff_address = json.dumps({
'street_address': dropoff_street,
'city': addr['city'],
'state': addr['state_code'],
'zip_code': addr['zip_code'],
'country': addr['country_code'],
})
# Build item manifest from order lines
items = []
for line in order.order_line:
if line.is_delivery:
continue
items.append({
'name': line.product_id.name or 'Item',
'quantity': int(line.product_uom_qty) or 1,
'price': int(line.price_unit * 100),
'currency_code': order.currency_id.name or 'CAD',
})
if not items:
items.append({
'name': 'Food Order',
'quantity': 1,
'price': int(order.amount_total * 100),
'currency_code': order.currency_id.name or 'CAD',
})
payload = {
'pickup_name': company.name,
'pickup_address': pickup_address,
'pickup_phone_number': order._format_uber_phone(company.phone),
'dropoff_name': addr['name'],
'dropoff_address': dropoff_address,
'dropoff_phone_number': order._format_uber_phone(addr['phone']),
'manifest_items': items,
}
if config.environment == 'sandbox':
payload['test_specifications'] = {'robo_courier_specification': {'mode': 'auto'}}
# 5. Call API / Simulate
api_url = f'https://api.uber.com/v1/customers/{customer_id}/deliveries'
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
def _simulated_data():
return {
'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(),
}
try:
if is_simulated:
data = _simulated_data()
_logger.info('Simulated Uber delivery for %s: %s', order.name, data)
else:
_logger.info('Requesting Uber 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 fallback: API returned %s', response.status_code)
data = _simulated_data()
else:
response.raise_for_status()
else:
data = response.json()
_logger.info('Uber delivery created for %s: %s', order.name, data)
# 6. Parse ETA
delivery_fee = float(data.get('fee', 0)) / 100.0
raw_eta = data.get('dropoff_eta') or data.get('estimated_dropoff_time')
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)
order.write({
'uber_status': 'pending',
'is_uber_order': True,
'uber_delivery_id': data.get('id'),
'uber_request_time': fields.Datetime.now(),
'uber_delivery_fee': delivery_fee,
'uber_tracking_url': data.get('tracking_url'),
'uber_eta': dropoff_eta,
'uber_alert_triggered': False,
})
# Real-time bus notification
self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': 'pending',
})
except requests.exceptions.HTTPError as e:
_logger.error('Uber API HTTP error: %s %s', e.response.status_code, e.response.text)
if config.environment == 'sandbox':
order.write({
'uber_status': 'pending', 'is_uber_order': True,
'uber_delivery_id': f'del_sandbox_{order.id}',
'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),
})
return
try:
err = e.response.json()
raise UserError(_('Uber API Error (%s): %s') % (err.get('code', '?'), err.get('message', str(e))))
except (ValueError, AttributeError):
raise UserError(_('Uber API Error %s: %s') % (e.response.status_code, e.response.text))
except Exception as e:
if config.environment == 'sandbox':
_logger.warning('Sandbox: exception (%s) simulating dispatch.', e)
order.write({
'uber_status': 'pending', 'is_uber_order': True,
'uber_delivery_id': f'del_sandbox_{order.id}',
'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),
})
else:
raise UserError(_('Failed to request Uber delivery: %s') % str(e))
def action_view_uber_map(self):
"""Open the custom Odoo delivery tracking page with map and auto-filled addresses."""
self.ensure_one()
# Open our custom tracking page (works for both sandbox & production)
return {
'type': 'ir.actions.act_url',
'url': f'/uber/track/sale/{self.id}',
'target': 'new',
}
def action_sync_uber_status(self):
"""Manually sync delivery status from Uber API."""
for order in self:
if not order.uber_delivery_id:
continue
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
continue
try:
access_token = config._get_access_token()
customer_id = config.customer_id
url = f'https://api.uber.com/v1/customers/{customer_id}/deliveries/{order.uber_delivery_id}'
resp = requests.get(url, headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
})
if resp.status_code == 200:
data = resp.json()
status_map = {
'pending': 'pending',
'pickup': 'pickup',
'pickup_complete': 'delivering',
'dropoff': 'delivering',
'delivered': 'delivered',
'canceled': 'cancelled',
'returned': 'cancelled',
}
uber_raw = data.get('status', '')
new_status = status_map.get(uber_raw, order.uber_status)
vals = {'uber_status': new_status}
if data.get('tracking_url'):
vals['uber_tracking_url'] = data['tracking_url']
order.write(vals)
except Exception as e:
_logger.warning('Uber status sync failed for %s: %s', order.name, e)
def action_cancel_uber_delivery(self):
"""Cancel active Uber courier for this sale order."""
self.ensure_one()
if not self.uber_delivery_id:
raise UserError(_('No active Uber delivery to cancel.'))
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
raise UserError(_('Uber Integration is not configured.'))
try:
access_token = config._get_access_token()
customer_id = config.customer_id
url = f'https://api.uber.com/v1/customers/{customer_id}/deliveries/{self.uber_delivery_id}/cancel'
resp = requests.post(url, headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
})
if resp.status_code in (200, 204):
self.write({'uber_status': 'cancelled'})
elif config.environment == 'sandbox':
self.write({'uber_status': 'cancelled'})
else:
resp.raise_for_status()
except Exception as e:
if config.environment == 'sandbox':
self.write({'uber_status': 'cancelled'})
else:
raise UserError(_('Failed to cancel Uber delivery: %s') % str(e))
# ---------------------------------------------------------------
# Keep existing carrier helpers
# ---------------------------------------------------------------
def _get_available_carriers(self, **kwargs): def _get_available_carriers(self, **kwargs):
"""Force Include the Uber carrier in the available list to bypass Odoo's address/website filters""" """Force-include the Uber carrier to bypass Odoo's address/website filters."""
carriers = super()._get_available_carriers(**kwargs) carriers = super()._get_available_carriers(**kwargs)
if self.carrier_id and 'Uber' in (self.carrier_id.name or ''): if self.carrier_id and 'Uber' in (self.carrier_id.name or ''):
if self.carrier_id not in carriers: if self.carrier_id not in carriers:
@ -15,75 +342,58 @@ class SaleOrder(models.Model):
return carriers return carriers
def _add_uber_delivery_fee(self, amount): def _add_uber_delivery_fee(self, amount):
"""Add the delivery fee using Odoo's standard delivery system to satisfy checkout validation""" """Add the Uber delivery fee line to the sale order."""
_logger.info("Uber: Syncing delivery fee %s to order %s", amount, self.name) _logger.info('Uber: syncing delivery fee %s to order %s', amount, self.name)
vals = {'uber_delivery_fee': amount, 'partner_shipping_id': self.partner_id.id}
self.write(vals)
# 1. Ensure fulfillment type is set
if hasattr(self, 'fulfilment_type'):
self.write({
'fulfilment_type': 'delivery',
'partner_shipping_id': self.partner_id.id
})
else:
self.write({'partner_shipping_id': self.partner_id.id})
# 2. Find or Create the Uber Delivery carrier
Carrier = self.env['delivery.carrier'].sudo() Carrier = self.env['delivery.carrier'].sudo()
config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
carrier = Carrier.search(['|', ('name', 'ilike', 'Uber'),
# Search for any carrier linked to the Uber product or with Uber in the name ('product_id', '=', config.delivery_product_id.id if config and config.delivery_product_id else 0)], limit=1)
carrier = Carrier.search(['|', ('name', 'ilike', 'Uber'), ('product_id', '=', config.delivery_product_id.id if config.delivery_product_id else 0)], limit=1)
if not carrier and config: if not carrier and config:
# Fallback product if one isn't set in config product = config.delivery_product_id or \
product = config.delivery_product_id self.env['product.product'].sudo().search([('name', 'ilike', 'Delivery')], limit=1) or \
if not product: self.env['product.product'].sudo().search([], limit=1)
product = self.env['product.product'].sudo().search([('name', 'ilike', 'Delivery')], limit=1)
if not product:
product = self.env['product.product'].sudo().search([], limit=1) # Last resort
_logger.info("Uber: Creating new Uber Delivery carrier using product %s", product.name)
carrier = Carrier.create({ carrier = Carrier.create({
'name': 'Uber Direct Delivery', 'name': 'Uber Direct Delivery', 'delivery_type': 'fixed',
'delivery_type': 'fixed', 'product_id': product.id, 'website_published': True,
'product_id': product.id, 'fixed_price': amount, 'active': True,
'website_published': True,
'fixed_price': 0.0,
'active': True,
}) })
if carrier: if carrier:
# Force carrier to be published and global to avoid "No shipping method" error carrier.sudo().write({'website_published': True, 'active': True, 'fixed_price': amount})
carrier.sudo().write({
'website_published': True,
'active': True,
'country_ids': [(6, 0, [])],
'state_ids': [(6, 0, [])],
'zip_prefix_ids': [(6, 0, [])] if hasattr(Carrier, 'zip_prefix_ids') else False
})
# 3. Use Odoo's standard method to set delivery
_logger.info("Uber: Setting carrier %s with fee %s", carrier.name, amount)
# Lock the price on the carrier record itself temporarily (SUDO) to satisfy Odoo's check_carrier
carrier.sudo().write({'fixed_price': amount})
# Remove any existing delivery lines first to prevent accumulation
# We do this manually because our protection in online_orders blocks the automatic removal
self.order_line.filtered(lambda l: l.is_delivery).sudo().unlink() self.order_line.filtered(lambda l: l.is_delivery).sudo().unlink()
# Apply to order
self.sudo().set_delivery_line(carrier, amount) self.sudo().set_delivery_line(carrier, amount)
self.sudo().write({'carrier_id': carrier.id}) self.sudo().write({'carrier_id': carrier.id})
# Force the line name and price one more time to be sure
delivery_line = self.order_line.filtered(lambda l: l.is_delivery) delivery_line = self.order_line.filtered(lambda l: l.is_delivery)
if delivery_line: if delivery_line:
delivery_line.sudo().write({ delivery_line.sudo().write({
'price_unit': amount, 'price_unit': amount,
'name': f"Uber Direct Delivery ({self.partner_id.city or 'Local'})" 'name': f'Uber Direct Delivery ({self.partner_id.city or "Local"})',
}) })
else:
_logger.warning("Uber: No delivery carrier found to apply fee")
return True return True
def _check_carrier_quotation(self, force_carrier_id=None, **kwargs):
"""Preserve Uber delivery fee on page transitions."""
self.ensure_one()
if getattr(self, 'fulfilment_type', '') == 'delivery':
delivery_line = self.order_line.filtered(lambda l: l.is_delivery)
if not delivery_line and self.uber_delivery_fee > 0:
carrier = self.carrier_id or \
self.env['delivery.carrier'].sudo().search(
['|', ('name', 'ilike', 'Uber'), ('website_published', '=', True)], limit=1)
if carrier:
self.set_delivery_line(carrier, self.uber_delivery_fee)
return True
elif getattr(self, 'fulfilment_type', '') in ('pickup', 'dine_in', 'walk_in'):
return True
return super()._check_carrier_quotation(force_carrier_id=force_carrier_id, **kwargs)
def _remove_delivery_line(self):
"""Prevent removal of delivery line when fulfilment is delivery."""
self.ensure_one()
if getattr(self, 'fulfilment_type', '') == 'delivery':
_logger.info('Preserving Uber delivery line on order %s', self.name)
return True
return super()._remove_delivery_line()

View File

@ -149,49 +149,39 @@ class UberConfig(models.Model):
return True return True
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 directly from Uber API with real coverage and distance evaluation"""
self.ensure_one() self.ensure_one()
p_dict = {}
d_dict = {}
try:
p_dict = json.loads(pickup_address) if isinstance(pickup_address, str) else (pickup_address or {})
d_dict = json.loads(dropoff_address) if isinstance(dropoff_address, str) else (dropoff_address or {})
except Exception:
pass
p_country = str(p_dict.get('country') or '').strip().upper()
d_country = str(d_dict.get('country') or '').strip().upper()
# Strict cross-country check
if p_country and d_country and p_country != d_country:
return {
'success': False,
'error': f"Delivery is not available in {d_dict.get('country')}. Address is outside our delivery area."
}
# 1. Attempt Live / Sandbox Uber API Request
try: try:
access_token = self._get_access_token() 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 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."))
if customer_id and access_token:
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"
headers = { headers = {
'Authorization': f'Bearer {access_token}', 'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
# Ensure at least one dummy item if none provided (Uber Direct sometimes requires this)
if not items: if not items:
items = [{ items = [{"name": "Food Delivery", "quantity": 1, "size": "small"}]
"name": "Food Delivery",
"quantity": 1,
"size": "small"
}]
payload = { payload = {
"pickup_address": pickup_address, "pickup_address": pickup_address,
@ -199,69 +189,110 @@ class UberConfig(models.Model):
"manifest_items": items "manifest_items": items
} }
_logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2)) _logger.info("Uber Direct API Request: %s", json.dumps(payload))
response = requests.post(api_url, headers=headers, json=payload, timeout=10)
_logger.info("Uber Direct API Response (%s): %s", response.status_code, response.text)
try: if response.status_code in [200, 201]:
response = requests.post(api_url, headers=headers, json=payload)
_logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text)
if response.status_code not in [200, 201]:
# Log detailed error for debugging
_logger.error("Uber Quote Error: %s - %s", response.status_code, response.text)
data = {}
try:
data = response.json() data = response.json()
except Exception:
pass
# Construct descriptive error message
msg = data.get('message', 'Uber API Error')
if data.get('errors'):
details = " ".join([e.get('message', '') for e in data['errors'] if isinstance(e, dict)])
if 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 {
'success': False,
'error': msg,
'code': data.get('code', 'unknown'),
'raw_error': data
}
data = response.json()
# Standard fee is in cents
fee_cents = data.get('fee', 0) fee_cents = data.get('fee', 0)
fee_amount = round(float(fee_cents) / 100.0, 2)
return { return {
'success': True, 'success': True,
'quote_id': data.get('id'), 'quote_id': data.get('id'),
'fee_amount': float(fee_cents) / 100.0, 'fee_amount': fee_amount if fee_amount > 0 else 5.99,
'currency': data.get('currency_code', 'CAD'), 'currency': data.get('currency_code', 'CAD'),
'estimated_arrival': data.get('estimated_arrival'), 'estimated_arrival': data.get('estimated_arrival') or data.get('dropoff_eta'),
'raw': data 'raw': data
} }
else:
# Uber API returned a coverage or validation error
err_data = {}
try:
err_data = response.json()
except Exception:
pass
msg = err_data.get('message', '')
if err_data.get('errors'):
details = " ".join([e.get('message', '') for e in err_data['errors'] if isinstance(e, dict)])
if details:
msg = f"{msg} {details}"
if not msg or "unauthorized" in msg.lower():
msg = "This address is outside Uber delivery coverage area. Please choose Store Pickup."
# If this is production, strictly fail on Uber API errors
if self.environment == 'production':
return {
'success': False,
'error': msg,
'code': err_data.get('code', 'outside_radius'),
'raw_error': err_data
}
except Exception as e: except Exception as e:
_logger.exception("Uber Quote API Exception") _logger.warning("Uber API connection issue: %s", str(e))
if self.environment == 'sandbox': if self.environment == 'production':
return {
'success': False,
'error': f"Unable to reach Uber Delivery Service: {str(e)}"
}
# 2. Dynamic City & Postal Code Distance Calculation Engine (Sandbox / Testing)
# Calculates realistic distance fee based on pickup city vs dropoff city & postal code
p_city = str(p_dict.get('city') or 'Toronto').strip().lower()
d_city_raw = str(d_dict.get('city') or '').strip().lower()
p_zip = str(p_dict.get('zip_code') or 'M1B').strip().upper()
d_zip = str(d_dict.get('zip_code') or '').strip().upper()
d_street = str(d_dict.get('street_address') or '').lower()
d_state = str(d_dict.get('state') or '').lower()
d_country = str(d_dict.get('country') or '').lower()
# Combine all parts to search for city keywords
full_text = f"{d_city_raw} {d_street} {d_zip} {d_state} {d_country}".lower()
# Extract primary city token if formatted as "Toronto, Ontario, Canada"
d_city = d_city_raw.split(',')[0].strip() if d_city_raw else ''
if not d_city:
for candidate in ['toronto', 'scarborough', 'north york', 'etobicoke', 'east york', 'mississauga', 'brampton', 'markham', 'vaughan', 'oakville', 'richmond hill', 'hamilton', 'barrie', 'waterloo', 'london']:
if candidate in full_text:
d_city = candidate
break
distance_km = 3.5 # Default local
if any(c in full_text for c in ['hamilton', 'barrie', 'waterloo', 'london', 'calgary', 'vancouver', 'montreal', 'ottawa']):
return {
'success': False,
'error': f"Delivery is not available in {d_city.title() if d_city else 'this location'}. Distance exceeds maximum 30 km Uber courier radius."
}
elif 'mississauga' in full_text:
distance_km = 24.0
elif any(c in full_text for c in ['brampton', 'vaughan', 'markham']):
distance_km = 18.5
elif any(c in full_text for c in ['oakville', 'richmond hill']):
distance_km = 32.0
elif any(c in full_text for c in ['toronto', 'scarborough', 'north york', 'etobicoke', 'east york', 'downtown']) or d_zip.startswith('M') or d_city in ['', p_city, 'ontario', 'canada']:
distance_km = 4.2
else:
if d_city == p_city or not d_city:
distance_km = 4.2
else:
return {
'success': False,
'error': f"Delivery is not available in {d_city.title()}. Address is outside our delivery coverage zone."
}
calculated_fee = round(4.50 + (distance_km * 0.75), 2)
eta_minutes = int(15 + (distance_km * 1.5))
return { return {
'success': True, 'success': True,
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}", 'quote_id': f"uber_quote_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
'fee_amount': 4.99, 'fee_amount': calculated_fee,
'distance_km': distance_km,
'currency': 'CAD', 'currency': 'CAD',
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(), 'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=eta_minutes)).isoformat(),
'raw': {'simulated': True, 'exception': str(e)} 'raw': {'distance_km': distance_km, 'dynamic_calculation': True}
} }
return {'success': False, 'error': str(e)}
def _return_notification(self, message, msg_type): def _return_notification(self, message, msg_type):
return { return {

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Add Uber Delivery buttons to the Sale Order form (website/backend orders) -->
<record id="view_sale_order_form_inherit_uber" model="ir.ui.view">
<field name="name">sale.order.form.inherit.uber</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Hidden Uber fields for visibility conditions -->
<xpath expr="//header" position="inside">
<field name="is_uber_order" invisible="1"/>
<field name="uber_delivery_id" invisible="1"/>
<field name="uber_tracking_url" invisible="1"/>
<field name="uber_status" invisible="1"/>
<field name="uber_alert_triggered" invisible="1"/>
<!-- Request Uber Delivery -->
<button name="action_request_uber_delivery"
string="🛵 Request Uber Delivery"
type="object"
invisible="(uber_delivery_id != False and uber_status not in ['cancelled', False]) or state == 'cancel'"
class="oe_highlight"/>
<!-- Track Driver -->
<button name="action_view_uber_map"
string="📍 Track Driver"
type="object"
invisible="not uber_tracking_url or uber_status in ['delivered', 'cancelled']"
class="btn-primary"/>
<!-- Sync Status -->
<button name="action_sync_uber_status"
string="🔄 Sync Uber Status"
type="object"
invisible="not uber_delivery_id or uber_status in ['delivered', 'cancelled']"
class="btn-secondary"/>
<!-- Cancel Uber -->
<button name="action_cancel_uber_delivery"
string="Cancel Uber Delivery"
type="object"
invisible="not uber_delivery_id or uber_status in ['delivered', 'cancelled']"
class="btn-danger"/>
</xpath>
<!-- Alert banners below header -->
<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" role="alert"
invisible="uber_status != 'delivered'"
style="font-weight:bold;background-color:#d1e7dd;color:#0f5132;">
<i class="fa fa-check-circle me-2"/> ORDER DELIVERED SUCCESSFULLY BY UBER
</div>
</xpath>
<!-- Uber status ribbon -->
<xpath expr="//sheet" position="inside">
<widget name="web_ribbon" title="Delivered" bg_color="text-bg-success"
invisible="uber_status != 'delivered'"/>
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
invisible="uber_status != 'cancelled'"/>
</xpath>
<!-- Uber info fields after order reference -->
<xpath expr="//field[@name='name']" position="after">
<field name="uber_status" readonly="1"
invisible="not is_uber_order"
decoration-info="uber_status == 'pending'"
decoration-warning="uber_status in ['pickup', 'delivering']"
decoration-success="uber_status == 'delivered'"
decoration-danger="uber_status == 'cancelled'"
widget="badge"/>
<field name="uber_delivery_id" readonly="1" invisible="not uber_delivery_id"/>
<field name="uber_eta" readonly="1" invisible="not uber_eta" string="Est. Delivery"/>
<field name="uber_delivery_fee" readonly="1" invisible="not uber_delivery_fee"
widget="monetary" options="{&quot;currency_field&quot;: &quot;currency_id&quot;}"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,337 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="uber_tracking_page" name="Uber Delivery Tracking">
<t t-call="web.layout">
<t t-set="head_tracking">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Uber Delivery Tracking <t t-esc="order_ref"/></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
}
/* ── Top Bar ── */
.track-header {
background: linear-gradient(135deg, #06b6d4 0%, #0369a1 100%);
color: #fff;
padding: 18px 24px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 3px 12px rgba(0,0,0,0.2);
}
.track-header .uber-logo {
font-size: 26px; font-weight: 900; letter-spacing: -1px;
background: #fff;
color: #000;
border-radius: 8px;
padding: 4px 12px;
}
.track-header h1 { font-size: 16px; font-weight: 600; opacity: .9; }
.track-header .order-ref {
margin-left: auto;
background: rgba(255,255,255,0.2);
border-radius: 20px;
padding: 4px 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: .5px;
}
/* ── Status Banner ── */
.status-banner {
margin: 16px 20px 0;
border-radius: 14px;
padding: 14px 20px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
font-size: 15px;
}
.status-warning { background: #fef3c7; color: #92400e; border: 2px solid #fbbf24; }
.status-info { background: #dbeafe; color: #1e40af; border: 2px solid #93c5fd; }
.status-primary { background: #e0f2fe; color: #0369a1; border: 2px solid #38bdf8; }
.status-success { background: #d1fae5; color: #065f46; border: 2px solid #34d399; }
.status-danger { background: #fee2e2; color: #991b1b; border: 2px solid #fca5a5; }
.status-secondary{ background: #f1f5f9; color: #475569; border: 2px solid #94a3b8; }
.pulse-dot {
width: 12px; height: 12px; border-radius: 50%;
background: currentColor; flex-shrink: 0;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(1.3); }
}
/* ── Card Grid ── */
.cards-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin: 14px 20px;
}
@media(max-width: 640px){ .cards-row{ grid-template-columns: 1fr; } }
.location-card {
background: #fff;
border-radius: 16px;
padding: 18px 20px;
box-shadow: 0 2px 10px rgba(0,0,0,.07);
border-left: 5px solid #06b6d4;
position: relative;
}
.location-card.dropoff { border-left-color: #f59e0b; }
.card-icon {
width: 40px; height: 40px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px; margin-bottom: 10px;
}
.card-icon.pickup-icon { background: #cffafe; }
.card-icon.dropoff-icon { background: #fef3c7; }
.card-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; color: #64748b; margin-bottom: 4px;
}
.card-title { font-size: 15px; font-weight: 700; color: #0f172a; margin-bottom: 6px; }
.card-addr { font-size: 13px; color: #475569; line-height: 1.5; }
.card-phone { font-size: 12px; color: #64748b; margin-top: 6px; }
/* ── Map ── */
.map-section {
margin: 0 20px 14px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
position: relative;
}
.map-section iframe {
width: 100%; height: 380px; border: 0; display: block;
}
.map-overlay-btns {
position: absolute; top: 12px; right: 12px;
display: flex; flex-direction: column; gap: 8px;
}
.map-btn {
background: #fff;
border: none; border-radius: 10px;
padding: 8px 14px;
font-size: 13px; font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,.15);
cursor: pointer; text-decoration: none;
color: #0f172a; display: flex; align-items: center; gap: 6px;
transition: transform .15s, box-shadow .15s;
}
.map-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,.2); color: #0f172a; }
.map-btn.uber-btn { background: #000; color: #fff; }
/* ── Action Buttons ── */
.action-bar {
display: flex; gap: 12px; margin: 0 20px 20px; flex-wrap: wrap;
}
.btn-full {
flex: 1; min-width: 140px;
padding: 14px 20px;
border-radius: 14px;
border: none; cursor: pointer;
font-size: 14px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
gap: 8px; text-decoration: none;
transition: transform .15s, box-shadow .15s;
}
.btn-full:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.15); }
.btn-gmaps { background: linear-gradient(135deg,#4285f4,#0f52c0); color:#fff; }
.btn-uber { background: #000; color: #fff; }
.btn-back { background: #f1f5f9; color: #0f172a; border: 2px solid #e2e8f0; }
/* ── ETA Card ── */
.eta-card {
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #fff;
border-radius: 16px;
padding: 16px 20px;
margin: 0 20px 14px;
display: flex; align-items: center; gap: 14px;
}
.eta-icon { font-size: 28px; }
.eta-label { font-size: 12px; opacity: .7; margin-bottom: 2px; }
.eta-value { font-size: 17px; font-weight: 700; }
/* ── Delivery ID ── */
.delivery-id {
margin: 0 20px 14px;
background: #fff;
border-radius: 12px;
padding: 12px 18px;
font-size: 12px;
color: #64748b;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
display: flex; gap: 8px; align-items: center;
}
.delivery-id strong { color: #0f172a; }
/* ── Auto-refresh badge ── */
.refresh-badge {
text-align: center; font-size: 12px; color: #94a3b8;
margin-bottom: 20px;
}
</style>
</t>
<t t-set="body_classname">o_web_client</t>
<!-- ═══ HEADER ═══════════════════════════════════════════════════ -->
<div class="track-header">
<div class="uber-logo">Uber</div>
<div>
<div style="font-size:11px;opacity:.7;text-transform:uppercase;letter-spacing:1px;">Delivery Tracking</div>
<h1>Live Order Status</h1>
</div>
<div class="order-ref"># <t t-esc="order_ref"/></div>
</div>
<!-- ═══ STATUS BANNER ════════════════════════════════════════════ -->
<div t-attf-class="status-banner status-#{status_color}" id="status_banner">
<div class="pulse-dot" t-if="uber_status not in ['delivered','cancelled']"></div>
<span t-esc="status_label"/>
<span style="margin-left:auto;font-size:12px;font-weight:400;opacity:.7;">Auto-refreshes every 30s</span>
</div>
<!-- ═══ ETA ══════════════════════════════════════════════════════ -->
<div class="eta-card" t-if="uber_eta">
<div class="eta-icon">⏱️</div>
<div>
<div class="eta-label">Estimated Delivery</div>
<div class="eta-value" t-esc="uber_eta"/>
</div>
</div>
<!-- ═══ DELIVERY ID ══════════════════════════════════════════════ -->
<div class="delivery-id" t-if="uber_delivery_id">
🔖 <strong>Uber Delivery ID:</strong> <t t-esc="uber_delivery_id"/>
</div>
<!-- ═══ LOCATION CARDS ═══════════════════════════════════════════ -->
<div class="cards-row">
<!-- Pickup: Restaurant -->
<div class="location-card">
<div class="card-icon pickup-icon">🏪</div>
<div class="card-label">📍 Pickup Location</div>
<div class="card-title" t-esc="pickup_name"/>
<div class="card-addr">
<t t-if="pickup_street"><t t-esc="pickup_street"/><br/></t>
<t t-if="pickup_city"><t t-esc="pickup_city"/></t>
<t t-if="pickup_state">, <t t-esc="pickup_state"/></t>
<t t-if="pickup_zip"> <t t-esc="pickup_zip"/></t>
<t t-if="pickup_country"><br/><t t-esc="pickup_country"/></t>
</div>
<div class="card-phone" t-if="pickup_phone">📞 <t t-esc="pickup_phone"/></div>
</div>
<!-- Dropoff: Customer -->
<div class="location-card dropoff">
<div class="card-icon dropoff-icon">🏠</div>
<div class="card-label">🎯 Delivery To</div>
<div class="card-title" t-esc="customer_name"/>
<div class="card-addr">
<t t-if="dropoff_street"><t t-esc="dropoff_street"/><br/></t>
<t t-if="dropoff_city"><t t-esc="dropoff_city"/></t>
<t t-if="dropoff_state">, <t t-esc="dropoff_state"/></t>
<t t-if="dropoff_zip"> <t t-esc="dropoff_zip"/></t>
<t t-if="dropoff_country"><br/><t t-esc="dropoff_country"/></t>
</div>
<div class="card-phone" t-if="customer_phone">📞 <t t-esc="customer_phone"/></div>
</div>
</div>
<!-- ═══ MAP ══════════════════════════════════════════════════════ -->
<div class="map-section">
<iframe
t-att-src="maps_embed_url"
allowfullscreen="1"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
title="Delivery Route Map"/>
<!-- Map overlay buttons -->
<div class="map-overlay-btns">
<a t-att-href="maps_directions_url" target="_blank" class="map-btn">
🗺️ Open Full Map
</a>
<a t-if="uber_tracking_url and 'sandbox' not in uber_tracking_url"
t-att-href="uber_tracking_url" target="_blank" class="map-btn uber-btn">
⚡ Uber Live Track
</a>
</div>
</div>
<!-- ═══ ACTION BUTTONS ════════════════════════════════════════════ -->
<div class="action-bar">
<a t-att-href="maps_directions_url" target="_blank" class="btn-full btn-gmaps">
🗺️ Google Maps Directions
</a>
<a t-if="uber_tracking_url and 'sandbox' not in uber_tracking_url"
t-att-href="uber_tracking_url" target="_blank" class="btn-full btn-uber">
⚡ Track on Uber
</a>
<a t-att-href="'/web#id=%s&amp;model=%s.order&amp;view_type=form' % (order_id, model_type)"
class="btn-full btn-back">
← Back to Order
</a>
</div>
<!-- ═══ REFRESH BADGE ════════════════════════════════════════════ -->
<div class="refresh-badge">Status auto-refreshes every 30 seconds</div>
<!-- ═══ AUTO-REFRESH SCRIPT ══════════════════════════════════════ -->
<script>
(function(){
var orderId = <t t-esc="order_id"/>;
var modelType = "<t t-esc="model_type"/>";
var statusColors = {
pending: ["⏳ Waiting for Driver", "warning"],
pickup: ["🚗 Driver at Restaurant", "info"],
delivering: ["🛵 On the Way", "primary"],
delivered: ["✅ Delivered", "success"],
cancelled: ["❌ Cancelled", "danger"],
};
function refreshStatus(){
fetch("/web/dataset/call_kw", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({
jsonrpc: "2.0", method: "call", id: 1,
params: {
model: modelType + ".order",
method: "read",
args: [[orderId], ["uber_status","uber_tracking_url","uber_eta"]],
kwargs: {}
}
})
})
.then(r => r.json())
.then(data => {
if(!data.result || !data.result[0]) return;
var rec = data.result[0];
var status = rec.uber_status || "";
var info = statusColors[status] || ["Unknown","secondary"];
var banner = document.getElementById("status_banner");
if(banner){
banner.className = "status-banner status-" + info[1];
banner.querySelector("span").textContent = info[0];
}
if(["delivered","cancelled"].includes(status)){
clearInterval(timer);
}
})
.catch(function(){});
}
// Refresh every 30 seconds
var timer = setInterval(refreshStatus, 30000);
})();
</script>
</t>
</template>
</data>
</odoo>