500 lines
24 KiB
Python
500 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 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',
|
|
}
|