349 lines
17 KiB
Python

from odoo import http
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class UberWebhookController(http.Controller):
@http.route('/uber/webhook/delivery', type='http', auth='none', methods=['POST'], csrf=False)
def uber_delivery_webhook(self, **post):
"""Handle status updates from Uber Direct (standard REST JSON webhook)"""
try:
raw_body = request.httprequest.data
if isinstance(raw_body, bytes):
raw_body = raw_body.decode('utf-8')
data = json.loads(raw_body) if raw_body else {}
except Exception as e:
_logger.error("Failed to parse Uber Webhook JSON: %s", str(e))
return request.make_json_response({'status': 'error', 'message': 'Invalid JSON'}, status=400)
_logger.info("Uber Webhook Received: %s", json.dumps(data, indent=2))
# Extract delivery ID or order ID across various Uber webhook formats
uber_id = (
data.get('delivery_id') or
data.get('id') or
data.get('order_id') or
(data.get('data', {}).get('id') if isinstance(data.get('data'), dict) else False) or
(data.get('meta', {}).get('resource_id') if isinstance(data.get('meta'), dict) else False)
)
raw_status = (
data.get('status') or
(data.get('data', {}).get('status') if isinstance(data.get('data'), dict) else False) or
data.get('event_type')
)
if uber_id:
order = request.env['pos.order'].sudo().search([
'|', ('uber_delivery_id', '=', str(uber_id)), ('uber_order_id', '=', str(uber_id))
], limit=1)
if order:
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',
}
# Check for event_type format (e.g., 'deliveries.dropoff_completed')
if raw_status and '.' in str(raw_status):
raw_status = str(raw_status).split('.')[-1]
new_status = status_map.get(raw_status, order.uber_status)
vals = {'uber_status': new_status}
if new_status != 'pending':
vals['uber_alert_triggered'] = False
tracking_url = data.get('tracking_url') or (data.get('data', {}).get('tracking_url') if isinstance(data.get('data'), dict) else False)
if tracking_url:
vals['uber_tracking_url'] = tracking_url
order.write(vals)
# Broadcast live status update to web clients
request.env['bus.bus'].sudo()._sendone('uber_status_updates', 'status_changed', {
'order_id': order.id,
'new_status': new_status
})
_logger.info("Uber Webhook successfully updated order %s to %s", order.name, new_status)
return request.make_json_response({'status': 'success', 'order_id': order.id, 'new_status': new_status})
return request.make_json_response({'status': 'ignored'})
@http.route(['/uber/webhook/order', '/uber/webhook/eats/order'], type='http', auth='none', methods=['POST'], csrf=False)
def uber_eats_order_webhook(self, **post):
"""Handle incoming marketplace orders from Uber Eats"""
try:
raw_body = request.httprequest.data
if isinstance(raw_body, bytes):
raw_body = raw_body.decode('utf-8')
data = json.loads(raw_body) if raw_body else {}
except Exception as e:
_logger.error("Failed to parse Uber Eats Order JSON: %s", str(e))
return request.make_json_response({'status': 'error', 'message': 'Invalid JSON'}, status=400)
_logger.info("Uber Eats Order Webhook Received: %s", json.dumps(data, indent=2))
uber_order_id = data.get('id') or data.get('order_id') or data.get('display_id')
if not uber_order_id:
return request.make_json_response({'status': 'error', 'message': 'Missing order id'}, status=400)
# Idempotency check
existing_order = request.env['pos.order'].sudo().search([('uber_order_id', '=', str(uber_order_id))], limit=1)
if existing_order:
_logger.info("Uber Eats Order %s already exists as POS order %s", uber_order_id, existing_order.name)
return request.make_json_response({'status': 'exists', 'order_id': existing_order.id})
# 1. Find active POS Session
pos_config = request.env['pos.config'].sudo().search([('module_pos_restaurant', '=', True), ('active', '=', True)], limit=1)
if not pos_config:
pos_config = request.env['pos.config'].sudo().search([('active', '=', True)], limit=1)
session = request.env['pos.session'].sudo().search([
('config_id', '=', pos_config.id if pos_config else 0),
('state', '=', 'opened')
], limit=1) if pos_config else False
if not session:
session = request.env['pos.session'].sudo().search([('state', '=', 'opened')], limit=1)
if not session and pos_config:
# Check for latest session or auto-open
session = request.env['pos.session'].sudo().search([('config_id', '=', pos_config.id)], order='id desc', limit=1)
if not session or session.state == 'closed':
try:
admin_user = request.env.ref('base.user_admin', raise_if_not_found=False) or request.env['res.users'].sudo().search([], limit=1)
session = request.env['pos.session'].sudo().create({
'user_id': admin_user.id,
'config_id': pos_config.id
})
session.action_pos_session_open()
except Exception as e:
_logger.warning("Could not auto-open session for Uber Eats order: %s", str(e))
session = request.env['pos.session'].sudo().search([], order='id desc', limit=1)
if not session:
_logger.warning("No POS session available to inject Uber Eats order %s", uber_order_id)
return request.make_json_response({'status': 'error', 'message': 'No POS session available'}, status=503)
# 2. Extract Customer details
eater = data.get('eater', {}) or data.get('customer', {})
customer_name = f"{eater.get('first_name', '')} {eater.get('last_name', '')}".strip() or eater.get('name') or f"Uber Eats Customer ({uber_order_id})"
customer_phone = eater.get('phone') or eater.get('phone_number') or ''
delivery_info = data.get('delivery', {}) or data.get('dropoff', {}) or {}
location = delivery_info.get('location', {}) or delivery_info.get('address', {}) or {}
street = location.get('street_address') or location.get('street') or (location.get('street_address_lines', [''])[0] if isinstance(location.get('street_address_lines'), list) else '') or ''
city = location.get('city') or session.company_id.city or ''
zip_code = location.get('postal_code') or location.get('zip') or session.company_id.zip or ''
partner = request.env['res.partner'].sudo().search(['|', ('phone', '=', customer_phone), ('name', '=ilike', customer_name)], limit=1) if customer_phone else False
if not partner:
partner = request.env['res.partner'].sudo().create({
'name': customer_name,
'phone': customer_phone,
'street': street,
'city': city,
'zip': zip_code,
'company_id': session.company_id.id,
})
# 3. Parse Cart Items
cart = data.get('cart', {})
items = cart.get('items', []) or data.get('items', [])
lines_data = []
total_amount = 0.0
for item in items:
item_name = item.get('title') or item.get('name') or 'Uber Eats Item'
item_qty = float(item.get('quantity', 1))
# Price in cents or dollars
raw_price = item.get('price', {}).get('unit_price', {}).get('amount') if isinstance(item.get('price'), dict) else item.get('price', 0)
try:
price_unit = float(raw_price) / 100.0 if float(raw_price) > 100 else float(raw_price)
except Exception:
price_unit = 0.0
# Match product
product = request.env['product.product'].sudo().search([
('name', '=ilike', item_name),
('available_in_pos', '=', True)
], limit=1)
if not product:
product = request.env['product.product'].sudo().search([('name', '=ilike', item_name)], limit=1)
if not product:
# Fallback to restaurant generic product
product = request.env['product.product'].sudo().search([('is_kitchen_item', '=', True)], limit=1)
if not product:
product = session.config_id.delivery_product_id if hasattr(session.config_id, 'delivery_product_id') else False
if not product:
product = request.env['product.product'].sudo().search([], limit=1)
if product:
taxes = product.taxes_id.compute_all(price_unit, session.currency_id, item_qty, product=product, partner=partner)
subtotal = taxes['total_excluded']
subtotal_incl = taxes['total_included']
total_amount += subtotal_incl
special_instructions = item.get('special_instructions') or item.get('notes') or ''
lines_data.append((0, 0, {
'product_id': product.id,
'qty': item_qty,
'price_unit': price_unit,
'price_subtotal': subtotal,
'price_subtotal_incl': subtotal_incl,
'full_product_name': item_name,
'customer_note': f"Uber Eats: {special_instructions}" if special_instructions else "Uber Eats Order",
'preparation_status': 'waiting',
'tax_ids': [(6, 0, product.taxes_id.ids)],
}))
if not lines_data:
return request.make_json_response({'status': 'error', 'message': 'No items in order'}, status=400)
import datetime
uid = f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-{session.id}-{uber_order_id}"
pos_reference = f"Uber #{uber_order_id}"
# 4. Create POS Order
pos_order = request.env['pos.order'].sudo().create({
'name': pos_reference,
'session_id': session.id,
'company_id': session.company_id.id,
'partner_id': partner.id,
'pricelist_id': session.config_id.pricelist_id.id,
'pos_reference': pos_reference,
'lines': lines_data,
'amount_total': total_amount,
'amount_tax': 0.0,
'amount_paid': total_amount, # Marketplaces handle customer payment directly
'amount_return': 0.0,
'order_source': 'platform',
'fulfilment_type': 'delivery',
'is_uber_order': True,
'uber_order_id': str(uber_order_id),
'uber_status': 'pending',
'delivery_type': 'uber',
'delivery_partner_id': partner.id,
'delivery_street': street,
'delivery_city': city,
'delivery_zip': zip_code,
'delivery_phone': customer_phone,
'note': f"Uber Eats Order #{uber_order_id}",
'state': 'paid',
})
# Notify KDS of new kitchen lines
pos_order.lines.filtered(
lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service'
)._notify_kds()
# Notify POS via bus
channel = f"online_orders_{session.config_id.id}"
request.env['bus.bus'].sudo()._sendone(channel, 'new_online_order', {
'order_id': pos_order.id,
'order_name': pos_order.pos_reference,
'customer_name': partner.name,
'amount_total': pos_order.amount_total,
'items_count': len(pos_order.lines),
'source': 'Uber Eats',
})
_logger.info("Successfully created POS Order %s for Uber Eats Order %s", pos_order.name, uber_order_id)
return request.make_json_response({'status': 'success', 'order_id': pos_order.id, 'pos_reference': pos_reference})
class UberDeliveryController(http.Controller):
@http.route('/shop/uber/quote', type='json', auth='public', website=True, csrf=False)
def uber_quote(self, address_data, **post):
"""Get Uber quote for a website address with cleaned address formatting"""
order = request.website.sale_get_order()
if not order:
return {'success': False, 'error': 'No active order'}
config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1)
if not config:
return {'success': False, 'error': 'Uber not configured'}
company = request.website.company_id
# Build STRUCTURED pickup address (Object) mapping POS exactly
pickup_address = {
"street_address": [company.street or "Restaurant Location"],
"city": company.city or "",
"state": company.state_id.code if company.state_id else "",
"zip_code": company.zip or "",
"country": company.country_id.code if company.country_id else "CA"
}
# User entered address fields
street = (address_data.get('street') or '').strip()
street2 = (address_data.get('street2') or '').strip()
full_street = f"{street}, {street2}" if street2 else street
state_input = (address_data.get('state') or address_data.get('province') or '').split('(')[0].strip()
state_code = "ON"
if state_input:
state_record = request.env['res.country.state'].sudo().search([
('country_id.code', '=', company.country_id.code or 'CA'),
'|', ('name', '=ilike', state_input), ('code', '=ilike', state_input)
], limit=1)
if state_record:
state_code = state_record.code
elif len(state_input) == 2:
state_code = state_input.upper()
elif company.state_id:
state_code = company.state_id.code
zip_val = (address_data.get('zip') or address_data.get('postal_code') or company.zip or '').strip()
city_val = (address_data.get('city') or company.city or '').strip()
country_val = (address_data.get('country') or company.country_id.code or 'CA').strip()
# Build STRUCTURED dropoff address (Object)
dropoff_address = {
"street_address": [full_street] if full_street else [company.street or "Delivery Address"],
"city": city_val,
"state": state_code,
"zip_code": zip_val,
"country": country_val
}
# For logging, still create strings
p_str = f"{pickup_address['street_address'][0]}, {pickup_address['city']} {pickup_address['state']}"
d_str = f"{dropoff_address['street_address'][0]}, {dropoff_address['city']} {dropoff_address['state']}"
_logger.info("WEBSITE UBER QUOTE (STRUCTURED) -\nPickup: [%s]\nDropoff: [%s]", p_str, d_str)
# POS ENCODING: The POS sends these as JSON-encoded STRINGS
result = config.get_uber_quote(json.dumps(pickup_address), json.dumps(dropoff_address))
if result.get('success'):
order.sudo()._add_uber_delivery_fee(result['fee_amount'])
return {
'success': True,
'fee': result['fee_amount'],
'eta': result.get('estimated_arrival'),
}
else:
_logger.warning("Uber Quote Failed: %s", result.get('error'))
return result