496 lines
24 KiB
Python
496 lines
24 KiB
Python
import logging
|
|
import uuid
|
|
import json
|
|
|
|
from odoo import http
|
|
from odoo.fields import Command, Domain
|
|
from odoo.http import request
|
|
from odoo.tools import consteq
|
|
from odoo.tools.json import scriptsafe as json
|
|
from odoo.addons.pos_urban_piper import const
|
|
from ...pos_enterprise.models.data_validator import object_of, list_of
|
|
|
|
from werkzeug import exceptions
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
order_data_schema = object_of({
|
|
'order': object_of({
|
|
'items': list_of(object_of({
|
|
'title': True,
|
|
'price': True,
|
|
'merchant_id': True,
|
|
'quantity': True,
|
|
})),
|
|
'details': object_of({
|
|
'order_subtotal': True,
|
|
'total_taxes': True,
|
|
'order_state': True,
|
|
'channel': True,
|
|
'id': True,
|
|
}),
|
|
'payment': True,
|
|
'store': object_of({
|
|
'merchant_ref_id': True,
|
|
}),
|
|
}),
|
|
'customer': object_of({
|
|
'name': True,
|
|
'email': True,
|
|
'phone': True,
|
|
'address': True,
|
|
}),
|
|
})
|
|
|
|
order_status_update_schema = object_of({
|
|
'order_id': True,
|
|
'new_state': True,
|
|
'store_id': True,
|
|
})
|
|
|
|
rider_status_update_schema = object_of({
|
|
'delivery_info': object_of({
|
|
'current_state': True,
|
|
'delivery_person_details': object_of({
|
|
'name': True,
|
|
'phone': True,
|
|
}),
|
|
}),
|
|
'order_id': True,
|
|
'store': object_of({
|
|
'ref_id': True,
|
|
}),
|
|
})
|
|
|
|
store_action_schema = object_of({
|
|
'status': True,
|
|
'platform': True,
|
|
'action': True,
|
|
'location_ref_id': True,
|
|
})
|
|
|
|
|
|
class PosUrbanPiperController(http.Controller):
|
|
|
|
@http.route('/urbanpiper/webhook/<string:event_type>', type='jsonrpc', methods=['POST'], auth='public')
|
|
def webhook(self, event_type):
|
|
if not consteq(request.httprequest.headers.get('X-Urbanpiper-Uuid'), request.env['ir.config_parameter'].sudo().get_param('pos_urban_piper.uuid')):
|
|
# Ignore request if it's not from the same database
|
|
request.env['pos.config'].log_xml(
|
|
"Rejected Webhook: X-Urbanpiper-Uuid: %s" % (request.httprequest.headers.get('X-Urbanpiper-Uuid')),
|
|
'urbanpiper_webhook_%s' % event_type,
|
|
'Urbanpiper Webhook - %s' % event_type
|
|
)
|
|
return
|
|
data = request.get_json_data()
|
|
# request data for order_placed and rider_status_update are already stored in the database
|
|
if event_type not in ['order_placed', 'rider_status_update']:
|
|
request.env['pos.config'].log_xml(
|
|
"UrbanPiper Webhook Received: %s" % json.dumps(data),
|
|
'urbanpiper_webhook_%s' % event_type,
|
|
'Urbanpiper Webhook - %s' % event_type
|
|
)
|
|
if event_type == 'order_placed':
|
|
self._handle_data(data, order_data_schema, self._create_order, event_type)
|
|
elif event_type == 'order_status_update':
|
|
self._handle_data(data, order_status_update_schema, self._order_status_update, event_type)
|
|
elif event_type == 'rider_status_update':
|
|
self._handle_data(data, rider_status_update_schema, self._rider_status_update, event_type)
|
|
elif event_type == 'store_action':
|
|
self._handle_data(data, store_action_schema, self._store_action, event_type)
|
|
|
|
def _handle_data(self, data, schema, handler, event_type):
|
|
is_valid, error = schema(data)
|
|
pos_config = request.env['pos.config']
|
|
if is_valid:
|
|
if event_type == 'order_placed':
|
|
pos_config_sudo = request.env['pos.config'].sudo().search([
|
|
('urbanpiper_store_identifier', '=', data['order']['store']['merchant_ref_id'])
|
|
])
|
|
if not pos_config_sudo:
|
|
_logger.warning("UrbanPiper: Store not found for order %r", data['order'].get('id'))
|
|
pos_config.log_xml(
|
|
"UrbanPiper: Store not found - %s" % data,
|
|
'urbanpiper_webhook_store_not_found',
|
|
'Urbanpiper Store Error'
|
|
)
|
|
return exceptions.BadRequest()
|
|
if not pos_config_sudo.current_session_id:
|
|
_logger.warning("UrbanPiper: Session is not open for %r", pos_config_sudo.name)
|
|
pos_config.log_xml(
|
|
"UrbanPiper: Session not open - %s" % data,
|
|
'urbanpiper_webhook_session_not_open',
|
|
'Urbanpiper Session Error'
|
|
)
|
|
return exceptions.BadRequest()
|
|
handler(data)
|
|
else:
|
|
pos_config.log_xml(
|
|
"Payload - %s. Error - %s" % (data, error),
|
|
'urbanpiper_webhook_%s' % event_type,
|
|
'Urbanpiper Payload Validation Error'
|
|
)
|
|
_logger.warning("UrbanPiper: %r", error)
|
|
|
|
def reframe_notes(self, notes):
|
|
notes_array = []
|
|
for note_name in notes.split("\n"):
|
|
if note_name := note_name.strip():
|
|
default_note = request.env['pos.note'].sudo().search([('name', '=', note_name)])
|
|
notes_array.append({"text": note_name, "colorIndex": default_note["color"] if default_note else 0})
|
|
return json.dumps(notes_array)
|
|
|
|
def _prepare_price_unit_from_baseline(self, total_tax, price_unit, product, pos_config_sudo):
|
|
base_line = total_tax._prepare_base_line_for_taxes_computation(
|
|
request.env['pos.order.line'],
|
|
currency_id=pos_config_sudo.company_id.currency_id,
|
|
tax_ids=total_tax,
|
|
price_unit=price_unit,
|
|
quantity=1,
|
|
special_mode="total_included",
|
|
product_id=product
|
|
)
|
|
total_tax._add_tax_details_in_base_line(base_line, pos_config_sudo.company_id)
|
|
total_tax._round_base_lines_tax_details([base_line], pos_config_sudo.company_id)
|
|
tax_types = total_tax.flatten_taxes_hierarchy().mapped('price_include')
|
|
if len(set(tax_types)) > 1:
|
|
_logger.warning("UrbanPiper: Multiple tax types found for product %s. Using the first one.", product.name)
|
|
price_unit = base_line['tax_details']['total_included'] if tax_types and tax_types[0] else base_line['tax_details']['total_excluded']
|
|
return price_unit
|
|
|
|
def _create_order(self, data):
|
|
order = data['order']
|
|
customer = data['customer']
|
|
customer_address = customer['address']
|
|
details = order['details']
|
|
PosOrder = request.env['pos.order'].sudo()
|
|
existing_order = PosOrder.search([
|
|
('delivery_identifier', '=', details['id']),
|
|
])
|
|
if existing_order:
|
|
_logger.info("UrbanPiper: Order %r already exists.", details['id'])
|
|
return False
|
|
pos_config_sudo = request.env['pos.config'].sudo().search([
|
|
('urbanpiper_store_identifier', '=', order['store']['merchant_ref_id'])
|
|
])
|
|
# Check the customer exists with the same name and phone number
|
|
customer_sudo = request.env['res.partner'].sudo().search(
|
|
[('name', '=', customer['name']), ('phone', '=', customer['phone'])], limit=1
|
|
)
|
|
if not customer_sudo:
|
|
customer_sudo = request.env['res.partner'].sudo().create({
|
|
'name': customer['name'],
|
|
'phone': customer['phone'],
|
|
'email': customer['email'],
|
|
'street': customer_address.get('line_1'),
|
|
'street2': customer_address.get('line_2'),
|
|
'city': customer_address.get('city'),
|
|
'zip': customer_address.get('pin'),
|
|
})
|
|
else:
|
|
customer_sudo.write({
|
|
'email': customer['email'],
|
|
'street': customer_address.get('line_1'),
|
|
'street2': customer_address.get('line_2'),
|
|
'zip': customer_address.get('pin'),
|
|
'city': customer_address.get('city'),
|
|
})
|
|
|
|
def get_prep_time(details):
|
|
data = details.get('prep_time')
|
|
if data:
|
|
return data.get('estimated') or data.get('max')
|
|
|
|
pos_delivery_provider = request.env['pos.delivery.provider'].sudo().search([('technical_name', '=', details['channel'])], limit=1)
|
|
if not pos_delivery_provider:
|
|
_logger.warning("UrbanPiper: Delivery provider not found for %r", details['channel'])
|
|
pos_config_sudo.log_xml(
|
|
"UrbanPiper: Delivery provider not found - %s" % data,
|
|
'urbanpiper_webhook_delivery_provider_not_found',
|
|
'Urbanpiper Delivery Provider Error'
|
|
)
|
|
return exceptions.BadRequest()
|
|
|
|
lines = [self._create_order_line(line, pos_config_sudo) for line in order['items']]
|
|
for charge in details.get('charges', []):
|
|
charge_title = charge.get('title', '').lower()
|
|
charge_product = request.env.ref('pos_urban_piper.product_other_charges', False)
|
|
if 'delivery' in charge_title:
|
|
charge_product = request.env.ref('pos_urban_piper.product_delivery_charges', False)
|
|
elif 'packaging' in charge_title:
|
|
charge_product = request.env.ref('pos_urban_piper.product_packaging_charges', False)
|
|
if not charge_product:
|
|
_logger.warning("UrbanPiper: Charge product not found for %r", charge_title)
|
|
pos_config_sudo.log_xml(
|
|
"UrbanPiper: Charge product not found - %s" % data,
|
|
'urbanpiper_charge_product_not_found',
|
|
'Urbanpiper Charge Product Error'
|
|
)
|
|
continue
|
|
line_tax = request.env["account.tax"]
|
|
domain = request.env['account.tax']._check_company_domain(pos_config_sudo.company_id)
|
|
total_tax = charge_product.sudo().taxes_id.filtered_domain(domain)
|
|
price_unit = charge.get('value')
|
|
if charge.get("taxes", []):
|
|
tax_ids = []
|
|
charge_value = charge.get("value")
|
|
for tax_payload in charge.get("taxes", []):
|
|
if not tax_payload.get("value"):
|
|
continue
|
|
tax_rate = int((100 * tax_payload["value"]) / charge_value)
|
|
tax_domain = self._get_tax_domain(pos_config_sudo, tax_rate)
|
|
tax_record = request.env["account.tax"].sudo().search(tax_domain, limit=1)
|
|
if tax_record:
|
|
tax_ids.append(tax_record.id)
|
|
line_tax = request.env["account.tax"].browse(tax_ids)
|
|
elif total_tax:
|
|
price_unit = self._prepare_price_unit_from_baseline(total_tax, price_unit, charge_product, pos_config_sudo)
|
|
line_tax = total_tax
|
|
tax_ids_after_fiscal_position = pos_config_sudo.urbanpiper_fiscal_position_id.map_tax(line_tax)
|
|
taxes = tax_ids_after_fiscal_position.compute_all(price_unit, pos_config_sudo.company_id.currency_id, 1, product=charge_product)
|
|
lines.append(Command.create({
|
|
'product_id': charge_product.sudo().id,
|
|
'full_product_name': charge.get('title', charge_product.sudo().name),
|
|
'qty': 1,
|
|
'price_unit': price_unit,
|
|
'tax_ids': [Command.set(line_tax.ids)],
|
|
'price_subtotal': taxes['total_excluded'],
|
|
'price_subtotal_incl': taxes['total_included'],
|
|
'note': self.reframe_notes(charge.get('title')),
|
|
'uuid': str(uuid.uuid4())
|
|
}))
|
|
pos_reference, tracking_number = pos_config_sudo._get_next_order_refs()
|
|
discounts = details.get('ext_platforms', [{}])[0].get('discounts', [])
|
|
general_note = "\n".join([
|
|
f"Aggregator Discount: {pos_config_sudo.company_id.currency_id.symbol} {discount.get('value')}"
|
|
for discount in discounts if not discount.get('is_merchant_discount')
|
|
])
|
|
delivery_order = PosOrder.create({
|
|
'partner_id': customer_sudo.id,
|
|
'pos_reference': pos_reference,
|
|
'tracking_number': tracking_number,
|
|
'config_id': pos_config_sudo.id,
|
|
'session_id': pos_config_sudo.current_session_id.id,
|
|
'company_id': pos_config_sudo.company_id.id,
|
|
'fiscal_position_id': pos_config_sudo.urbanpiper_fiscal_position_id.id,
|
|
'lines': lines,
|
|
'amount_paid': 0.0, #calculation is done below
|
|
'amount_total': 0.0,
|
|
'amount_tax': 0.0,
|
|
'amount_return': 0.0,
|
|
'delivery_identifier': details['id'],
|
|
'delivery_status': details['order_state'].lower(),
|
|
'general_customer_note': "\n".join(
|
|
x for x in [details.get('instructions'), general_note] if x and x.strip()
|
|
),
|
|
'delivery_provider_id': pos_delivery_provider.id,
|
|
'prep_time': get_prep_time(details),
|
|
'delivery_json': json.dumps(data),
|
|
'user_id': pos_config_sudo.current_session_id.user_id.id,
|
|
'uuid': str(uuid.uuid4()),
|
|
})
|
|
for discount in discounts:
|
|
if not discount.get('is_merchant_discount'):
|
|
continue
|
|
discount_product = pos_config_sudo.discount_product_id or request.env.ref('pos_discount.product_product_consumable', False) or request.env['product.product'].sudo().search([('default_code', '=', 'DISC')], limit=1)
|
|
if not discount_product:
|
|
discount_product = request.env['product.product'].sudo().create({
|
|
'name': 'Discount',
|
|
'type': 'consu',
|
|
'list_price': 0,
|
|
'taxes_id': [(5, 0, 0)],
|
|
'default_code': 'DISC'
|
|
})
|
|
discountable_lines = delivery_order.lines.filtered(
|
|
lambda line: line.product_id not in pos_config_sudo.get_urbanpiper_special_products()
|
|
)
|
|
AccountTax = request.env['account.tax'].sudo()
|
|
base_lines = []
|
|
for line in discountable_lines:
|
|
base_line = AccountTax._prepare_base_line_for_taxes_computation(
|
|
line,
|
|
currency_id=pos_config_sudo.currency_id,
|
|
tax_ids=pos_config_sudo.urbanpiper_fiscal_position_id.map_tax(line.tax_ids) if pos_config_sudo.urbanpiper_fiscal_position_id else line.tax_ids,
|
|
price_unit=line.price_unit,
|
|
quantity=line.qty,
|
|
product_id=line.product_id,
|
|
discount=line.discount,
|
|
)
|
|
base_lines.append(base_line)
|
|
AccountTax._add_tax_details_in_base_lines(base_lines, pos_config_sudo.company_id)
|
|
AccountTax._round_base_lines_tax_details(base_lines, pos_config_sudo.company_id)
|
|
|
|
def grouping_function(base_line):
|
|
return {
|
|
'product_id': discount_product.sudo(),
|
|
}
|
|
|
|
discount_base_lines = AccountTax._prepare_global_discount_lines(
|
|
base_lines=base_lines,
|
|
company=pos_config_sudo.company_id,
|
|
amount_type='fixed',
|
|
amount=discount.get('value'),
|
|
computation_key=f'global_discount,{delivery_order.id}',
|
|
grouping_function=grouping_function,
|
|
)
|
|
for discount_base_line in discount_base_lines:
|
|
request.env['pos.order.line'].sudo().create({
|
|
'order_id': delivery_order.id,
|
|
'product_id': discount_product.sudo().id,
|
|
'price_unit': discount_base_line['price_unit'],
|
|
'price_subtotal': discount_base_line['tax_details']['total_excluded_currency'],
|
|
'price_subtotal_incl': discount_base_line['tax_details']['total_included_currency'],
|
|
'qty': discount_base_line['quantity'],
|
|
'tax_ids': discount_base_line['tax_ids'],
|
|
'extra_tax_data': AccountTax._export_base_line_extra_tax_data(discount_base_line),
|
|
'note': self.reframe_notes('\n'.join([discount.get('code', discount.get('title', ''))])),
|
|
})
|
|
delivery_order._compute_prices()
|
|
self.after_delivery_order_create(delivery_order, details, pos_config_sudo)
|
|
pos_config_sudo._send_delivery_order_count(delivery_order.id)
|
|
|
|
def after_delivery_order_create(self, delivery_order, details, pos_config_sudo):
|
|
pass
|
|
|
|
def _get_tax_value(self, taxes_data, pos_config):
|
|
"""
|
|
Override in delivery provider modules.
|
|
"""
|
|
taxes = request.env['account.tax']
|
|
for tax_line in taxes_data:
|
|
taxes |= request.env['account.tax'].sudo().search([
|
|
('tax_group_id.name', '=', tax_line.get('title')),
|
|
('amount', '=', tax_line.get('rate'))
|
|
], limit=1)
|
|
return taxes
|
|
|
|
def _get_tax_domain(self, pos_config, tax_percentage):
|
|
return Domain('company_id', '=', pos_config.company_id.id) & Domain('amount', '=', tax_percentage)
|
|
|
|
def _create_order_line(self, line_data, pos_config_sudo):
|
|
value_ids_lst = []
|
|
note = ''
|
|
if line_data.get('options_to_add'):
|
|
note = '\n'.join([
|
|
f"{option.get('title')} X {option.get('quantity')}"
|
|
for option in line_data['options_to_add']
|
|
if int(option.get('quantity', 0)) > 1
|
|
])
|
|
merchant_value_lst = [option.get('merchant_id') for option in line_data['options_to_add']]
|
|
value_ids_lst = [int(vid.split('-')[1]) for vid in merchant_value_lst]
|
|
price_extra = sum(option.get('total_price', 0) for option in line_data.get('options_to_add', []))
|
|
attribute_value_ids = []
|
|
values_to_remove = []
|
|
if value_ids_lst:
|
|
for value in value_ids_lst:
|
|
value_id = request.env['product.attribute.value'].sudo().browse(value)
|
|
product_option = request.env['product.template.attribute.value'].sudo().search([
|
|
('product_tmpl_id', '=', int(line_data['merchant_id'].split('-')[0])),
|
|
('product_attribute_value_id', '=', value),
|
|
('ptav_active', '=', True),
|
|
], limit=1)
|
|
if product_option:
|
|
attribute_value_ids.append(product_option.id)
|
|
if value_id.attribute_id.create_variant == 'no_variant':
|
|
values_to_remove.append(value)
|
|
variant_value_lst = [value for value in value_ids_lst if value not in values_to_remove]
|
|
line_taxes = request.env['account.tax']
|
|
main_product = self._product_template_to_product_variant(int(line_data['merchant_id'].split('-')[0]), variant_value_lst)
|
|
price_unit = float(line_data['price'] + price_extra)
|
|
tax_ids = main_product.taxes_id.filtered_domain(request.env['account.tax']._check_company_domain(pos_config_sudo.company_id))
|
|
tax_types = tax_ids.flatten_taxes_hierarchy().mapped('price_include')
|
|
line_qty = int(line_data['quantity'])
|
|
if len(set(tax_types)) > 1:
|
|
_logger.warning("UrbanPiper: Multiple tax types found for product %s. Using the first one.", main_product.name)
|
|
if line_data.get('taxes'):
|
|
if tax_types and len(set(tax_types)) >= 1 and tax_types[0]:
|
|
tax_value_sum = sum(tax.get('value', 0) for tax in line_data['taxes'])
|
|
price_unit = float((line_data['price'] + price_extra) + (tax_value_sum / line_qty))
|
|
line_taxes = tax_ids
|
|
elif tax_ids:
|
|
price_unit = self._prepare_price_unit_from_baseline(tax_ids, price_unit, main_product, pos_config_sudo)
|
|
line_taxes = tax_ids
|
|
tax_ids_after_fiscal_position = pos_config_sudo.urbanpiper_fiscal_position_id.map_tax(line_taxes)
|
|
taxes = tax_ids_after_fiscal_position.compute_all(price_unit, pos_config_sudo.company_id.currency_id, line_qty, product=main_product)
|
|
|
|
line_discounts = line_data.get('discounts', [])
|
|
non_merchant_note = "\n".join([
|
|
f"Aggregator Discount: {pos_config_sudo.company_id.currency_id.symbol} {discount.get('value')}"
|
|
for discount in line_discounts if not discount.get('is_merchant_discount')
|
|
])
|
|
|
|
merchant_discount_value = sum(d.get('value', 0) for d in line_discounts if d.get('is_merchant_discount'))
|
|
|
|
# in case if there is no total_included amount to avoid division by zero
|
|
discount_percentage = 0
|
|
if merchant_discount_value and taxes['total_included']:
|
|
discount_percentage = pos_config_sudo.company_id.currency_id.round(
|
|
(merchant_discount_value / taxes['total_included']) * 100
|
|
)
|
|
|
|
lines = Command.create({
|
|
'product_id': main_product.id,
|
|
'full_product_name': line_data['title'],
|
|
'qty': line_qty,
|
|
'attribute_value_ids': attribute_value_ids,
|
|
'price_extra': price_extra,
|
|
'price_unit': price_unit,
|
|
'price_subtotal': taxes['total_excluded'],
|
|
'price_subtotal_incl': taxes['total_included'],
|
|
'discount': discount_percentage,
|
|
'tax_ids': [Command.set(line_taxes.ids)] if line_taxes else None,
|
|
'note': self.reframe_notes("\n".join(
|
|
x for x in [line_data.get('instructions'), note, non_merchant_note] if x and x.strip()
|
|
)),
|
|
'uuid': str(uuid.uuid4()),
|
|
})
|
|
return lines
|
|
|
|
def _product_template_to_product_variant(self, tmpl_id, value_ids):
|
|
products = request.env['product.product'].sudo().search([('product_tmpl_id', '=', tmpl_id)])
|
|
if products:
|
|
if not value_ids:
|
|
return products[0]
|
|
if len(products) == 1:
|
|
return products[0]
|
|
product = products.filtered(
|
|
lambda l: sorted(l.product_template_variant_value_ids.product_attribute_value_id.ids) == sorted(value_ids)
|
|
)
|
|
return product
|
|
|
|
def _order_status_update(self, data):
|
|
def _get_status_seq(status):
|
|
return const.ORDER_STATUS_MAPPING[status][0]
|
|
current_order_id = request.env['pos.order'].sudo().search([('delivery_identifier', '=', str(data['order_id']))])
|
|
if not current_order_id:
|
|
_logger.warning("UrbanPiper: Order %r not found for update the status.", data['order_id'])
|
|
return
|
|
if _get_status_seq(current_order_id.delivery_status.replace('_', ' ').title()) < _get_status_seq(data['new_state']):
|
|
current_order_id.delivery_status = const.ORDER_STATUS_MAPPING[data['new_state']][1]
|
|
pos_config_sudo = request.env['pos.config'].sudo().search([
|
|
('urbanpiper_store_identifier', '=', data['store_id'])
|
|
])
|
|
if current_order_id.state == 'draft':
|
|
if current_order_id.delivery_status in ('food_ready', 'dispatched', 'completed'):
|
|
pos_config_sudo._make_order_payment(current_order_id)
|
|
elif current_order_id.delivery_status == 'cancelled':
|
|
current_order_id.with_context(active_ids=current_order_id.ids).action_pos_order_cancel()
|
|
pos_config_sudo._send_delivery_order_count(current_order_id.id)
|
|
|
|
def _rider_status_update(self, data):
|
|
current_order_id = request.env['pos.order'].sudo().search([('delivery_identifier', '=', str(data['order_id']))])
|
|
if not current_order_id:
|
|
_logger.warning("UrbanPiper: Order %r not found for update the rider status.", data['order_id'])
|
|
return
|
|
current_order_id.delivery_rider_json = json.dumps(data['delivery_info'])
|
|
pos_config_sudo = request.env['pos.config'].sudo().search([
|
|
('urbanpiper_store_identifier', '=', data['store']['ref_id'])
|
|
])
|
|
pos_config_sudo._send_delivery_order_count()
|
|
|
|
def _store_action(self, data):
|
|
pos_config_sudo = request.env['pos.config'].sudo().search([
|
|
('urbanpiper_store_identifier', '=', data['location_ref_id'])
|
|
])
|
|
pos_config_sudo._store_action_update(data)
|