400 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()