1429 lines
60 KiB
Python
1429 lines
60 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
import pytz
|
|
import secrets
|
|
from werkzeug import urls
|
|
|
|
from odoo import Command, api, fields, models
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.service.model import PG_CONCURRENCY_EXCEPTIONS_TO_RETRY
|
|
from odoo.tools import split_every
|
|
|
|
from odoo.addons.sale_lazada import const, utils
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LazadaShop(models.Model):
|
|
_name = 'lazada.shop'
|
|
_description = "Lazada Shop"
|
|
_check_company_auto = True
|
|
|
|
# === BASIC FIELDS === #
|
|
name = fields.Char(string="Name", required=True, default="Unnamed Lazada Shop")
|
|
active = fields.Boolean(default=True, required=True)
|
|
country_id = fields.Many2one(string="Shop Region", comodel_name='res.country', readonly=True)
|
|
company_id = fields.Many2one(
|
|
string="Company",
|
|
comodel_name='res.company',
|
|
default=lambda self: self.env.company,
|
|
required=True,
|
|
readonly=True,
|
|
)
|
|
lazada_item_ids = fields.One2many(
|
|
string="Lazada Items", comodel_name='lazada.item', inverse_name='shop_id'
|
|
)
|
|
lazada_item_count = fields.Integer(string="Item Count", compute='_compute_lazada_item_count')
|
|
order_ids = fields.One2many(
|
|
string="Orders", comodel_name='sale.order', inverse_name='lazada_shop_id'
|
|
)
|
|
order_count = fields.Integer(string="Order Count", compute='_compute_order_count')
|
|
shop_extern_id = fields.Char(
|
|
string="Lazada Seller ID",
|
|
help="Your unique seller ID assigned by Lazada. This is automatically set during "
|
|
"authorization and ensures the same Lazada shop cannot be linked twice to the "
|
|
"same company.",
|
|
readonly=True,
|
|
required=True,
|
|
default='',
|
|
)
|
|
|
|
# === API & AUTHENTICATION FIELDS === #
|
|
api_endpoint = fields.Char(string="API Endpoint", compute='_compute_api_endpoint')
|
|
app_key = fields.Char(
|
|
help="Your Lazada application key (App Key). You can find this in your Lazada "
|
|
"Service Provider Center after creating an application. Enter this before "
|
|
"authorizing the connection.",
|
|
required=True,
|
|
)
|
|
app_secret = fields.Char(
|
|
help="Your Lazada application secret (App Secret). This is provided alongside your "
|
|
"App Key in the Lazada Service Provider Center. Keep this confidential "
|
|
"and enter it before authorizing the connection.",
|
|
required=True,
|
|
)
|
|
access_token = fields.Char(
|
|
help="Temporary access token provided by Lazada for API authentication. This token "
|
|
"expires after 4 hours and is automatically refreshed using the refresh token. "
|
|
"You don't need to manage this manually.",
|
|
readonly=True,
|
|
)
|
|
access_token_expiration_date = fields.Datetime(
|
|
help="The date and time when the current access token will expire. The system "
|
|
"automatically refreshes the token before expiration, so no action is required.",
|
|
readonly=True,
|
|
)
|
|
refresh_token = fields.Char(
|
|
help="Long-term refresh token used to automatically obtain new access tokens. This "
|
|
"token expires after 30 days. If it expires, you'll need to re-authorize the "
|
|
"connection by clicking the authorization button in the Lazada shop view.",
|
|
readonly=True,
|
|
)
|
|
refresh_token_expiration_date = fields.Datetime(
|
|
help="The date and time when the refresh token will expire. If this date passes, "
|
|
"you'll need to re-authorize the connection to continue synchronizing with Lazada.",
|
|
readonly=True,
|
|
)
|
|
|
|
# === CONFIGURATION FIELDS === #
|
|
user_id = fields.Many2one(
|
|
string="Default Salesperson",
|
|
help="The default Salesperson assigned to orders imported from this Lazada shop",
|
|
comodel_name='res.users',
|
|
default=lambda self: self.env.user,
|
|
check_company=True,
|
|
)
|
|
team_id = fields.Many2one(
|
|
string="Default Sales Team",
|
|
help="The default Sales Team assigned to orders imported from this Lazada shop",
|
|
comodel_name='crm.team',
|
|
check_company=True,
|
|
)
|
|
last_product_catalog_sync_date = fields.Datetime(
|
|
string="Last Catalog Synchronization",
|
|
help="Shows when the product catalog was last synchronized from Lazada to Odoo. "
|
|
"Only products updated in Lazada after this date will be fetched in the next "
|
|
"synchronization. This is automatically updated after each sync. "
|
|
"To re-initialize the full catalog, clear this value.",
|
|
)
|
|
last_orders_sync_date = fields.Datetime(
|
|
string="Last Order Synchronization Date",
|
|
help="Shows when orders were last synchronized from Lazada. Only orders that have "
|
|
"changed since this date will be imported or updated in Odoo.",
|
|
required=True,
|
|
default=fields.Datetime.now,
|
|
)
|
|
synchronize_inventory = fields.Boolean(
|
|
string="Allow inventory synchronization",
|
|
default=False,
|
|
help="Enable this to automatically sync your Odoo inventory quantities to Lazada. "
|
|
"When enabled, product stock levels in Odoo will be pushed to Lazada for "
|
|
"FBM (Fulfilled by Merchant) items. This ensures your Lazada listings show "
|
|
"accurate stock availability.",
|
|
)
|
|
manage_fbl_inventory = fields.Boolean(
|
|
string="Track FBL Inventory",
|
|
help="Enable this to track inventory for FBL (Fulfilled by Lazada) items in Odoo. "
|
|
"When enabled, stock in the FBL location will be deducted from your available "
|
|
"FBM stock. The FBL location is automatically created when you set up the shop.",
|
|
default=False,
|
|
)
|
|
fbl_location_id = fields.Many2one(
|
|
string="FBL Stock Location",
|
|
help="The stock location representing inventory managed by Lazada under the FBL "
|
|
"(Fulfilled by Lazada) program. This location is automatically created when "
|
|
"you set up the shop. Stock moves to this location represent items sent to "
|
|
"Lazada warehouses for fulfillment.",
|
|
comodel_name='stock.location',
|
|
domain="[('usage', '=', 'internal')]",
|
|
check_company=True,
|
|
)
|
|
fbm_warehouse_id = fields.Many2one(
|
|
string="FBM Warehouse",
|
|
help="The warehouse containing inventory that will be synchronized to Lazada for "
|
|
"FBM (Fulfilled by Merchant) orders. Select the warehouse where you store "
|
|
"products that you ship directly to customers. If not set, your company's "
|
|
"default warehouse will be used.",
|
|
comodel_name='stock.warehouse',
|
|
domain="[('company_id', '=', company_id)]",
|
|
required=True,
|
|
default=lambda self: self.env['stock.warehouse'].search(
|
|
[('company_id', '=', self.env.company.id)], limit=1
|
|
),
|
|
)
|
|
lazada_oauth_state = fields.Char(
|
|
string="Lazada OAuth State",
|
|
help="The state of the OAuth flow for this shop.",
|
|
copy=False,
|
|
)
|
|
|
|
_unique_shop_extern_id_company = models.Constraint(
|
|
'UNIQUE(shop_extern_id, company_id)',
|
|
"A shop with this external identifier already exists in this company. "
|
|
"The same Lazada shop cannot be linked twice to the same company.",
|
|
)
|
|
|
|
# === COMPUTE METHODS === #
|
|
|
|
@api.depends('lazada_item_ids')
|
|
def _compute_lazada_item_count(self):
|
|
"""Compute the count of Lazada items for this shop."""
|
|
for shop in self:
|
|
shop.lazada_item_count = len(shop.lazada_item_ids)
|
|
|
|
@api.depends('order_ids')
|
|
def _compute_order_count(self):
|
|
"""Compute the count of orders for this shop."""
|
|
for shop in self:
|
|
shop.order_count = len(shop.order_ids)
|
|
|
|
@api.depends('country_id')
|
|
def _compute_api_endpoint(self):
|
|
"""Compute the API endpoint based on the shop's country."""
|
|
for shop in self:
|
|
if not shop.country_id:
|
|
shop.api_endpoint = ''
|
|
continue
|
|
api_endpoint = const.API_ENDPOINTS.get(shop.country_id.code)
|
|
if api_endpoint:
|
|
shop.api_endpoint = api_endpoint
|
|
else:
|
|
raise UserError(
|
|
self.env._(
|
|
"The API Endpoint for the country %(country)s is not supported.",
|
|
country=shop.country_id.name,
|
|
)
|
|
)
|
|
|
|
# === CRUD METHODS === #
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""Create Lazada shops and configure FBL locations.
|
|
|
|
Each shop is automatically assigned a dedicated FBL (Fulfilled by Lazada) location.
|
|
"""
|
|
for vals in vals_list:
|
|
company_id = vals.get('company_id') or self.env.company.id
|
|
# Find or create the lazada warehouse to be associated with the shop
|
|
parent_warehouse = self.env['stock.warehouse'].search_read(
|
|
self.env['stock.warehouse']._check_company_domain(company_id),
|
|
['view_location_id'],
|
|
limit=1,
|
|
)
|
|
location_name = self.env._("Lazada FBL Stock - %(shop)s", shop=vals.get('name'))
|
|
if parent_warehouse:
|
|
location = self.env['stock.location'].search(
|
|
[
|
|
*self.env['stock.location']._check_company_domain(company_id),
|
|
('name', '=', location_name),
|
|
('location_id', '=', parent_warehouse[0]['view_location_id'][0]),
|
|
],
|
|
limit=1,
|
|
)
|
|
# Create FBL location if it doesn't exist
|
|
if not location:
|
|
location = self.env['stock.location'].create({
|
|
'name': location_name,
|
|
'usage': 'internal',
|
|
'location_id': parent_warehouse[0]['view_location_id'][0],
|
|
'company_id': company_id,
|
|
})
|
|
vals.update({'fbl_location_id': location.id})
|
|
|
|
return super().create(vals_list)
|
|
|
|
# === ACTION METHODS === #
|
|
|
|
def action_view_orders(self):
|
|
"""Open the list of orders for this shop."""
|
|
self.ensure_one()
|
|
action = self.env['ir.actions.act_window']._for_xml_id('sale.action_orders')
|
|
action['domain'] = [('lazada_shop_id', '=', self.id)]
|
|
action['context'] = dict(self.env.context, create=False)
|
|
return action
|
|
|
|
def action_sync_orders(self):
|
|
"""Manually sync orders for this shop."""
|
|
self.ensure_one()
|
|
self._sync_orders()
|
|
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
|
|
|
def action_view_lazada_items(self):
|
|
"""Open the list of Lazada items for this shop."""
|
|
self.ensure_one()
|
|
action = self.env['ir.actions.act_window']._for_xml_id(
|
|
'sale_lazada.action_lazada_item_list'
|
|
)
|
|
action['domain'] = [('shop_id', '=', self.id)]
|
|
action['context'] = dict(self.env.context, default_shop_id=self.id, create=False)
|
|
return action
|
|
|
|
def action_sync_inventory(self):
|
|
"""Manually sync inventory for this shop."""
|
|
self.ensure_one()
|
|
self._sync_inventory()
|
|
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
|
|
|
def action_sync_product_catalog(self):
|
|
"""Manually sync product catalog for this shop."""
|
|
self.ensure_one()
|
|
self._sync_product_catalog()
|
|
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
|
|
|
def action_archive(self):
|
|
"""Disconnect and archive the Lazada shop."""
|
|
self._reset_refresh_token()
|
|
return super().action_archive()
|
|
|
|
def action_open_auth_link(self):
|
|
"""Redirect to Lazada authorization page.
|
|
|
|
:return: Action dictionary for URL redirect
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not (self.app_key and self.app_secret):
|
|
raise UserError(self.env._("App Key and App Secret are required for authorization."))
|
|
|
|
auth_url = const.AUTH_URL
|
|
timestamp = int(fields.Datetime.now().timestamp())
|
|
sign = utils.get_public_sign(self, timestamp)
|
|
redirect_url = (
|
|
f'{self.get_base_url()}/lazada/return_from_authorization/{self.id}/{timestamp}/{sign}'
|
|
)
|
|
state = secrets.token_urlsafe(32)
|
|
self.lazada_oauth_state = state
|
|
params = {
|
|
'response_type': 'code',
|
|
'force_auth': 'true',
|
|
'redirect_uri': redirect_url,
|
|
'client_id': self.app_key,
|
|
'state': state,
|
|
}
|
|
|
|
# The url needs to be accessed to authorize the Lazada app. It will return to a callback URL
|
|
# with the authorization code needed to get the access and refresh tokens.
|
|
url = f'{auth_url}?{urls.url_encode(params)}'
|
|
return {'type': 'ir.actions.act_url', 'url': url, 'target': 'self'}
|
|
|
|
# === TOKEN MANAGEMENT === #
|
|
|
|
def _reset_refresh_token(self):
|
|
"""Reset refresh and access tokens for the shop."""
|
|
self.write({
|
|
'refresh_token': False,
|
|
'refresh_token_expiration_date': False,
|
|
'access_token': False,
|
|
'access_token_expiration_date': False,
|
|
})
|
|
|
|
# === INVENTORY & PRODUCT CATALOG SYNCHRONIZATION === #
|
|
|
|
def _sync_inventory(self, auto_commit=True):
|
|
"""Synchronize inventory from Odoo to Lazada.
|
|
|
|
Syncs all active shops if called on empty recordset.
|
|
|
|
:param bool auto_commit: Whether to commit after each successful sync
|
|
"""
|
|
shops = self or self.search([('active', '=', True)])
|
|
for shop in shops.filtered(lambda s: s.synchronize_inventory):
|
|
shop.lazada_item_ids._sync_inventory(auto_commit=auto_commit)
|
|
|
|
def _sync_product_catalog(self):
|
|
"""Synchronize Lazada product catalog to Odoo.
|
|
|
|
Syncs all active shops if called on empty recordset. If initialize is True,
|
|
fetches all products; otherwise only products updated since last sync.
|
|
"""
|
|
sync_start_dt = fields.Datetime.now()
|
|
shops = self or self.search([('active', '=', True)])
|
|
for shop in shops:
|
|
# If the sync date is empty, initialize the catalog (fetch all items)
|
|
initialize_shop = not shop.last_product_catalog_sync_date
|
|
time_from = shop.last_product_catalog_sync_date
|
|
time_to = sync_start_dt
|
|
offset = 0
|
|
total_products = False
|
|
while (
|
|
total_products is False or offset * const.PRODUCT_LIST_SIZE_LIMIT < total_products
|
|
):
|
|
if initialize_shop:
|
|
offset, new_total_products, lazada_products = shop._fetch_lazada_products(
|
|
offset=offset
|
|
)
|
|
else:
|
|
offset, new_total_products, lazada_products = shop._fetch_lazada_products(
|
|
time_from=time_from, time_to=time_to, offset=offset
|
|
)
|
|
|
|
if total_products is False:
|
|
total_products = new_total_products
|
|
elif new_total_products != total_products:
|
|
raise ValidationError(
|
|
self.env._(
|
|
"Failed to synchronize Lazada product catalog for shop: %(shop)d.\n"
|
|
"The total number of products in Lazada changed during"
|
|
" synchronization. Please re-run the synchronization.",
|
|
shop=shop.id,
|
|
)
|
|
)
|
|
|
|
for product in lazada_products:
|
|
product_status = (product.get('status') or '').lower()
|
|
for product_sku in product.get('skus'):
|
|
fulfillment_type = (
|
|
len(product_sku.get('fblWarehouseInventories', [])) and 'fbl'
|
|
) or 'fbm'
|
|
# Consider item inactive if either product or SKU status is not "active"
|
|
sku_status = (product_sku.get('Status') or '').lower()
|
|
is_active = all(
|
|
status in ('', 'active') for status in [product_status, sku_status]
|
|
)
|
|
lazada_item = shop._find_or_create_item(
|
|
product_sku['SellerSku'],
|
|
product_sku['SkuId'],
|
|
fulfillment_type=fulfillment_type,
|
|
no_create=not is_active,
|
|
)
|
|
# Disable inventory sync when Lazada item is inactive/deleted
|
|
if not is_active and lazada_item.sync_lazada_inventory:
|
|
lazada_item.sync_lazada_inventory &= is_active
|
|
|
|
shop.last_product_catalog_sync_date = sync_start_dt
|
|
|
|
def _fetch_lazada_products(self, time_from=None, time_to=None, offset=0):
|
|
"""Fetch products from Lazada API.
|
|
|
|
:param datetime time_from: Lower time limit for filtering products
|
|
:param datetime time_to: Upper time limit for filtering products
|
|
:param int offset: Page offset for pagination
|
|
:return: Tuple of (next_offset, total_products, products_list)
|
|
:rtype: tuple
|
|
"""
|
|
target_timezone = pytz.timezone(
|
|
pytz.country_timezones.get(self.country_id.code, ['UTC'])[0]
|
|
)
|
|
params = {
|
|
'offset': offset * const.PRODUCT_LIST_SIZE_LIMIT,
|
|
'limit': const.PRODUCT_LIST_SIZE_LIMIT,
|
|
'filter': 'all',
|
|
}
|
|
if time_from and time_to:
|
|
params.update({
|
|
'update_after': time_from.astimezone(target_timezone).isoformat(),
|
|
'update_before': time_to.astimezone(target_timezone).isoformat(),
|
|
})
|
|
response = utils.make_lazada_api_request('GetProducts', self, params=params)
|
|
total_products = response['data']['total_products'] if response.get('data') else 0
|
|
products = response['data']['products'] if response.get('data') else []
|
|
return offset + 1, total_products, products
|
|
|
|
# === ORDER SYNCHRONIZATION METHODS === #
|
|
|
|
def _sync_orders(self, auto_commit=True):
|
|
"""Synchronize orders updated in Lazada since last sync.
|
|
|
|
Syncs all active shops if called on empty recordset.
|
|
|
|
:param bool auto_commit: Whether to commit after each successful order sync
|
|
"""
|
|
shops = self or self.search([('active', '=', True)])
|
|
for shop in shops:
|
|
shop = shop.with_prefetch()
|
|
start_sync_dt = fields.Datetime.now()
|
|
try:
|
|
# Orders are pulled in batches of up to ORDER_LIST_SIZE_LIMIT orders. If more can be
|
|
# synchronized, the request results are paginated and the next page holds another
|
|
# batch.
|
|
while shop.last_orders_sync_date < start_sync_dt:
|
|
status_update_lower_limit = shop.last_orders_sync_date
|
|
status_update_upper_limit = min(
|
|
status_update_lower_limit + timedelta(days=const.ORDER_LIST_DAYS_LIMIT),
|
|
start_sync_dt,
|
|
)
|
|
orders_map = shop._fetch_order_list(
|
|
time_from=status_update_lower_limit, time_to=status_update_upper_limit
|
|
)
|
|
if not orders_map:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
continue
|
|
|
|
order_extern_id_list = list(orders_map.keys())
|
|
order_items_map = shop._fetch_orders_items(order_extern_id_list)
|
|
for order_id, order in orders_map.items():
|
|
order['order_items'] = order_items_map.get(int(order_id), [])
|
|
|
|
# Convert the maximum updated_at timestamp of the order items of
|
|
# each order to a timestamp at UTC timezone
|
|
for order in orders_map.values():
|
|
max_order_item_timestamp = max(
|
|
utils.lazada_timestamp_to_datetime(item.get('updated_at'))
|
|
for item in order['order_items']
|
|
)
|
|
order['last_order_item_updated_at'] = max_order_item_timestamp
|
|
|
|
# As we fetched these order items based on order ids, not their updated time,
|
|
# orders might have been updated later than the set upper limit. We discard
|
|
# them for now to avoid skipping the intermediary orders by setting a wrong
|
|
# last updated time as they'll be sync later anyway.
|
|
valid_orders_map = {
|
|
id: order
|
|
for id, order in orders_map.items()
|
|
if order['last_order_item_updated_at'] <= status_update_upper_limit
|
|
}
|
|
if not valid_orders_map:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
continue
|
|
|
|
for order_data in valid_orders_map.values():
|
|
shop._create_or_update_sale_order(order_data, auto_commit)
|
|
|
|
# The sync of this orders batch went through, save the last status update time
|
|
valid_update_times = [
|
|
order['last_order_item_updated_at'] for order in valid_orders_map.values()
|
|
]
|
|
if valid_update_times and len(order_items_map) == const.ORDER_LIST_SIZE_LIMIT:
|
|
shop.last_orders_sync_date = max(valid_update_times)
|
|
else:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
|
|
except utils.LazadaRateLimitError as error:
|
|
_logger.info(
|
|
"Rate limit reached while synchronizing sales orders for Lazada shop with"
|
|
" id %(shop)d. Operation: %(error_operation)s",
|
|
{'shop': shop.id, 'error_operation': error.operation},
|
|
)
|
|
continue # The remaining orders will be pulled in the next cron run.
|
|
|
|
def _fetch_order_list(self, time_from, time_to):
|
|
"""Fetch order list from Lazada API.
|
|
|
|
:param datetime time_from: Lower time limit
|
|
:param datetime time_to: Upper time limit
|
|
:return: Dict mapping order_id to order details
|
|
:rtype: dict
|
|
"""
|
|
target_timezone = pytz.timezone(
|
|
pytz.country_timezones.get(self.country_id.code, ['UTC'])[0]
|
|
)
|
|
|
|
response = utils.make_lazada_api_request(
|
|
'GetOrders',
|
|
self,
|
|
params={
|
|
'update_after': time_from.astimezone(target_timezone).isoformat(),
|
|
'update_before': time_to.astimezone(target_timezone).isoformat(),
|
|
'limit': const.ORDER_LIST_SIZE_LIMIT,
|
|
'offset': 0,
|
|
'sort_by': 'updated_at',
|
|
'sort_direction': 'ASC',
|
|
},
|
|
)
|
|
return {i['order_id']: i for i in response['data'].get('orders', [])}
|
|
|
|
def _fetch_orders_items(self, order_extern_id_list):
|
|
"""Fetch order items from Lazada API.
|
|
|
|
:param list order_extern_id_list: List of order identifiers
|
|
:return: Dict mapping order_id to order items
|
|
:rtype: dict
|
|
"""
|
|
order_items_map = {}
|
|
for batch in split_every(const.ORDER_DETAIL_SIZE_LIMIT, order_extern_id_list, list):
|
|
response = utils.make_lazada_api_request(
|
|
'GetMultipleOrderItems', self, params={'order_ids': str(batch)}
|
|
)
|
|
order_items_map.update({
|
|
item['order_id']: item['order_items'] for item in response['data']
|
|
})
|
|
|
|
return order_items_map
|
|
|
|
def _fetch_seller_information(self):
|
|
"""Fetch seller information from Lazada API.
|
|
|
|
:return: Seller data including seller_id, name
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
response = utils.make_lazada_api_request('GetSeller', self, params={})
|
|
return response['data']
|
|
|
|
def _create_or_update_sale_order(self, order_data, auto_commit=True):
|
|
"""Create or update sale order from Lazada order data.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:param bool auto_commit: Whether to commit after successful sync
|
|
"""
|
|
try:
|
|
if order_data['items_count'] == 0:
|
|
return
|
|
|
|
if auto_commit:
|
|
with self.env.cr.savepoint():
|
|
self._process_order_data(order_data)
|
|
else: # Avoid the savepoint in testing
|
|
self._process_order_data(order_data)
|
|
except utils.LazadaRateLimitError:
|
|
raise
|
|
except PG_CONCURRENCY_EXCEPTIONS_TO_RETRY as error:
|
|
_logger.info(
|
|
"A concurrency error happened while synchronizing order %(order_id)s, it will be"
|
|
" synchronized later. %(error)s",
|
|
{'order_id': order_data['order_id'], 'error': error},
|
|
)
|
|
except utils.LazadaApiError:
|
|
lazada_order_ref = order_data['order_id']
|
|
_logger.warning(
|
|
"A business error occurred while processing the order data"
|
|
" with lazada_order_ref %(order_ref)s for Lazada shop %(shop)s.",
|
|
{'order_ref': lazada_order_ref, 'shop': self.id},
|
|
exc_info=True,
|
|
)
|
|
# Dismiss business errors to allow the synchronization to skip the
|
|
# problematic orders.
|
|
# The order will then require creating the order manually.
|
|
self.env.cr.rollback()
|
|
self._handle_sync_failure(flow='order_sync', lazada_order_ref=lazada_order_ref)
|
|
if auto_commit:
|
|
self.env.cr.commit() # Commit to mitigate a potential cron kill.
|
|
|
|
def _process_order_data(self, order_data):
|
|
"""Process Lazada order data and create or update sale order.
|
|
|
|
Creates new orders if status is in ORDER_STATUSES_TO_SYNC, otherwise updates
|
|
existing order items.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:return: Created or updated sale order
|
|
:rtype: sale.order or None
|
|
"""
|
|
fulfillment_type = self._get_fulfillment_type(order_data)
|
|
if not fulfillment_type:
|
|
_logger.info(
|
|
"Ignored Lazada order with reference %(ref)s for Lazada shop with id %(id)s."
|
|
" Multiple fulfillment types in 1 order is not supported.",
|
|
{'ref': order_data['order_id'], 'id': self.id},
|
|
)
|
|
return None
|
|
|
|
lazada_order_ref = order_data['order_id']
|
|
order = self.env['sale.order'].search(
|
|
[('lazada_order_ref', '=', lazada_order_ref), ('lazada_shop_id', '=', self.id)], limit=1
|
|
)
|
|
|
|
if order:
|
|
self._update_order_from_data(order, order_data)
|
|
elif self._should_create_order(order_data, fulfillment_type):
|
|
order = self._create_order_from_data(order_data)
|
|
if order.lazada_fulfillment_type == 'fbl':
|
|
self._generate_stock_moves(order)
|
|
elif order.lazada_fulfillment_type == 'fbm':
|
|
order.with_context(mail_notrack=True).action_lock()
|
|
_logger.info(
|
|
"Created a new sales order with lazada_order_ref %(ref)s for Lazada shop"
|
|
" with id %(id)s.",
|
|
{'ref': lazada_order_ref, 'id': self.id},
|
|
)
|
|
return order
|
|
|
|
def _get_fulfillment_type(self, order_data):
|
|
"""Get fulfillment type from order data.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:return: 'fbm' or 'fbl'
|
|
:rtype: str
|
|
"""
|
|
fulfillment_type_list = list({
|
|
'fbl' if int(item['is_fbl']) else 'fbm' for item in order_data['order_items']
|
|
})
|
|
if len(fulfillment_type_list) == 1:
|
|
return fulfillment_type_list[0]
|
|
return None
|
|
|
|
def _should_create_order(self, order_data, fulfillment_type):
|
|
"""Determine if order should be created based on status.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:param str fulfillment_type: 'fbm' or 'fbl'
|
|
:return: True if order should be created
|
|
:rtype: bool
|
|
"""
|
|
# The provider type of DBS order is "seller_own_fleet", which is not supported for now.
|
|
provider_types = {item.get("shipping_provider_type") for item in order_data["order_items"]}
|
|
invalid_types = provider_types - const.SUPPORTED_SHIPPING_PROVIDER_TYPES
|
|
|
|
statuses = set(order_data["statuses"])
|
|
statuses_to_sync = const.ORDER_STATUSES_TO_SYNC.get(fulfillment_type, set())
|
|
invalid_statuses = statuses - statuses_to_sync
|
|
|
|
skip_reasons = []
|
|
if invalid_types:
|
|
skip_reasons.append(
|
|
f"- Unsupported shipping provider type(s): {', '.join(invalid_types)}."
|
|
f" Supported type(s): {', '.join(const.SUPPORTED_SHIPPING_PROVIDER_TYPES)}."
|
|
)
|
|
if invalid_statuses:
|
|
skip_reasons.append(
|
|
f"- Unsupported order status(es): {', '.join(invalid_statuses)}."
|
|
f" Expected status(es): {', '.join(statuses_to_sync)}."
|
|
)
|
|
if skip_reasons:
|
|
_logger.info(
|
|
"Skipped Lazada order %(ref)s for Lazada shop %(id)s.\n%(reasons)s",
|
|
{"ref": order_data["order_id"], "id": self.id, "reasons": "\n".join(skip_reasons)},
|
|
)
|
|
|
|
return not (invalid_statuses or invalid_types)
|
|
|
|
def _generate_stock_moves(self, order):
|
|
"""
|
|
Generate a stock move for each product of the provided sales order.
|
|
|
|
:param sale.order order: The sales order to generate stock moves.
|
|
:return: The generated stock moves.
|
|
:rtype: stock.move
|
|
"""
|
|
customers_location = self.env.ref('stock.stock_location_customers')
|
|
for order_line in order.order_line.filtered(
|
|
lambda line: line.product_id.type != 'service' and not line.display_type
|
|
):
|
|
stock_move = self.env['stock.move'].create({
|
|
'company_id': self.company_id.id,
|
|
'product_id': order_line.product_id.id,
|
|
'product_uom_qty': order_line.product_uom_qty,
|
|
'product_uom': order_line.product_uom_id.id,
|
|
'location_id': self.fbl_location_id.id,
|
|
'location_dest_id': customers_location.id,
|
|
'state': 'confirmed',
|
|
'sale_line_id': order_line.id,
|
|
})
|
|
stock_move._set_quantity_done(order_line.product_uom_qty)
|
|
stock_move.picked = True # To also change move lines created in `_set_quantity_done`
|
|
stock_move._action_done()
|
|
|
|
def _get_shipping_carrier(self, order_data):
|
|
"""Extract shipping carrier code from order data.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:return: Shipping carrier code or empty string
|
|
:rtype: str
|
|
"""
|
|
unique_providers = []
|
|
for item in order_data['order_items']:
|
|
provider_type = item.get('shipping_provider_type')
|
|
provider = item.get('shipment_provider')
|
|
if not (provider and provider_type):
|
|
continue
|
|
provider_info = {'provider_type': provider_type, 'shipment_provider': provider}
|
|
if provider_info not in unique_providers:
|
|
unique_providers.append(provider_info)
|
|
|
|
if (
|
|
len(unique_providers) != 1
|
|
or unique_providers[0]['provider_type'] not in const.SUPPORTED_SHIPPING_PROVIDER_TYPES
|
|
):
|
|
return ''
|
|
|
|
return unique_providers[0]['shipment_provider']
|
|
|
|
def _update_picking_from_data(self, picking, order_data):
|
|
"""Update picking from Lazada order data.
|
|
|
|
:param stock.picking picking: Picking record
|
|
:param dict order_data: Order data from Lazada API
|
|
"""
|
|
if picking.package_extern_id:
|
|
package_item_data = [
|
|
item
|
|
for item in order_data['order_items']
|
|
if item.get('package_id') == picking.package_extern_id
|
|
]
|
|
else:
|
|
package_item_data = order_data['order_items']
|
|
|
|
if not package_item_data:
|
|
return
|
|
|
|
# ignore if the picking is already synced
|
|
max_update_time = max(
|
|
utils.lazada_timestamp_to_datetime(item['updated_at']) for item in package_item_data
|
|
)
|
|
if max_update_time <= picking.last_lazada_picking_sync_date:
|
|
return
|
|
|
|
shipping_carrier_code = self._get_shipping_carrier(order_data)
|
|
shipping_product = self._find_matching_product(
|
|
shipping_carrier_code, 'default_shipping_product', 'Shipping', 'service'
|
|
)
|
|
delivery_method = self._find_or_create_delivery_carrier(
|
|
shipping_carrier_code, shipping_product
|
|
)
|
|
|
|
tracking_number_list = list({
|
|
item['tracking_code'] for item in package_item_data if item['tracking_code']
|
|
})
|
|
tracking_number = tracking_number_list[0] if len(tracking_number_list) == 1 else ''
|
|
|
|
picking.write({
|
|
'carrier_id': delivery_method.id,
|
|
'carrier_tracking_ref': tracking_number,
|
|
'package_extern_id': package_item_data[0].get('package_id'),
|
|
'last_lazada_picking_sync_date': max_update_time,
|
|
})
|
|
|
|
def _update_order_from_data(self, order, order_data):
|
|
"""Update sale order from Lazada order data.
|
|
|
|
:param sale.order order: Sale order record
|
|
:param dict order_data: Order data from Lazada API
|
|
"""
|
|
order.ensure_one()
|
|
if order.lazada_fulfillment_type != 'fbl':
|
|
order_item_map = {
|
|
str(item['order_item_id']): item for item in order_data['order_items']
|
|
}
|
|
for order_item in order.order_line.lazada_order_item_ids:
|
|
item_data = order_item_map.get(order_item.order_item_extern_id)
|
|
if item_data:
|
|
order_item.status = const.ORDER_ITEM_STATUS_MAPPING.get(
|
|
item_data.get('status'), 'manual'
|
|
)
|
|
|
|
for picking in order.picking_ids.filtered('last_lazada_picking_sync_date'):
|
|
self._update_picking_from_data(picking, order_data)
|
|
|
|
if order.lazada_order_status == 'canceled' and order.state != 'cancel':
|
|
order._action_cancel()
|
|
_logger.info(
|
|
"Cancelled sales order with Lazada Order Ref %(ref)s of shop %(name)s.",
|
|
{'ref': order.lazada_order_ref, 'name': self.name},
|
|
)
|
|
|
|
def _create_order_from_data(self, order_data):
|
|
"""Create sale order from Lazada order data.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:return: Created sale order
|
|
:rtype: sale.order
|
|
"""
|
|
# Prepare the order values
|
|
currency = (
|
|
self.env['res.currency']
|
|
.with_context(active_test=False)
|
|
.search([('name', '=', order_data['order_items'][0]['currency'])], limit=1)
|
|
)
|
|
lazada_order_ref = order_data['order_id']
|
|
contact_partner, delivery_partner = self._find_or_create_partners_from_data(order_data)
|
|
fiscal_position = (
|
|
self.env['account.fiscal.position']
|
|
.with_company(self.company_id)
|
|
._get_fiscal_position(contact_partner, delivery_partner)
|
|
)
|
|
|
|
fulfillment_type = self._get_fulfillment_type(order_data)
|
|
order_income = {
|
|
'voucher': order_data.get('voucher'),
|
|
'shipping_fee': order_data.get('shipping_fee'),
|
|
}
|
|
order_lines_values = (
|
|
self.with_context(order_income=order_income)
|
|
._prepare_order_lines_values(order_data, currency, fiscal_position)
|
|
)
|
|
order_lines = [
|
|
Command.create(order_line_values) for order_line_values in order_lines_values
|
|
]
|
|
origin = self.env._(
|
|
"Lazada Order %(lazada_order_ref)s at %(name)s",
|
|
lazada_order_ref=lazada_order_ref,
|
|
name=self.name,
|
|
)
|
|
date_order = utils.lazada_timestamp_to_datetime(order_data['created_at'])
|
|
|
|
order_vals = {
|
|
'origin': origin,
|
|
'state': 'sale',
|
|
'locked': fulfillment_type == 'fbl',
|
|
'date_order': date_order,
|
|
'partner_id': contact_partner.id,
|
|
'pricelist_id': self._find_or_create_pricelist(currency).id,
|
|
'order_line': order_lines,
|
|
'invoice_status': 'no',
|
|
'partner_shipping_id': delivery_partner.id,
|
|
'require_signature': False,
|
|
'require_payment': False,
|
|
'fiscal_position_id': fiscal_position.id,
|
|
'company_id': self.company_id.id,
|
|
'user_id': self.user_id.id,
|
|
'team_id': self.team_id.id,
|
|
'lazada_order_ref': lazada_order_ref,
|
|
'lazada_shop_id': self.id,
|
|
'lazada_fulfillment_type': fulfillment_type or 'fbm',
|
|
}
|
|
if fulfillment_type == 'fbl' and self.fbl_location_id.warehouse_id:
|
|
order_vals['warehouse_id'] = self.fbl_location_id.warehouse_id.id
|
|
elif fulfillment_type == 'fbm' and self.fbm_warehouse_id:
|
|
order_vals['warehouse_id'] = self.fbm_warehouse_id.id
|
|
|
|
order = (
|
|
self.env['sale.order']
|
|
.with_context(mail_create_nosubscribe=True)
|
|
.with_company(self.company_id)
|
|
.create(order_vals)
|
|
)
|
|
|
|
# price from Lazada API does not include shipping fee and voucher
|
|
lazada_total = (
|
|
float(order_data.get("price") or 0)
|
|
+ float(order_data.get("shipping_fee") or 0)
|
|
- float(order_data.get("voucher") or 0)
|
|
)
|
|
self._adjust_order_total(order, currency, lazada_total)
|
|
|
|
if order.picking_ids and fulfillment_type != 'fbl':
|
|
# Initialize the lazada order items from the sale order lines
|
|
order.picking_ids.move_ids.initialize_lazada_order_items()
|
|
self._update_order_from_data(order, order_data)
|
|
return order
|
|
|
|
def _adjust_order_total(self, order, currency, lazada_total):
|
|
"""Adjust the order total to reconcile with Lazada's ``price``.
|
|
|
|
A single tax-free adjustment line absorbs the full residue. The residue
|
|
can come from any other component not explicitly modeled as a sale order line.
|
|
|
|
:param sale.order order: The order to adjust.
|
|
:param res.currency currency: The currency of the order.
|
|
:param float lazada_total: The total amount from Lazada.
|
|
"""
|
|
residue = lazada_total - order.amount_total
|
|
if not order.amount_total or currency.is_zero(residue):
|
|
return
|
|
|
|
adjustment_product = self._find_matching_product(
|
|
internal_reference="",
|
|
default_xmlid="default_adjustment_product",
|
|
default_name=self.env._("Lazada Amount Adjustment"),
|
|
default_type="service",
|
|
)
|
|
order.order_line.create({
|
|
"order_id": order.id,
|
|
"product_id": adjustment_product.id,
|
|
"price_unit": residue,
|
|
"tax_ids": [],
|
|
})
|
|
|
|
def _prepare_discount_lines_values(self, currency, tax_group_bases, discount_total):
|
|
"""Distribute an order-level discount across the product tax groups.
|
|
|
|
One negative discount line is created per distinct tax group, allocated
|
|
pro-rata to each group's buyer-paid (tax-inclusive) merchandise, so the
|
|
discount reduces each group's taxable base correctly. The allocation
|
|
sums exactly to ``discount_total``; the remainder lands on the largest
|
|
group, leaving only price rounding for the amount-adjustment line. With
|
|
a single tax group, this collapses to one discount line.
|
|
|
|
:param res.currency currency: The currency of the sale order.
|
|
:param dict tax_group_bases: Map of tax-id tuple to ``{'taxes', 'base'}``.
|
|
:param float discount_total: The order-level discount to distribute.
|
|
:return: The discount-line values list ready for ``Command.create``.
|
|
:rtype: list[dict]
|
|
"""
|
|
if currency.is_zero(discount_total) or not tax_group_bases:
|
|
return []
|
|
|
|
discount_product = self._find_matching_product(
|
|
internal_reference="",
|
|
default_xmlid="default_discount_product",
|
|
default_name=self.env._("Lazada Order Discount"),
|
|
default_type="service",
|
|
)
|
|
total_base = sum(tax_group_bases.values())
|
|
|
|
# Largest group first: in the degenerate zero-base case (no merchandise to weight the
|
|
# split by) the whole discount lands on it rather than on a marginal group.
|
|
tax_groups = sorted(
|
|
[{"taxes": taxes, "base": base} for taxes, base in tax_group_bases.items()],
|
|
key=lambda tax_group: tax_group["base"], reverse=True
|
|
)
|
|
|
|
discount_lines_values = []
|
|
allocated_discount = 0.0
|
|
for index, tax_group in enumerate(tax_groups):
|
|
if index == len(tax_groups) - 1 or currency.is_zero(total_base):
|
|
group_discount = discount_total - allocated_discount
|
|
else:
|
|
group_discount = currency.round(discount_total * tax_group["base"] / total_base)
|
|
allocated_discount += group_discount
|
|
if currency.is_zero(group_discount):
|
|
continue
|
|
discount_subtotal = self._compute_subtotal(
|
|
-group_discount, tax_group["taxes"], currency
|
|
)
|
|
discount_lines_values.append(
|
|
self._convert_to_order_line_values(
|
|
product_id=discount_product.id,
|
|
subtotal=discount_subtotal,
|
|
description=self.env._("Order Discount"),
|
|
tax_ids=tax_group["taxes"].ids,
|
|
)
|
|
)
|
|
return discount_lines_values
|
|
|
|
def _convert_to_order_line_values(self, **kwargs):
|
|
"""Convert values to sale order line format.
|
|
|
|
:param dict kwargs: Values including product_id, quantity, subtotal, order_items, etc.
|
|
:return: Sale order line values dict
|
|
:rtype: dict
|
|
"""
|
|
subtotal = kwargs.get('subtotal', 0)
|
|
quantity = kwargs.get("quantity", 1)
|
|
tax_ids = kwargs.get('tax_ids')
|
|
order_item_values = [
|
|
Command.create({
|
|
'order_item_extern_id': str(item['order_item_id']),
|
|
'status': const.ORDER_ITEM_STATUS_MAPPING.get(item.get('status'), 'manual'),
|
|
})
|
|
for item in kwargs.get('order_items') or []
|
|
]
|
|
return {
|
|
"name": kwargs.get("description", ""),
|
|
"product_id": kwargs.get("product_id"),
|
|
"price_unit": subtotal / quantity if quantity else 0,
|
|
"tax_ids": tax_ids if tax_ids else [],
|
|
"product_uom_qty": quantity,
|
|
"lazada_order_item_ids": order_item_values,
|
|
}
|
|
|
|
def _prepare_order_lines_values(self, order_data, currency, fiscal_pos):
|
|
"""Prepare the sale order lines values from the Lazada order data.
|
|
|
|
Each SKU becomes one ``sale.order.line`` values dict (Lazada groups
|
|
multiple order items under the same SKU). The order-level voucher and
|
|
shipping fee are read from the ``order_income`` context key. Shipping
|
|
is appended as a final line when the shipping fee is non-zero.
|
|
|
|
:param dict order_data: The order data from the Lazada API.
|
|
:param res.currency currency: The currency of the sale order.
|
|
:param account.fiscal.position fiscal_pos: The fiscal position of the sale order.
|
|
:return: The order-line values list ready for ``Command.create``.
|
|
:rtype: list[dict]
|
|
"""
|
|
self.ensure_one()
|
|
|
|
order_income = self.env.context.get('order_income') or {}
|
|
order_lines_values = []
|
|
# Buyer-paid (tax-inclusive) merchandise per distinct tax group, used to
|
|
# distribute the order-level discount in `_prepare_discount_lines_values`.
|
|
tax_group_bases = {}
|
|
sku_items = {}
|
|
for item_data in order_data['order_items']:
|
|
sku_items.setdefault(item_data['sku'], []).append(item_data)
|
|
|
|
for sku, items in sku_items.items():
|
|
# Use first item for common info
|
|
common_item_data = items[0]
|
|
fulfillment_type = 'fbl' if common_item_data['is_fbl'] else 'fbm'
|
|
item_extern_id = common_item_data["sku_id"]
|
|
|
|
lazada_item = self._find_or_create_item(
|
|
sku, item_extern_id, fulfillment_type=fulfillment_type
|
|
)
|
|
product_taxes = lazada_item.product_id.taxes_id._filter_taxes_by_company(
|
|
self.company_id
|
|
)
|
|
taxes = fiscal_pos.map_tax(product_taxes)
|
|
|
|
quantity = len([i for i in items if i.get("status") != "canceled"])
|
|
paid_total = sum(float(item["paid_price"]) for item in items)
|
|
|
|
description = self._build_item_description(sku, common_item_data, currency)
|
|
subtotal = self._compute_subtotal(paid_total, taxes, currency)
|
|
|
|
order_lines_values.append(
|
|
self._convert_to_order_line_values(
|
|
product_id=lazada_item.product_id.id,
|
|
subtotal=subtotal,
|
|
description=description,
|
|
quantity=quantity,
|
|
tax_ids=taxes.ids,
|
|
order_items=items,
|
|
)
|
|
)
|
|
tax_group_bases[taxes] = tax_group_bases.get(taxes, 0.0) + paid_total
|
|
|
|
# Order-level discount lines (e.g. cart vouchers), distributed per tax group.
|
|
discount_total = float(order_income.get("voucher") or 0)
|
|
order_lines_values += self._prepare_discount_lines_values(
|
|
currency, tax_group_bases, discount_total
|
|
)
|
|
|
|
# Shipping line.
|
|
shipping_fee = float(order_income.get("shipping_fee") or 0)
|
|
if not currency.is_zero(shipping_fee):
|
|
shipping_carrier_code = self._get_shipping_carrier(order_data)
|
|
shipping_product = self._find_matching_product(
|
|
internal_reference=shipping_carrier_code,
|
|
default_xmlid="default_shipping_product",
|
|
default_name=self.env._("Lazada Shipping"),
|
|
default_type="service",
|
|
)
|
|
shipping_taxes_raw = shipping_product.taxes_id._filter_taxes_by_company(self.company_id)
|
|
shipping_taxes = fiscal_pos.map_tax(shipping_taxes_raw)
|
|
shipping_subtotal = self._compute_subtotal(shipping_fee, shipping_taxes, currency)
|
|
shipping_name = (
|
|
self.env._("Shipping: %(carrier)s", carrier=shipping_carrier_code)
|
|
if shipping_carrier_code
|
|
else self.env._("Shipping")
|
|
)
|
|
order_lines_values.append(
|
|
self._convert_to_order_line_values(
|
|
product_id=shipping_product.id,
|
|
subtotal=shipping_subtotal,
|
|
description=shipping_name,
|
|
tax_ids=shipping_taxes.ids,
|
|
)
|
|
)
|
|
|
|
return order_lines_values
|
|
|
|
def _build_item_description(self, sku, item_data, currency):
|
|
"""Build the base description text for a Lazada item line.
|
|
|
|
Includes header, optional voucher line (guarded against absence of
|
|
``voucher_code_seller``), and an original-price line when the item
|
|
was discounted.
|
|
|
|
:param str sku: The item SKU (used in the header).
|
|
:param dict item_data: The Lazada item payload.
|
|
:param res.currency currency: The currency (used to format the original price).
|
|
:return: The multi-line description text.
|
|
:rtype: str
|
|
"""
|
|
lines = [f"[{sku}] {item_data['name']}"]
|
|
voucher_code = item_data.get("voucher_code_seller")
|
|
if voucher_code:
|
|
lines.append(
|
|
self.env._(
|
|
"Voucher id: %(voucher_id)s\nOriginal price: %(price)s",
|
|
voucher_id=voucher_code,
|
|
price=currency.format(float(item_data["item_price"])),
|
|
)
|
|
)
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _find_matching_product(
|
|
self, internal_reference, default_xmlid, default_name, default_type, fallback=True
|
|
):
|
|
"""Find product by internal reference or return default product.
|
|
|
|
:param str internal_reference: Product internal reference (SKU)
|
|
:param str default_xmlid: XML ID of default fallback product
|
|
:param str default_name: Name for default product if restored
|
|
:param str default_type: Type for default product if restored
|
|
:param bool fallback: Whether to fallback to default product
|
|
:return: Product record
|
|
:rtype: product.product
|
|
"""
|
|
self.ensure_one()
|
|
|
|
product = self.env['product.product']
|
|
if internal_reference:
|
|
product = self.env['product.product'].search(
|
|
[
|
|
*self.env['product.product']._check_company_domain(self.company_id),
|
|
('default_code', '=', internal_reference),
|
|
],
|
|
limit=1,
|
|
)
|
|
if not product and fallback: # Fallback to the default product
|
|
product = self.env.ref(
|
|
f'sale_lazada.{default_xmlid}', raise_if_not_found=False
|
|
) or self.env['product.product']._restore_lazada_data_product(
|
|
default_name, default_type, default_xmlid
|
|
)
|
|
return product
|
|
|
|
def _find_or_create_partners_from_data(self, order_data):
|
|
"""Find or create contact and delivery partners from order data.
|
|
|
|
:param dict order_data: Order data from Lazada API
|
|
:return: Tuple of (contact_partner, delivery_partner)
|
|
:rtype: tuple
|
|
"""
|
|
self.ensure_one()
|
|
|
|
lazada_buyer_extern_id = str(order_data['order_items'][0]['buyer_id'])
|
|
buyer_name = f"{order_data['customer_first_name']} {order_data['customer_last_name']}"
|
|
shipping_address_name = (
|
|
f"{order_data['address_shipping']['first_name']}"
|
|
f" {order_data['address_shipping']['last_name']}"
|
|
)
|
|
city = order_data['address_shipping']['city']
|
|
street = order_data['address_shipping']['address1']
|
|
street2 = order_data['address_shipping']['address2']
|
|
zip_code = order_data['address_shipping'].get('post_code')
|
|
state_name = order_data['address_shipping']['address3']
|
|
country_name = order_data['address_shipping']['country']
|
|
phone = order_data['address_shipping']['phone']
|
|
|
|
country = self.env['res.country'].search([('code', '=ilike', country_name)], limit=1)
|
|
state = self.env['res.country.state']
|
|
if country and state_name:
|
|
state = self.env['res.country.state'].search(
|
|
[('name', '=ilike', state_name), ('country_id', '=', country.id)], limit=1
|
|
)
|
|
|
|
partner_vals = {
|
|
'street': street,
|
|
'street2': street2,
|
|
'zip': zip_code,
|
|
'city': city,
|
|
'country_id': country.id if country else False,
|
|
'state_id': state.id if state else False,
|
|
'phone': phone,
|
|
'customer_rank': 1,
|
|
'company_id': self.company_id.id,
|
|
'lazada_buyer_extern_id': lazada_buyer_extern_id,
|
|
}
|
|
|
|
# Try to find existing contact partner
|
|
contact_partner = self.env['res.partner'].search(
|
|
[
|
|
('lazada_buyer_extern_id', '=', lazada_buyer_extern_id),
|
|
('company_id', '=', self.company_id.id),
|
|
],
|
|
limit=1,
|
|
)
|
|
|
|
if not contact_partner:
|
|
partner_vals['name'] = buyer_name
|
|
contact_partner = self.env['res.partner'].create(partner_vals)
|
|
|
|
if (
|
|
shipping_address_name != buyer_name
|
|
or partner_vals['street'] != contact_partner.street
|
|
or partner_vals['street2'] != contact_partner.street2
|
|
or partner_vals['city'] != contact_partner.city
|
|
or partner_vals['phone'] != contact_partner.phone
|
|
):
|
|
delivery_vals = partner_vals.copy()
|
|
delivery_vals.update({
|
|
'name': shipping_address_name,
|
|
'parent_id': contact_partner.id,
|
|
'type': 'delivery',
|
|
})
|
|
delivery_partner = self.env['res.partner'].create(delivery_vals)
|
|
else:
|
|
delivery_partner = contact_partner
|
|
|
|
return contact_partner, delivery_partner
|
|
|
|
def _find_or_create_delivery_carrier(self, shipping_carrier_code, shipping_product):
|
|
"""Find or create delivery carrier by shipping code.
|
|
|
|
:param str shipping_carrier_code: Shipping carrier code
|
|
:param record shipping_product: Shipping product record (product.product)
|
|
:return: Delivery carrier record
|
|
:rtype: delivery.carrier
|
|
"""
|
|
delivery_method = self.env['delivery.carrier'].search(
|
|
[('name', '=', shipping_carrier_code)], limit=1
|
|
)
|
|
if shipping_carrier_code and not delivery_method:
|
|
delivery_method = self.env['delivery.carrier'].create({
|
|
'name': shipping_carrier_code,
|
|
'product_id': shipping_product.id,
|
|
})
|
|
return delivery_method
|
|
|
|
def _find_or_create_pricelist(self, currency):
|
|
"""Find or create pricelist for currency.
|
|
|
|
:param currency: Currency record (res.currency)
|
|
:return: Pricelist record
|
|
:rtype: product.pricelist
|
|
"""
|
|
pricelist = self.env['product.pricelist'].search(
|
|
[
|
|
('currency_id', '=', currency.id),
|
|
*self.env['product.pricelist']._check_company_domain(self.company_id),
|
|
],
|
|
limit=1,
|
|
)
|
|
|
|
if not pricelist:
|
|
pricelist = self.env['product.pricelist'].create({
|
|
'name': f"Lazada {currency.name}",
|
|
'currency_id': currency.id,
|
|
'company_id': self.company_id.id,
|
|
'active': False,
|
|
})
|
|
|
|
return pricelist
|
|
|
|
def _compute_subtotal(self, total, taxes, currency):
|
|
"""Compute the subtotal from the taxes.
|
|
|
|
Lazada does not include the tax amount of the order; we need to recompute the subtotal from
|
|
the taxes on the product (or those given by the fiscal position) to match the order total.
|
|
|
|
To achieve this, the subtotal is recomputed from the taxes for the total to match that of
|
|
the order. If the taxes used are not identical to those used by Lazada, the recomputed
|
|
subtotal will differ from the original subtotal.
|
|
|
|
:param float total: The original total.
|
|
:param account.tax taxes: The final taxes to use for the computation of the new subtotal.
|
|
:param res.currency currency: The currency used by the rounding methods.
|
|
:return: The new subtotal.
|
|
:rtype: float
|
|
"""
|
|
taxes_res = taxes.with_context(force_price_include=True, round_base=False).compute_all(
|
|
total, currency=currency
|
|
)
|
|
subtotal = taxes_res['total_included']
|
|
for tax_res in taxes_res['taxes']:
|
|
tax = self.env['account.tax'].browse(tax_res['id'])
|
|
if not tax.price_include:
|
|
subtotal -= tax_res['amount']
|
|
return subtotal
|
|
|
|
def _find_or_create_item(self, sku, item_extern_id, fulfillment_type='fbm', no_create=False):
|
|
"""Find or create Lazada item mapping.
|
|
|
|
Links Lazada items to Odoo products based on SKU.
|
|
|
|
:param str sku: Product SKU
|
|
:param str item_extern_id: Lazada item ID
|
|
:param str fulfillment_type: 'fbm' or 'fbl' (optional)
|
|
:return: Lazada item record
|
|
:rtype: lazada.item
|
|
"""
|
|
self.ensure_one()
|
|
lazada_item = self.lazada_item_ids.filtered(
|
|
lambda i: i.lazada_item_extern_id == str(item_extern_id)
|
|
)
|
|
if not lazada_item and not no_create:
|
|
item_vals = {
|
|
'product_id': self._find_matching_product(
|
|
sku, 'default_sale_product', 'Lazada Sales', 'consu'
|
|
).id,
|
|
'shop_id': self.id,
|
|
'lazada_item_extern_id': item_extern_id,
|
|
'fulfillment_type': fulfillment_type,
|
|
'lazada_sku': sku,
|
|
}
|
|
lazada_item = (
|
|
self.env['lazada.item'].with_context(tracking_disable=True).create(item_vals)
|
|
)
|
|
# If the item has been linked with the default product, check if another product
|
|
# has now been assigned the current SKU as internal reference and update if so
|
|
else:
|
|
if 'sale_lazada.default_sale_product' in lazada_item.product_id._get_external_ids().get(
|
|
lazada_item.product_id.id, []
|
|
):
|
|
product = self._find_matching_product(sku, '', '', '', fallback=False)
|
|
if product:
|
|
lazada_item.product_id = product.id
|
|
|
|
item_vals = {}
|
|
if lazada_item.lazada_sku != sku:
|
|
item_vals['lazada_sku'] = sku
|
|
if fulfillment_type and lazada_item.fulfillment_type != fulfillment_type:
|
|
item_vals['fulfillment_type'] = fulfillment_type
|
|
if item_vals:
|
|
lazada_item.write(item_vals)
|
|
|
|
return lazada_item
|
|
|
|
# === OTHER HELPER METHODS === #
|
|
def _get_shop_vals(self, code):
|
|
"""Create shop values from Lazada authorization code.
|
|
|
|
:param str code: Authorization code from Lazada
|
|
:return: Dict of shop values (tokens, country_id, shop_extern_id)
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
token_data = utils.request_access_token(self, authorization_code=code)
|
|
if not token_data:
|
|
raise ValidationError(self.env._("Failed to get token from Lazada."))
|
|
country_id = (
|
|
self.env['res.country'].search([('code', '=', token_data['country'].upper())]).id
|
|
)
|
|
if not country_id:
|
|
raise ValidationError(
|
|
self.env._("Could not find country with code %(code)s", code=token_data['country'])
|
|
)
|
|
if not len(token_data.get('country_user_info', [])):
|
|
raise ValidationError(self.env._("Could not allocate the shop identifier"))
|
|
|
|
shop_extern_id = token_data['country_user_info'][0]['seller_id']
|
|
expiration_date = fields.Datetime.now() + timedelta(seconds=token_data['expires_in'])
|
|
refresh_expiration_date = fields.Datetime.now() + timedelta(
|
|
seconds=token_data['refresh_expires_in']
|
|
)
|
|
return {
|
|
'country_id': country_id,
|
|
'shop_extern_id': shop_extern_id,
|
|
'access_token': token_data['access_token'],
|
|
'access_token_expiration_date': expiration_date,
|
|
'refresh_token': token_data['refresh_token'],
|
|
'refresh_token_expiration_date': refresh_expiration_date,
|
|
}
|
|
|
|
def _handle_sync_failure(self, flow='', lazada_order_ref=False, error_messages=False):
|
|
"""Send failure notification email to shop manager.
|
|
|
|
:param str flow: Sync flow type: 'order_sync' or 'inventory_sync'
|
|
:param str lazada_order_ref: Lazada order reference (for order_sync flow)
|
|
"""
|
|
mail_template_id = ''
|
|
if flow == 'order_sync':
|
|
_logger.error(
|
|
"Failed to synchronize order with lazada reference %(ref)s for lazada.shop with"
|
|
" id %(shop_id)s (Shope Name: %(shop_name)s)."
|
|
"Please create the order manually.",
|
|
{'ref': lazada_order_ref, 'shop_id': self.id, 'shop_name': self.name},
|
|
)
|
|
mail_template_id = 'sale_lazada.order_sync_failure'
|
|
elif flow == 'inventory_sync':
|
|
_logger.error(
|
|
"Failed to synchronize the inventory for items in lazada.shop with id"
|
|
" %(shop_id)s (Shope Name: %(shop_name)s).",
|
|
{'shop_id': self.id, 'shop_name': self.name},
|
|
)
|
|
mail_template_id = 'sale_lazada.inventory_sync_failure'
|
|
|
|
mail_template = self.env.ref(mail_template_id, raise_if_not_found=False)
|
|
if not mail_template:
|
|
_logger.error(
|
|
"The mail template with xmlid %(mail_template)s has been deleted.",
|
|
{'mail_template': mail_template_id},
|
|
)
|
|
else:
|
|
responsible_emails = {
|
|
user.email
|
|
for user in filter(
|
|
None, (self.user_id, self.env.ref('base.user_admin', raise_if_not_found=False))
|
|
)
|
|
}
|
|
mail_template.with_context(
|
|
email_to=','.join(responsible_emails),
|
|
lazada_order_ref=lazada_order_ref,
|
|
error_messages=error_messages,
|
|
lazada_shop=self.name,
|
|
).send_mail(self.env.user.id)
|
|
_logger.info(
|
|
"Sent synchronization failure notification email to %(emails)s",
|
|
{'emails': ', '.join(responsible_emails)},
|
|
)
|