diff --git a/CLIENT_HANDBOOK_DINE360_SHIVASAKTHI.md b/CLIENT_HANDBOOK_DINE360_SHIVASAKTHI.md new file mode 100644 index 0000000..772c8d2 --- /dev/null +++ b/CLIENT_HANDBOOK_DINE360_SHIVASAKTHI.md @@ -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.
2. Restart QZ Tray application from Windows taskbar.
3. Verify printer test page prints from Windows Settings. | +| **KDS screen not refreshing** | Local network dropout | 1. Check Wi-Fi connection on kitchen tablet.
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.
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.
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` diff --git a/addons/dine360_kds/models/product.py b/addons/dine360_kds/models/product.py index 20e152b..008b5b3 100644 --- a/addons/dine360_kds/models/product.py +++ b/addons/dine360_kds/models/product.py @@ -5,6 +5,6 @@ class ProductTemplate(models.Model): is_kitchen_item = fields.Boolean( string='Show in KDS', - default=False, + default=True, help="If checked, this product will appear in the Kitchen Display System when ordered." ) diff --git a/addons/dine360_kds/models/website_sale_integration.py b/addons/dine360_kds/models/website_sale_integration.py index da29b71..a595a0c 100644 --- a/addons/dine360_kds/models/website_sale_integration.py +++ b/addons/dine360_kds/models/website_sale_integration.py @@ -58,7 +58,8 @@ class SaleOrder(models.Model): # Skip non-kitchen items, but allow delivery lines for accurate total matching 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 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) # Use skip_kds_notify context to prevent immediate KDS notification # 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, 'company_id': sale_order.company_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_return': 0.0, '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 _logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} (pending cashier confirmation).") diff --git a/addons/dine360_online_orders/controllers/main.py b/addons/dine360_online_orders/controllers/main.py index b0eb327..7778b86 100644 --- a/addons/dine360_online_orders/controllers/main.py +++ b/addons/dine360_online_orders/controllers/main.py @@ -15,51 +15,118 @@ class Dine360OnlineOrders(http.Controller): return True 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) def shop_payment(self, **post): + """Ensure customer came from address selection, and bypass missing delivery carrier validation""" order = request.website.sale_get_order() - if order and not order.carrier_id: - # Bypass delivery validation by assigning the first available delivery carrier - carriers = order._get_delivery_methods() - if not carriers: - carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) - - if carriers: - order.carrier_id = carriers[0].id - price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0 - order.set_delivery_line(carriers[0], price) + if not order or order.state != 'draft' or not order.order_line: + return request.redirect('/shop/cart') + + # If user directly visits /shop/payment without passing the address & fulfillment page, redirect them + if request.session.get('address_confirmed_for_order') != order.id: + partner_id = order.partner_id.id if order.partner_id else 0 + return request.redirect(f'/shop/address?mode=billing&partner_id={partner_id}') + + # For delivery orders, ensure the delivery line is active and reflects the stored fee + if order.fulfilment_type == 'delivery': + delivery_line = order.order_line.filtered(lambda l: l.is_delivery) + fee = getattr(order, 'uber_delivery_fee', 0.0) + if not delivery_line and fee > 0 and hasattr(order, '_add_uber_delivery_fee'): + order.sudo()._add_uber_delivery_fee(fee) + elif order.fulfilment_type in ['pickup', 'dine_in']: + # Ensure no leftover delivery line for pickup + order.order_line.filtered(lambda l: l.is_delivery).sudo().unlink() return super(Dine360WebsiteSaleOnline, self).shop_payment(**post) @http.route('/shop/payment/transaction/', type='json', auth="public", website=True) 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) - if order and not order.carrier_id: - carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) - if carriers: - order.carrier_id = carriers[0].id - order.set_delivery_line(carriers[0], 0.0) + if order and order.fulfilment_type == 'delivery': + delivery_line = order.order_line.filtered(lambda l: l.is_delivery) + fee = getattr(order, 'uber_delivery_fee', 0.0) + if not delivery_line and fee > 0 and hasattr(order, '_add_uber_delivery_fee'): + order._add_uber_delivery_fee(fee) return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs) - - @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) diff --git a/addons/dine360_online_orders/models/pos_order.py b/addons/dine360_online_orders/models/pos_order.py index b539d25..e13a20c 100644 --- a/addons/dine360_online_orders/models/pos_order.py +++ b/addons/dine360_online_orders/models/pos_order.py @@ -90,14 +90,14 @@ class PosOrder(models.Model): # Set all order lines to 'waiting' so KDS picks them up 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({ 'preparation_status': 'waiting', }) # Notify KDS 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 POS that order was confirmed diff --git a/addons/dine360_online_orders/models/sale_order.py b/addons/dine360_online_orders/models/sale_order.py index 02a088a..6f12190 100644 --- a/addons/dine360_online_orders/models/sale_order.py +++ b/addons/dine360_online_orders/models/sale_order.py @@ -132,13 +132,14 @@ class SaleOrderOnline(models.Model): def _is_shippable_order(self): """ - Treat pickup, delivery and other types as non-shippable for Odoo's standard validation. - This enables 'Billing-only' checkout which is more reliable for payment providers. - The Uber delivery line is protected by our _remove_delivery_line override. + Pickup and dine-in are non-shippable (no delivery fee). + Delivery orders ARE shippable to preserve the delivery fee on payment page. """ 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 + if self.fulfilment_type == 'delivery': + return True return super()._is_shippable_order() def _check_carrier_quotation(self, force_carrier_id=None, **kwargs): diff --git a/addons/dine360_order_channels/controllers/main.py b/addons/dine360_order_channels/controllers/main.py index 89b8816..9ba4cc2 100644 --- a/addons/dine360_order_channels/controllers/main.py +++ b/addons/dine360_order_channels/controllers/main.py @@ -18,8 +18,11 @@ class Dine360OrderChannelsController(http.Controller): 'name': p.name, 'phone': p.phone or p.mobile or '', 'street': p.street or '', + 'street2': p.street2 or '', 'city': p.city or '', + 'state': p.state_id.name if p.state_id else '', 'zip': p.zip or '', + 'country': p.country_id.name if p.country_id else '', 'display_name': p.display_name, } for p in partners] diff --git a/addons/dine360_order_channels/models/pos_order.py b/addons/dine360_order_channels/models/pos_order.py index f78fa13..efa8415 100644 --- a/addons/dine360_order_channels/models/pos_order.py +++ b/addons/dine360_order_channels/models/pos_order.py @@ -42,8 +42,11 @@ class PosOrderChannels(models.Model): help='Delivery address for this order' ) 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_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_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_notes = fields.Text('Delivery Notes', help='Special delivery instructions') @@ -55,7 +58,7 @@ class PosOrderChannels(models.Model): # ----------------------------------------------------------- # Social Media: reference - # ----------------------------------------------------------- # Social Media: reference + # ----------------------------------------------------------- social_ref = fields.Char('Social Ref', help='Instagram/Facebook message/post reference') # Telephone: customer phone for phone orders @@ -78,14 +81,20 @@ class PosOrderChannels(models.Model): for order in self: p = order.delivery_partner_id if p: - order.delivery_street = p.street or '' - order.delivery_city = p.city or '' - order.delivery_zip = p.zip or '' - order.delivery_phone = p.phone or p.mobile or '' + order.delivery_street = p.street or '' + order.delivery_street2 = p.street2 or '' + order.delivery_city = p.city or '' + order.delivery_state = p.state_id.name if p.state_id else '' + order.delivery_zip = p.zip or '' + order.delivery_country = p.country_id.name if p.country_id else '' + order.delivery_phone = p.phone or p.mobile or '' 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_state = order.delivery_state or '' order.delivery_zip = order.delivery_zip or '' + order.delivery_country = order.delivery_country 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['fulfilment_type'] = ui_order.get('fulfilment_type', 'dine_in') 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_state'] = ui_order.get('delivery_state', '') 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_notes'] = ui_order.get('delivery_notes', '') fields_dict['whatsapp_number'] = ui_order.get('whatsapp_number', '') @@ -123,15 +135,18 @@ class PosOrderChannels(models.Model): # ----------------------------------------------------------- def _export_for_ui(self, order): result = super()._export_for_ui(order) - result['order_source'] = order.order_source - result['fulfilment_type'] = order.fulfilment_type - result['delivery_street'] = order.delivery_street or '' - result['delivery_city'] = order.delivery_city or '' - result['delivery_zip'] = order.delivery_zip 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['order_source'] = order.order_source + result['fulfilment_type'] = order.fulfilment_type + result['delivery_street'] = order.delivery_street or '' + result['delivery_street2'] = order.delivery_street2 or '' + result['delivery_city'] = order.delivery_city or '' + result['delivery_state'] = order.delivery_state or '' + result['delivery_zip'] = order.delivery_zip or '' + result['delivery_country'] = order.delivery_country 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 '' return result diff --git a/addons/dine360_order_channels/static/src/js/channel_panel.js b/addons/dine360_order_channels/static/src/js/channel_panel.js index fdcfe48..a5f0970 100644 --- a/addons/dine360_order_channels/static/src/js/channel_panel.js +++ b/addons/dine360_order_channels/static/src/js/channel_panel.js @@ -9,13 +9,14 @@ import { jsonrpc } from "@web/core/network/rpc_service"; * Allows staff to select: * - Order Source (Walk-in / Phone / WhatsApp / Social / Online) * - 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 { static template = "dine360_order_channels.ChannelPanel"; setup() { this.pos = usePos(); + this.quoteTimer = null; this.SOURCE_LABELS = { walk_in: 'Walk-In', @@ -41,6 +42,7 @@ export class ChannelPanel extends Component { searchQuery: '', searchResults: [], 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; } + 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 --- onSourceChange(source) { const order = this.currentOrder; if (!order) return; order.setOrderSource(source); - // Trigger re-render this.state.showDelivery = this.isDelivery; } @@ -83,6 +92,13 @@ export class ChannelPanel extends Component { if (!order) return; order.setFulfilmentType(type); this.state.showDelivery = (type === 'delivery'); + + if (type !== 'delivery') { + this.state.quote = null; + this.removeDeliveryFeeLine(); + } else { + this.triggerQuoteCheck(100); + } } // --- Partner Address Search --- @@ -94,9 +110,14 @@ export class ChannelPanel extends Component { return; } this.state.searching = true; - const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 }); - this.state.searchResults = results; - this.state.searching = false; + try { + const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 }); + this.state.searchResults = results; + } catch (e) { + console.warn("Partner search error:", e); + } finally { + this.state.searching = false; + } } onSelectPartner(partner) { @@ -104,26 +125,161 @@ export class ChannelPanel extends Component { if (!order) return; order.setDeliveryAddress({ street: partner.street, + street2: partner.street2, city: partner.city, + state: partner.state || 'Ontario', zip: partner.zip, + country: partner.country || 'Canada', phone: partner.phone, }); this.state.searchQuery = partner.display_name; this.state.searchResults = []; + this.triggerQuoteCheck(0); // instant quote check } onDeliveryFieldChange(field, ev) { const order = this.currentOrder; if (!order) return; - // Ad-hoc street/city/zip/phone/notes editing const current = { street: order.delivery_street, + street2: order.delivery_street2, city: order.delivery_city, + state: order.delivery_state, zip: order.delivery_zip, + country: order.delivery_country, phone: order.delivery_phone, notes: order.delivery_notes, }; current[field] = ev.target.value; 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); + } } } diff --git a/addons/dine360_order_channels/static/src/js/order_channel_model.js b/addons/dine360_order_channels/static/src/js/order_channel_model.js index 46ea21f..9fabce1 100644 --- a/addons/dine360_order_channels/static/src/js/order_channel_model.js +++ b/addons/dine360_order_channels/static/src/js/order_channel_model.js @@ -4,7 +4,7 @@ import { patch } from "@web/core/utils/patch"; 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. */ patch(Order.prototype, { @@ -15,10 +15,14 @@ patch(Order.prototype, { this.order_source = config.default_order_source || 'walk_in'; this.fulfilment_type = config.default_fulfilment_type || 'dine_in'; this.delivery_street = ''; + this.delivery_street2 = ''; this.delivery_city = ''; + this.delivery_state = ''; this.delivery_zip = ''; + this.delivery_country = ''; this.delivery_phone = ''; this.delivery_notes = ''; + this.uber_delivery_fee = 0.0; this.whatsapp_number = ''; this.social_ref = ''; this.telephone_number = ''; @@ -29,10 +33,14 @@ patch(Order.prototype, { json.order_source = this.order_source; json.fulfilment_type = this.fulfilment_type; json.delivery_street = this.delivery_street; + json.delivery_street2 = this.delivery_street2; json.delivery_city = this.delivery_city; + json.delivery_state = this.delivery_state; json.delivery_zip = this.delivery_zip; + json.delivery_country = this.delivery_country; json.delivery_phone = this.delivery_phone; json.delivery_notes = this.delivery_notes; + json.uber_delivery_fee = this.uber_delivery_fee; json.whatsapp_number = this.whatsapp_number; json.social_ref = this.social_ref; json.telephone_number = this.telephone_number; @@ -44,10 +52,14 @@ patch(Order.prototype, { this.order_source = json.order_source || 'walk_in'; this.fulfilment_type = json.fulfilment_type || 'dine_in'; this.delivery_street = json.delivery_street || ''; + this.delivery_street2 = json.delivery_street2 || ''; this.delivery_city = json.delivery_city || ''; + this.delivery_state = json.delivery_state || ''; this.delivery_zip = json.delivery_zip || ''; + this.delivery_country = json.delivery_country || ''; this.delivery_phone = json.delivery_phone || ''; this.delivery_notes = json.delivery_notes || ''; + this.uber_delivery_fee = json.uber_delivery_fee || 0.0; this.whatsapp_number = json.whatsapp_number || ''; this.social_ref = json.social_ref || ''; this.telephone_number = json.telephone_number || ''; @@ -63,8 +75,11 @@ patch(Order.prototype, { setDeliveryAddress(fields) { this.delivery_street = fields.street || ''; + this.delivery_street2 = fields.street2 || ''; this.delivery_city = fields.city || ''; + this.delivery_state = fields.state || ''; this.delivery_zip = fields.zip || ''; + this.delivery_country = fields.country || ''; this.delivery_phone = fields.phone || ''; this.delivery_notes = fields.notes || ''; }, @@ -94,10 +109,14 @@ patch(Order.prototype, { result.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_street2 = this.delivery_street2; result.delivery_city = this.delivery_city; + result.delivery_state = this.delivery_state; result.delivery_zip = this.delivery_zip; + result.delivery_country = this.delivery_country; result.delivery_phone = this.delivery_phone; result.delivery_notes = this.delivery_notes; + result.uber_delivery_fee = this.uber_delivery_fee; result.whatsapp_number = this.whatsapp_number; result.social_ref = this.social_ref; result.telephone_number = this.telephone_number; diff --git a/addons/dine360_order_channels/static/src/xml/channel_panel.xml b/addons/dine360_order_channels/static/src/xml/channel_panel.xml index 12a3f18..3f6e360 100644 --- a/addons/dine360_order_channels/static/src/xml/channel_panel.xml +++ b/addons/dine360_order_channels/static/src/xml/channel_panel.xml @@ -101,29 +101,67 @@ -
- -
+
+
+ - + placeholder="123 Main St" + t-att-value="currentOrder.delivery_street" + t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/> +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +