400 lines
19 KiB
Python
400 lines
19 KiB
Python
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()
|
||
|