349 lines
17 KiB
Python
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
|
|
|