from odoo import models, fields, api, _ from odoo.exceptions import UserError import logging import datetime import requests import json _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _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): """Force-include the Uber carrier to bypass Odoo's address/website filters.""" carriers = super()._get_available_carriers(**kwargs) if self.carrier_id and 'Uber' in (self.carrier_id.name or ''): if self.carrier_id not in carriers: carriers |= self.carrier_id return carriers def _add_uber_delivery_fee(self, amount): """Add the Uber delivery fee line to the sale order.""" _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) Carrier = self.env['delivery.carrier'].sudo() config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) carrier = Carrier.search(['|', ('name', 'ilike', 'Uber'), ('product_id', '=', config.delivery_product_id.id if config and config.delivery_product_id else 0)], limit=1) if not carrier and config: product = config.delivery_product_id or \ self.env['product.product'].sudo().search([('name', 'ilike', 'Delivery')], limit=1) or \ self.env['product.product'].sudo().search([], limit=1) carrier = Carrier.create({ 'name': 'Uber Direct Delivery', 'delivery_type': 'fixed', 'product_id': product.id, 'website_published': True, 'fixed_price': amount, 'active': True, }) if carrier: carrier.sudo().write({'website_published': True, 'active': True, 'fixed_price': amount}) self.order_line.filtered(lambda l: l.is_delivery).sudo().unlink() self.sudo().set_delivery_line(carrier, amount) self.sudo().write({'carrier_id': carrier.id}) delivery_line = self.order_line.filtered(lambda l: l.is_delivery) if delivery_line: delivery_line.sudo().write({ 'price_unit': amount, 'name': f'Uber Direct Delivery ({self.partner_id.city or "Local"})', }) 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()