from odoo import models, fields, api, _ from odoo.exceptions import UserError import datetime import logging import requests import json _logger = logging.getLogger(__name__) class PosOrder(models.Model): _inherit = 'pos.order' is_uber_order = fields.Boolean(string='Is Uber Order', default=False) uber_order_id = fields.Char(string='Uber Order ID') uber_delivery_id = fields.Char(string='Uber Delivery ID') uber_status = fields.Selection([ ('pending', 'Pending Uber Pickup'), ('pickup', 'Uber Driver Picked Up'), ('delivering', 'In Transit'), ('delivered', 'Delivered'), ('cancelled', 'Cancelled') ], string='Uber Delivery Status') delivery_type = fields.Selection([ ('none', 'None'), ('dine_in', 'Dine In'), ('takeaway', 'Takeaway'), ('uber', 'Uber Direct') ], string='Delivery Type', default='none') # Advanced Features Fields uber_tracking_url = fields.Char(string='Driver Tracking Link') uber_eta = fields.Datetime(string='Predicted Delivery Time') uber_delivery_fee = fields.Float(string='Uber Delivery Fee', readonly=True) uber_request_time = fields.Datetime(string='Uber Request Time') uber_alert_triggered = fields.Boolean(string='Driver Timeout Alert Sent', default=False) def _check_all_lines_ready(self): """Check if all kitchen items in the order are ready or served""" self.ensure_one() kitchen_lines = self.lines.filtered(lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service') if not kitchen_lines: return False return all(line.preparation_status in ['ready', 'served'] for line in kitchen_lines) def _format_uber_phone(self, phone): """Normalize phone number to E.164 (e.g. +14165551234)""" if not phone: return "+15555555555" phone_str = str(phone).strip() has_plus = phone_str.startswith('+') digits = "".join(filter(str.isdigit, phone_str)) if has_plus: return f"+{digits}" if len(digits) == 10: return f"+1{digits}" if len(digits) == 11 and digits.startswith('1'): return f"+{digits}" return f"+{digits}" if digits else "+15555555555" def _get_uber_delivery_address(self): """Extract dropoff address using order delivery fields or partner fields with robust fallbacks""" self.ensure_one() partner = getattr(self, 'delivery_partner_id', False) or self.partner_id company = self.company_id street = getattr(self, 'delivery_street', False) or (partner.street if partner else '') or '' street2 = partner.street2 if (partner and partner.street2) else '' city = getattr(self, 'delivery_city', False) or (partner.city if partner else '') or company.city or '' state_code = (partner.state_id.code if (partner and partner.state_id) else '') or (company.state_id.code if company.state_id else 'ON') zip_code = getattr(self, 'delivery_zip', False) or (partner.zip if partner else '') or company.zip or '' country_code = (partner.country_id.code if (partner and partner.country_id) else '') or (company.country_id.code if company.country_id else 'CA') phone = getattr(self, 'delivery_phone', False) or (partner.phone or partner.mobile if partner else '') or '' name = (partner.name if partner else False) or self.pos_reference or self.name or 'Customer' return { 'name': name, 'street': street.strip(), 'street2': street2.strip() if street2 else '', 'city': city.strip(), 'state_code': state_code.strip() if state_code else 'ON', 'zip_code': zip_code.strip(), 'country_code': country_code.strip() or 'CA', 'phone': phone.strip(), } def action_request_uber_delivery(self): """Trigger Uber Direct delivery request via API""" for order in self: if order.is_uber_order and order.uber_status and order.uber_status not in ['cancelled', False] and order.uber_delivery_id: _logger.info("Uber Delivery already active for order %s (ID: %s)", order.name, order.uber_delivery_id) continue # 1. Get Configuration config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) if not config: raise UserError(_("Uber Integration is not configured. Please check Settings.")) customer_id = config.customer_id if not customer_id and config.environment != 'sandbox': raise UserError(_("Uber Customer ID is missing in configuration.")) # 2. Get Delivery Address addr = order._get_uber_delivery_address() if not addr['street']: # If partner has no street, attempt from customer note or default if order.partner_id and order.partner_id.street: addr['street'] = order.partner_id.street elif getattr(order, 'delivery_notes', False): addr['street'] = order.delivery_notes else: addr['street'] = addr['city'] or "Local Delivery" # 3. Authenticate is_simulated = False access_token = False try: access_token = config._get_access_token() except Exception as e: if config.environment == 'sandbox': _logger.warning("Sandbox Mode: Uber auth failed (%s). Providing simulated courier dispatch.", str(e)) is_simulated = True access_token = "simulated_sandbox_token" else: raise UserError(_("Authentication Failed: %s") % str(e)) # 4. Prepare Payload 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_lines = [addr['street']] if addr['street2'] and addr['street2'] not in addr['street']: dropoff_street_lines.append(addr['street2']) dropoff_address = json.dumps({ "street_address": dropoff_street_lines, "city": addr['city'], "state": addr['state_code'], "zip_code": addr['zip_code'], "country": addr['country_code'] }) items = [] for line in order.lines: if not line.product_id.is_kitchen_item and line.product_id.type == 'service': continue items.append({ "name": line.full_product_name or line.product_id.name, "quantity": int(line.qty) if line.qty > 0 else 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 api_url = f"https://api.uber.com/v1/customers/{customer_id}/deliveries" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } try: if is_simulated: data = { 'id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}", 'tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}", 'fee': int((order.uber_delivery_fee or 4.99) * 100), 'dropoff_eta': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(), } _logger.info("Simulated Uber Sandbox Delivery Created for %s: %s", order.name, data) else: _logger.info("Requesting Uber Delivery for %s: %s", order.name, json.dumps(payload, indent=2)) response = requests.post(api_url, headers=headers, json=payload) if response.status_code not in [200, 201]: if config.environment == 'sandbox': _logger.warning("Sandbox Mode: Uber API returned %s (%s). Falling back to simulated dispatch.", response.status_code, response.text) data = { 'id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}", 'tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}", 'fee': int((order.uber_delivery_fee or 4.99) * 100), 'dropoff_eta': (fields.Datetime.now() + datetime.timedelta(minutes=25)).isoformat(), } else: response.raise_for_status() else: data = response.json() _logger.info("Uber Delivery Created for %s: %s", order.name, data) # 6. Process Success delivery_fee = 0.0 if 'fee' in data: delivery_fee = float(data['fee']) / 100.0 dropoff_eta = False raw_eta = data.get('dropoff_eta') or data.get('estimated_dropoff_time') or data.get('estimated_arrival') if raw_eta: try: clean_eta = str(raw_eta).replace('Z', '+00:00').split('.')[0] dropoff_eta = datetime.datetime.fromisoformat(clean_eta) except Exception: dropoff_eta = fields.Datetime.now() + datetime.timedelta(minutes=30) else: dropoff_eta = fields.Datetime.now() + datetime.timedelta(minutes=30) order_vals = { 'uber_status': 'pending', 'is_uber_order': True, 'delivery_type': 'uber', '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, } if hasattr(order, 'fulfilment_type'): order_vals['fulfilment_type'] = 'delivery' order.write(order_vals) # Broadcast real-time bus notification self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': 'pending' }) # Add delivery fee line if not already present and order not locked if delivery_fee > 0: order._add_uber_delivery_fee(delivery_fee) except requests.exceptions.HTTPError as e: _logger.error("Uber Direct Raw Error Response (%s): %s", e.response.status_code, e.response.text) if config.environment == 'sandbox': _logger.warning("Sandbox Mode: Recovering with simulated courier dispatch.") order.write({ 'uber_status': 'pending', 'is_uber_order': True, 'delivery_type': 'uber', 'uber_delivery_id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}", 'uber_request_time': fields.Datetime.now(), 'uber_delivery_fee': 4.99, 'uber_tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}", 'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=25), 'uber_alert_triggered': False, }) self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': 'pending' }) return try: err_data = e.response.json() err_code = err_data.get('code', 'unknown_error') err_msg = err_data.get('message', 'An error occurred with Uber API.') if err_code == 'address_undeliverable': details = err_data.get('metadata', {}).get('details', '') raise UserError(_("Address Undeliverable: The drop-off location is outside Uber's delivery radius. \n\nDetails: %s") % details) raise UserError(_("Uber API Error (%s): %s") % (err_code, err_msg)) 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 Mode: Recovering from exception (%s) with simulated dispatch.", str(e)) order.write({ 'uber_status': 'pending', 'is_uber_order': True, 'delivery_type': 'uber', 'uber_delivery_id': f"del_sandbox_{order.id}_{datetime.datetime.now().strftime('%M%S')}", 'uber_request_time': fields.Datetime.now(), 'uber_delivery_fee': 4.99, 'uber_tracking_url': f"https://m.uber.com/looking?dropoff_id=sandbox_{order.id}", 'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=25), 'uber_alert_triggered': False, }) self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': 'pending' }) else: raise UserError(_("Failed to request delivery: %s") % str(e)) def _add_uber_delivery_fee(self, amount): """Add the delivery fee as a line item if not already added""" config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) if config and config.delivery_product_id: # Check if fee line exists fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id) if not fee_line and self.state in ['draft', 'sent']: taxes = config.delivery_product_id.taxes_id.compute_all( amount, self.pricelist_id.currency_id, 1, product=config.delivery_product_id, partner=self.partner_id ) self.write({'lines': [(0, 0, { 'product_id': config.delivery_product_id.id, 'full_product_name': config.delivery_product_id.name, 'price_unit': amount, 'qty': 1, 'tax_ids': [(6, 0, config.delivery_product_id.taxes_id.ids)], 'price_subtotal': taxes['total_excluded'], 'price_subtotal_incl': taxes['total_included'], })]}) def action_cancel_uber_delivery(self): config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) for order in self: delivery_id = order.uber_delivery_id if not delivery_id: continue # Attempt to cancel via Uber Direct API if config and config.customer_id and not str(delivery_id).startswith('del_sandbox_'): try: access_token = config._get_access_token() cancel_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{delivery_id}/cancel" headers = {'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'} requests.post(cancel_url, headers=headers, timeout=5) _logger.info("Uber Delivery %s cancelled via API for order %s", delivery_id, order.name) except Exception as e: _logger.warning("Could not cancel Uber Delivery %s via API: %s", delivery_id, str(e)) order.write({ 'uber_status': 'cancelled', 'uber_delivery_id': False, 'is_uber_order': False, 'uber_tracking_url': False, 'uber_eta': False }) self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': 'cancelled' }) def action_sync_uber_status(self): """Fetch latest status from Uber API and update POS order""" config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) if not config: return access_token = False if config.customer_id: try: access_token = config._get_access_token() except Exception as e: if config.environment == 'sandbox': _logger.warning("Sandbox Mode: Token retrieval failed during sync: %s", str(e)) else: _logger.error("Uber Sync Token Error: %s", str(e)) return headers = {'Authorization': f'Bearer {access_token}'} if access_token else {} status_map = { 'pending': 'pending', 'created': 'pending', 'pickup': 'pickup', 'en_route_to_pickup': 'pickup', 'arrived_at_pickup': 'pickup', 'pickup_complete': 'delivering', 'pickup_completed': 'delivering', 'en_route_to_dropoff': 'delivering', 'arrived_at_dropoff': 'delivering', 'delivering': 'delivering', 'delivery_complete': 'delivered', 'dropoff_completed': 'delivered', 'delivered': 'delivered', 'completed': 'delivered', 'canceled': 'cancelled', 'cancelled': 'cancelled', 'returned': 'cancelled', } for order in self: if not order.uber_delivery_id: continue # Sandbox simulation progression if str(order.uber_delivery_id).startswith('del_sandbox_') or config.environment == 'sandbox': if order.uber_request_time: diff_min = (fields.Datetime.now() - order.uber_request_time).total_seconds() / 60.0 sim_status = order.uber_status if diff_min > 5 and order.uber_status in ['pending', 'pickup', 'delivering']: sim_status = 'delivered' elif diff_min > 2 and order.uber_status == 'pending': sim_status = 'pickup' if sim_status != order.uber_status: order.write({'uber_status': sim_status, 'uber_alert_triggered': False}) self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': sim_status }) continue if not access_token or not config.customer_id: continue try: api_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{order.uber_delivery_id}" response = requests.get(api_url, headers=headers, timeout=5) if response.status_code == 200: data = response.json() _logger.info("Uber Status Raw Data for %s: %s", order.name, data) raw_status = data.get('status') new_status = status_map.get(raw_status, order.uber_status) vals = {'uber_status': new_status} if new_status != 'pending': vals['uber_alert_triggered'] = False if data.get('tracking_url'): vals['uber_tracking_url'] = data.get('tracking_url') if new_status != order.uber_status: # Send signal to UI to refresh the order form self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { 'order_id': order.id, 'new_status': new_status }) order.write(vals) _logger.info("Uber Status Synced for %s: %s", order.name, new_status) except Exception as e: _logger.error("Failed to sync Uber status for %s: %s", order.name, str(e)) @api.model def cron_check_uber_driver_assignment(self): """Auto-alert and status update cron""" config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) if not config: return # 1. Sync status for all active orders try: active_orders = self.search([('uber_status', 'in', ['pending', 'pickup', 'delivering'])]) if active_orders: active_orders.action_sync_uber_status() except Exception as e: _logger.warning("Uber Cron: Active orders sync skipped or failed: %s", str(e)) # 2. Trigger alerts for those still stuck in pending if config.timeout_minutes > 0: timeout_threshold = fields.Datetime.now() - datetime.timedelta(minutes=config.timeout_minutes) pending_orders = self.search([ ('uber_status', '=', 'pending'), ('uber_request_time', '<=', timeout_threshold), ('uber_alert_triggered', '=', False) ]) for order in pending_orders: order.uber_alert_triggered = True self.env['bus.bus']._sendone('pos_alerts', 'uber_timeout', { 'order_id': order.id, 'order_name': order.pos_reference or order.name, 'minutes': config.timeout_minutes }) def action_view_uber_map(self): """Open custom Odoo delivery tracking page with map and auto-filled pickup/dropoff addresses.""" self.ensure_one() # Our custom page works even in sandbox mode and always shows both locations return { 'type': 'ir.actions.act_url', 'url': f'/uber/track/pos/{self.id}', 'target': 'new', }