501 lines
24 KiB
Python

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 Uber Live Tracking Link"""
self.ensure_one()
if not self.uber_tracking_url:
raise UserError(_("No tracking link available yet."))
return {
'type': 'ir.actions.act_url',
'url': self.uber_tracking_url,
'target': 'new',
}