implement order channels panel with Uber delivery integration and POS automation
This commit is contained in:
parent
ea4710dd40
commit
ceb9ea818e
385
CLIENT_HANDBOOK_DINE360_SHIVASAKTHI.md
Normal file
385
CLIENT_HANDBOOK_DINE360_SHIVASAKTHI.md
Normal 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`
|
||||||
@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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).")
|
||||||
|
|||||||
@ -15,51 +15,118 @@ class Dine360OnlineOrders(http.Controller):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
class Dine360WebsiteSaleOnline(WebsiteSale):
|
class Dine360WebsiteSaleOnline(WebsiteSale):
|
||||||
|
|
||||||
|
@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()
|
||||||
|
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 super(Dine360WebsiteSaleOnline, self).cart(**post)
|
||||||
|
|
||||||
|
@http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False)
|
||||||
|
def cart_update_json(self, *args, **kwargs):
|
||||||
|
"""Ensure delivery fee is not shown when modifying cart quantities"""
|
||||||
|
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
|
||||||
|
|
||||||
|
@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')
|
||||||
|
|
||||||
|
if not order.order_line:
|
||||||
|
return request.redirect('/shop/cart')
|
||||||
|
|
||||||
|
# If user has already selected & submitted the address in this flow, proceed to standard checkout/payment
|
||||||
|
if post.get('express') or request.session.get('address_confirmed_for_order') == order.id:
|
||||||
|
return super(Dine360WebsiteSaleOnline, self).checkout(**post)
|
||||||
|
|
||||||
|
# Otherwise, always direct to the unified delivery/pickup address page
|
||||||
|
partner_id = order.partner_id.id if order.partner_id else 0
|
||||||
|
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)
|
||||||
|
def address(self, **post):
|
||||||
|
"""Process address form submission, record fulfillment type, and mark address as confirmed"""
|
||||||
|
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('city'): post['city'] = 'Local'
|
||||||
|
if not post.get('zip'): post['zip'] = '00000'
|
||||||
|
if not post.get('country_id'):
|
||||||
|
country = request.env['res.country'].sudo().search([], limit=1)
|
||||||
|
post['country_id'] = str(country.id) if country else ''
|
||||||
|
|
||||||
|
for k, v in post.items():
|
||||||
|
if k in ['street', 'city', 'zip', 'country_id']:
|
||||||
|
if hasattr(request.params, 'update'):
|
||||||
|
try:
|
||||||
|
request.params[k] = v
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return super(Dine360WebsiteSaleOnline, self).address(**post)
|
||||||
|
|
||||||
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
|
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
|
||||||
def shop_payment(self, **post):
|
def shop_payment(self, **post):
|
||||||
|
"""Ensure customer came from address selection, and bypass missing delivery carrier validation"""
|
||||||
order = request.website.sale_get_order()
|
order = request.website.sale_get_order()
|
||||||
if order and not order.carrier_id:
|
if not order or order.state != 'draft' or not order.order_line:
|
||||||
# Bypass delivery validation by assigning the first available delivery carrier
|
return request.redirect('/shop/cart')
|
||||||
carriers = order._get_delivery_methods()
|
|
||||||
if not carriers:
|
|
||||||
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1)
|
|
||||||
|
|
||||||
if carriers:
|
# If user directly visits /shop/payment without passing the address & fulfillment page, redirect them
|
||||||
order.carrier_id = carriers[0].id
|
if request.session.get('address_confirmed_for_order') != order.id:
|
||||||
price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0
|
partner_id = order.partner_id.id if order.partner_id else 0
|
||||||
order.set_delivery_line(carriers[0], price)
|
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)
|
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post)
|
||||||
|
|
||||||
@http.route('/shop/payment/transaction/<int:order_id>', type='json', auth="public", website=True)
|
@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):
|
def shop_payment_transaction(self, order_id, access_token=None, **kwargs):
|
||||||
# Force bypass "No shipping method is selected" error during payment processing
|
"""Preserve delivery fee during payment transaction initiation"""
|
||||||
order = request.env['sale.order'].sudo().browse(order_id)
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
if order and not order.carrier_id:
|
if order and order.fulfilment_type == 'delivery':
|
||||||
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1)
|
delivery_line = order.order_line.filtered(lambda l: l.is_delivery)
|
||||||
if carriers:
|
fee = getattr(order, 'uber_delivery_fee', 0.0)
|
||||||
order.carrier_id = carriers[0].id
|
if not delivery_line and fee > 0 and hasattr(order, '_add_uber_delivery_fee'):
|
||||||
order.set_delivery_line(carriers[0], 0.0)
|
order._add_uber_delivery_fee(fee)
|
||||||
return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs)
|
return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs)
|
||||||
|
|
||||||
@http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False)
|
|
||||||
def address(self, **post):
|
|
||||||
# Override to inject dummy address values for pickup orders before Odoo validates them
|
|
||||||
if post.get('submitted') and post.get('fulfilment_type') == 'pickup':
|
|
||||||
if not post.get('street'): post['street'] = 'Store Pickup'
|
|
||||||
if not post.get('city'): post['city'] = 'Local'
|
|
||||||
if not post.get('zip'): post['zip'] = '00000'
|
|
||||||
if not post.get('country_id'):
|
|
||||||
country = request.env['res.country'].sudo().search([], limit=1)
|
|
||||||
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():
|
|
||||||
if k in ['street', 'city', 'zip', 'country_id']:
|
|
||||||
if hasattr(request.params, 'update'):
|
|
||||||
try:
|
|
||||||
request.params[k] = v
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super(Dine360WebsiteSaleOnline, self).address(**post)
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -78,14 +81,20 @@ class PosOrderChannels(models.Model):
|
|||||||
for order in self:
|
for order in self:
|
||||||
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_city = p.city or ''
|
order.delivery_street2 = p.street2 or ''
|
||||||
order.delivery_zip = p.zip or ''
|
order.delivery_city = p.city or ''
|
||||||
order.delivery_phone = p.phone or p.mobile or ''
|
order.delivery_state = p.state_id.name if p.state_id else ''
|
||||||
|
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 ''
|
||||||
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', '')
|
||||||
@ -123,15 +135,18 @@ class PosOrderChannels(models.Model):
|
|||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
def _export_for_ui(self, order):
|
def _export_for_ui(self, order):
|
||||||
result = super()._export_for_ui(order)
|
result = super()._export_for_ui(order)
|
||||||
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_city'] = order.delivery_city or ''
|
result['delivery_street2'] = order.delivery_street2 or ''
|
||||||
result['delivery_zip'] = order.delivery_zip or ''
|
result['delivery_city'] = order.delivery_city or ''
|
||||||
result['delivery_phone'] = order.delivery_phone or ''
|
result['delivery_state'] = order.delivery_state or ''
|
||||||
result['delivery_notes'] = order.delivery_notes or ''
|
result['delivery_zip'] = order.delivery_zip or ''
|
||||||
result['whatsapp_number'] = order.whatsapp_number or ''
|
result['delivery_country'] = order.delivery_country or ''
|
||||||
result['social_ref'] = order.social_ref or ''
|
result['delivery_phone'] = order.delivery_phone or ''
|
||||||
|
result['delivery_notes'] = order.delivery_notes or ''
|
||||||
|
result['whatsapp_number'] = order.whatsapp_number or ''
|
||||||
|
result['social_ref'] = order.social_ref or ''
|
||||||
result['telephone_number'] = order.telephone_number or ''
|
result['telephone_number'] = order.telephone_number or ''
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -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,9 +110,14 @@ export class ChannelPanel extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.state.searching = true;
|
this.state.searching = true;
|
||||||
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
|
try {
|
||||||
this.state.searchResults = results;
|
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
|
||||||
this.state.searching = false;
|
this.state.searchResults = results;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Partner search error:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.searching = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectPartner(partner) {
|
onSelectPartner(partner) {
|
||||||
@ -104,26 +125,161 @@ export class ChannelPanel extends Component {
|
|||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -101,29 +101,67 @@
|
|||||||
</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>
|
||||||
t-att-value="currentOrder.delivery_street"
|
|
||||||
t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/>
|
|
||||||
<div class="d-flex gap-1 mb-1">
|
|
||||||
<input type="text" class="form-control form-control-sm"
|
<input type="text" class="form-control form-control-sm"
|
||||||
placeholder="City"
|
placeholder="123 Main St"
|
||||||
t-att-value="currentOrder.delivery_city"
|
t-att-value="currentOrder.delivery_street"
|
||||||
t-on-change="(ev) => this.onDeliveryFieldChange('city', ev)"/>
|
t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/>
|
||||||
<input type="text" class="form-control form-control-sm"
|
</div>
|
||||||
placeholder="Zip" style="max-width:90px"
|
<div class="mb-1">
|
||||||
t-att-value="currentOrder.delivery_zip"
|
<label class="form-label mb-0 text-muted" style="font-size: 10px;">Apartment, suite, etc.</label>
|
||||||
t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/>
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
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-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"
|
||||||
|
placeholder="M1B 2K4"
|
||||||
|
t-att-value="currentOrder.delivery_zip"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/>
|
||||||
|
</div>
|
||||||
|
</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;">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-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/>
|
||||||
|
</div>
|
||||||
|
<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-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/>
|
||||||
</div>
|
</div>
|
||||||
<input type="tel" class="form-control form-control-sm mb-1"
|
|
||||||
placeholder="Phone"
|
|
||||||
t-att-value="currentOrder.delivery_phone"
|
|
||||||
t-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/>
|
|
||||||
<textarea class="form-control form-control-sm" rows="2"
|
|
||||||
placeholder="Delivery notes (gate code, floor...)"
|
|
||||||
t-att-value="currentOrder.delivery_notes"
|
|
||||||
t-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Info when hidden -->
|
<!-- Quick Info when hidden -->
|
||||||
@ -131,6 +169,45 @@
|
|||||||
<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 & 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>
|
||||||
|
|
||||||
|
|||||||
@ -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 && countrySelect.selectedIndex >= 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 && data.result.success) {
|
if (data.result && 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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': [
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
from . import main
|
from . import main
|
||||||
|
from . import tracking
|
||||||
|
|||||||
@ -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.'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
445
addons/dine360_uber/controllers/tracking.py
Normal file
445
addons/dine360_uber/controllers/tracking.py
Normal 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('&', '&').replace('<', '<').replace('"', '"')
|
||||||
|
|
||||||
|
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;">🏪</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;">🏠</div>'
|
||||||
|
|
||||||
|
eta_chip = ('<div class="info-chip"><div class="lbl">⏱ 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">🔖 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">📞 ' + esc(v['pickup_phone']) + '</div>') if v['pickup_phone'] else ''
|
||||||
|
dropoff_phone_html = ('<div class="card-phone">📞 ' + 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 = (' – ' + 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 = (' – ' + 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">⚡ Uber Live</a>') if show_uber_btn else ''
|
||||||
|
uber_btn = ('<a href="' + esc(v['uber_tracking_url']) + '" target="_blank" class="btn btn-dark">⚡ 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">🎯 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">🗺 Google Maps Directions</a>
|
||||||
|
{uber_btn}
|
||||||
|
<a href="javascript:history.back()" class="btn btn-back">← 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>'''
|
||||||
@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -149,119 +149,150 @@ 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:
|
customer_id = self.customer_id
|
||||||
if self.environment == 'sandbox':
|
|
||||||
_logger.warning("Sandbox Mode: Uber token retrieval failed (%s). Providing sandbox quote.", str(e))
|
if customer_id and access_token:
|
||||||
return {
|
api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
|
||||||
'success': True,
|
headers = {
|
||||||
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
|
'Authorization': f'Bearer {access_token}',
|
||||||
'fee_amount': 4.99,
|
'Content-Type': 'application/json'
|
||||||
'currency': 'CAD',
|
|
||||||
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
|
|
||||||
'raw': {'simulated': True}
|
|
||||||
}
|
}
|
||||||
raise e
|
if not items:
|
||||||
|
items = [{"name": "Food Delivery", "quantity": 1, "size": "small"}]
|
||||||
|
|
||||||
customer_id = self.customer_id
|
payload = {
|
||||||
if not customer_id:
|
"pickup_address": pickup_address,
|
||||||
if self.environment == 'sandbox':
|
"dropoff_address": dropoff_address,
|
||||||
return {
|
"manifest_items": items
|
||||||
'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."))
|
|
||||||
|
|
||||||
api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes"
|
_logger.info("Uber Direct API Request: %s", json.dumps(payload))
|
||||||
headers = {
|
response = requests.post(api_url, headers=headers, json=payload, timeout=10)
|
||||||
'Authorization': f'Bearer {access_token}',
|
_logger.info("Uber Direct API Response (%s): %s", response.status_code, response.text)
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure at least one dummy item if none provided (Uber Direct sometimes requires this)
|
if response.status_code in [200, 201]:
|
||||||
if not items:
|
|
||||||
items = [{
|
|
||||||
"name": "Food Delivery",
|
|
||||||
"quantity": 1,
|
|
||||||
"size": "small"
|
|
||||||
}]
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"pickup_address": pickup_address,
|
|
||||||
"dropoff_address": dropoff_address,
|
|
||||||
"manifest_items": items
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2))
|
|
||||||
|
|
||||||
try:
|
|
||||||
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:
|
fee_cents = data.get('fee', 0)
|
||||||
pass
|
fee_amount = round(float(fee_cents) / 100.0, 2)
|
||||||
|
|
||||||
# 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 {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
|
'quote_id': data.get('id'),
|
||||||
'fee_amount': 4.99,
|
'fee_amount': fee_amount if fee_amount > 0 else 5.99,
|
||||||
'currency': 'CAD',
|
'currency': data.get('currency_code', 'CAD'),
|
||||||
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
|
'estimated_arrival': data.get('estimated_arrival') or data.get('dropoff_eta'),
|
||||||
'raw': {'simulated': True, 'original_error': msg}
|
'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:
|
||||||
|
_logger.warning("Uber API connection issue: %s", str(e))
|
||||||
|
if self.environment == 'production':
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': msg,
|
'error': f"Unable to reach Uber Delivery Service: {str(e)}"
|
||||||
'code': data.get('code', 'unknown'),
|
|
||||||
'raw_error': data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data = response.json()
|
# 2. Dynamic City & Postal Code Distance Calculation Engine (Sandbox / Testing)
|
||||||
# Standard fee is in cents
|
# Calculates realistic distance fee based on pickup city vs dropoff city & postal code
|
||||||
fee_cents = data.get('fee', 0)
|
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 {
|
return {
|
||||||
'success': True,
|
'success': False,
|
||||||
'quote_id': data.get('id'),
|
'error': f"Delivery is not available in {d_city.title() if d_city else 'this location'}. Distance exceeds maximum 30 km Uber courier radius."
|
||||||
'fee_amount': float(fee_cents) / 100.0,
|
|
||||||
'currency': data.get('currency_code', 'CAD'),
|
|
||||||
'estimated_arrival': data.get('estimated_arrival'),
|
|
||||||
'raw': data
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
elif 'mississauga' in full_text:
|
||||||
_logger.exception("Uber Quote API Exception")
|
distance_km = 24.0
|
||||||
if self.environment == 'sandbox':
|
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 {
|
return {
|
||||||
'success': True,
|
'success': False,
|
||||||
'quote_id': f"quote_sandbox_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
|
'error': f"Delivery is not available in {d_city.title()}. Address is outside our delivery coverage zone."
|
||||||
'fee_amount': 4.99,
|
|
||||||
'currency': 'CAD',
|
|
||||||
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(),
|
|
||||||
'raw': {'simulated': True, 'exception': str(e)}
|
|
||||||
}
|
}
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
calculated_fee = round(4.50 + (distance_km * 0.75), 2)
|
||||||
|
eta_minutes = int(15 + (distance_km * 1.5))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'quote_id': f"uber_quote_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
'fee_amount': calculated_fee,
|
||||||
|
'distance_km': distance_km,
|
||||||
|
'currency': 'CAD',
|
||||||
|
'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=eta_minutes)).isoformat(),
|
||||||
|
'raw': {'distance_km': distance_km, 'dynamic_calculation': True}
|
||||||
|
}
|
||||||
|
|
||||||
def _return_notification(self, message, msg_type):
|
def _return_notification(self, message, msg_type):
|
||||||
return {
|
return {
|
||||||
|
|||||||
87
addons/dine360_uber/views/sale_order_views.xml
Normal file
87
addons/dine360_uber/views/sale_order_views.xml
Normal 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="{"currency_field": "currency_id"}"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
337
addons/dine360_uber/views/tracking_template.xml
Normal file
337
addons/dine360_uber/views/tracking_template.xml
Normal 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&model=%s.order&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>
|
||||||
Loading…
x
Reference in New Issue
Block a user