1197 lines
54 KiB
Python
1197 lines
54 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
import math
|
|
from datetime import datetime, timedelta
|
|
|
|
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.addons.sale_shopee import const, utils
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShopeeShop(models.Model):
|
|
_name = 'shopee.shop'
|
|
_description = "Shopee Shop"
|
|
_check_company_auto = True
|
|
|
|
name = fields.Char(string="Name", required=True)
|
|
account_id = fields.Many2one(
|
|
string="Shopee Account",
|
|
comodel_name='shopee.account',
|
|
ondelete='cascade',
|
|
index=True,
|
|
readonly=True,
|
|
required=True,
|
|
)
|
|
shopee_item_ids = fields.One2many(
|
|
string="Shopee Items", comodel_name='shopee.item', inverse_name='shop_id'
|
|
)
|
|
|
|
# Credentials fields.
|
|
shop_identifier = fields.Integer(string="Shopee Shop ID", readonly=True, required=True)
|
|
access_token = fields.Char(
|
|
help="Access Token expires in 4 hours and is used to access the Shopee API.",
|
|
readonly=True,
|
|
)
|
|
access_token_expiration_date = fields.Datetime(
|
|
help="Access token expiration date. Computed using the expires_in returned by Shopee.",
|
|
readonly=True,
|
|
)
|
|
refresh_token = fields.Char(
|
|
help="Refresh Token is used to request a new Access Token and expires in 30 days. Once"
|
|
" expired, the user must re-authorize the connection on Shopee Account view.",
|
|
readonly=True,
|
|
)
|
|
authorization_expiration_date = fields.Datetime(
|
|
help="Expiration Date of Authorization of a shop, that was returned during the Shopee shop"
|
|
" authorization on Shopee Account view. Once expired, the user must re-authorize the"
|
|
" connection on Shopee Account view.",
|
|
readonly=True,
|
|
)
|
|
|
|
# Follow-up fields.
|
|
user_id = fields.Many2one(
|
|
string="Default Salesperson",
|
|
help="The default salesperson assigned to Shopee orders",
|
|
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 Shopee orders for reporting",
|
|
comodel_name='crm.team',
|
|
check_company=True,
|
|
)
|
|
company_id = fields.Many2one(
|
|
string="Company",
|
|
comodel_name='res.company',
|
|
default=lambda self: self.env.company,
|
|
required=True,
|
|
readonly=True,
|
|
)
|
|
fbs_location_id = fields.Many2one(
|
|
string="FBS Stock Location",
|
|
help="The stock location managed by Shopee under the Fulfilled by Shopee (FBS) program.",
|
|
comodel_name='stock.location',
|
|
domain="[('usage', '=', 'internal')]",
|
|
check_company=True,
|
|
)
|
|
active = fields.Boolean(
|
|
string="Active",
|
|
help="If made inactive, this account will no longer be synchronized with Shopee.",
|
|
default=True,
|
|
required=True,
|
|
)
|
|
status = fields.Selection(
|
|
help="The shop status on Shopee."
|
|
"- Inactive: The shop is not yet approved by Shopee."
|
|
"- Active: The shop is active and can be synchronized with Odoo."
|
|
"- Error: The shop is banned and cannot be synchronized with Odoo.",
|
|
selection=[
|
|
('inactive', "Inactive"),
|
|
('active', "Active"),
|
|
('error', "Error"),
|
|
],
|
|
readonly=True,
|
|
default='inactive',
|
|
)
|
|
last_shop_status_sync_date = fields.Datetime(
|
|
string="Last Shop Status Synchronization Date",
|
|
help="The last time the shop status was synchronized with Shopee",
|
|
required=True,
|
|
default=fields.Datetime.now,
|
|
)
|
|
last_orders_sync_date = fields.Datetime(
|
|
string="Last Order Synchronization Date",
|
|
help="The last time the orders were synchronized with Shopee. Orders whose status has "
|
|
"not changed since this date will not be created nor updated in Odoo.",
|
|
required=True,
|
|
default=fields.Datetime.now,
|
|
)
|
|
synchronize_inventory = fields.Boolean(
|
|
string="Synchronize Inventory",
|
|
help="Whether the available quantities of products linked to this shop are synchronized"
|
|
" with Shopee. Only products available in FBM or hybrid will be synchronized.",
|
|
default=False,
|
|
)
|
|
|
|
# Display fields.
|
|
order_count = fields.Integer(string="Order Count", compute='_compute_order_count')
|
|
shopee_item_count = fields.Integer(
|
|
string="Shopee Item Count", compute='_compute_shopee_item_count'
|
|
)
|
|
authorization_remaining_days = fields.Integer(
|
|
string="Authorization Remaining Days",
|
|
help="Remaining days of the shop authorization. Show a warning in the Shopee Shop view "
|
|
"that the authorization will expire soon.",
|
|
compute='_compute_authorization_remaining_days',
|
|
readonly=True,
|
|
)
|
|
|
|
# === COMPUTE METHODS === #
|
|
|
|
def _compute_order_count(self):
|
|
for shop in self:
|
|
order_count = self.env['sale.order'].search_count([('shopee_shop_id', 'in', shop.ids)])
|
|
shop.order_count = order_count
|
|
|
|
@api.depends('shopee_item_ids')
|
|
def _compute_shopee_item_count(self):
|
|
for shop in self:
|
|
shop.shopee_item_count = len(shop.shopee_item_ids)
|
|
|
|
@api.depends('authorization_expiration_date')
|
|
def _compute_authorization_remaining_days(self):
|
|
for shop in self:
|
|
if shop.authorization_expiration_date:
|
|
shop.authorization_remaining_days = (
|
|
shop.authorization_expiration_date - fields.Datetime.now()
|
|
).days
|
|
else:
|
|
shop.authorization_remaining_days = -1
|
|
|
|
# === CRUD METHODS === #
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
""" Associate a shop with a location and a sales team.
|
|
|
|
By default, each shop has its own location, while all shops share the same sales team.
|
|
"""
|
|
for vals in vals_list:
|
|
company_id = vals.get('company_id') or self.env.company.id
|
|
# Find or create the shopee warehouse to be associated with this account
|
|
parent_warehouse = self.env['stock.warehouse'].search_read(
|
|
self.env['stock.warehouse']._check_company_domain(company_id),
|
|
['view_location_id'],
|
|
limit=1,
|
|
)
|
|
location_name = _("Shopee Warehouse - %(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,
|
|
)
|
|
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['fbs_location_id'] = location.id
|
|
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
""" Override to block the change of the company of a Shopee shop."""
|
|
if 'company_id' in vals:
|
|
raise UserError(_("Changing the company of a Shopee shop is not allowed."))
|
|
return super().write(vals)
|
|
|
|
# === ACTION METHODS === #
|
|
|
|
def action_sync_orders(self):
|
|
self._sync_orders()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'reload',
|
|
}
|
|
|
|
def action_sync_pickings(self):
|
|
return self.env['stock.picking']._sync_shopee_pickings(tuple(self.ids))
|
|
|
|
def action_sync_inventory(self):
|
|
self._sync_inventory()
|
|
|
|
def action_force_update_shop(self):
|
|
return self._update_shop_information(force_update=True)
|
|
|
|
def action_view_orders(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Sale Orders"),
|
|
'res_model': 'sale.order',
|
|
'view_mode': 'list,form',
|
|
'domain': [('shopee_shop_id', '=', self.id)],
|
|
}
|
|
|
|
def action_view_shopee_items(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Shopee Items"),
|
|
'res_model': 'shopee.item',
|
|
'view_mode': 'list,form',
|
|
'domain': [('shop_id', '=', self.id)],
|
|
'context': {'default_shop_id': self.id},
|
|
}
|
|
|
|
def action_archive(self):
|
|
""" Override to disconnect the Shopee shops before archiving it. """
|
|
for shop in self:
|
|
shop._reset_tokens()
|
|
return super().action_archive()
|
|
|
|
# === BUSINESS METHODS === #
|
|
|
|
def create_or_update_shop(self, company_id, account_id, shop_id, shop_vals):
|
|
""" Create or update a shop with the provided shop_id and account_id.
|
|
|
|
:param int company_id: The company identifier.
|
|
:param int account_id: The Shopee account identifier.
|
|
:param int shop_id: The Shopee shop identifier.
|
|
:param dict shop_vals: The values to update the shop with.
|
|
TODO: rename to ``token_vals`` in master
|
|
:return: shopee.shop
|
|
"""
|
|
shop = self.search(
|
|
[('shop_identifier', '=', shop_id)], limit=1
|
|
)
|
|
|
|
if not shop: # When call during the onboarding of an account (and not a shop)
|
|
shop = self.create({
|
|
'name': _("Shopee Shop #%(shop_id)s", shop_id=shop_id),
|
|
'shop_identifier': shop_id,
|
|
'account_id': account_id,
|
|
'company_id': company_id,
|
|
**shop_vals, # Contains the tokens from the account
|
|
})
|
|
elif shop_vals:
|
|
shop.write({
|
|
'account_id': account_id,
|
|
**shop_vals,
|
|
})
|
|
else:
|
|
shop.write({'account_id': account_id})
|
|
utils.request_access_token(shop)
|
|
|
|
shop._update_shop_information(force_update=True)
|
|
return shop
|
|
|
|
def _update_shop_information(self, force_update=False):
|
|
""" Update the shop information from Shopee.
|
|
|
|
See https://open.shopee.com/documents/v2/v2.shop.get_shop_info?module=92&type=1
|
|
"""
|
|
self.ensure_one()
|
|
|
|
task_begin_datetime = fields.Datetime.now()
|
|
if force_update \
|
|
or not self.authorization_expiration_date \
|
|
or not self.last_shop_status_sync_date \
|
|
or self.last_shop_status_sync_date < task_begin_datetime - timedelta(days=1):
|
|
shop_data = utils.make_shopee_api_request(self, 'get_shop_info')
|
|
expiration_date = datetime.fromtimestamp(shop_data['expire_time'])
|
|
self.update({
|
|
'status': const.SHOP_STATUS_MAPPING.get(shop_data['status'], 'error'),
|
|
'authorization_expiration_date': expiration_date,
|
|
'last_shop_status_sync_date': task_begin_datetime,
|
|
})
|
|
|
|
return self
|
|
|
|
def _sync_orders(self, auto_commit=True):
|
|
""" Synchronize the shops' sales orders that were recently updated on Shopee.
|
|
|
|
If called on an empty recordset, the orders of all active shops are synchronized instead.
|
|
|
|
Note: This method is called by the `ir_cron_sync_shopee_orders` cron.
|
|
|
|
:param bool auto_commit: Whether the database cursor should be committed as soon as an order
|
|
is successfully synchronized.
|
|
:return: None
|
|
"""
|
|
shops = self or self.search([])
|
|
for shop in shops:
|
|
shop._update_shop_information()
|
|
if shop.status != 'active':
|
|
continue
|
|
|
|
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,
|
|
)
|
|
order_list = shop._fetch_order_list(
|
|
time_from=status_update_lower_limit, time_to=status_update_upper_limit
|
|
)
|
|
if not order_list:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
continue
|
|
|
|
orders_detail = shop._fetch_orders_detail(order_list)
|
|
# As we fetched these orders based on their id, 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.
|
|
upper_limit_timestamp = int(status_update_upper_limit.timestamp())
|
|
valid_orders_detail = [
|
|
od for od in orders_detail if od['update_time'] <= upper_limit_timestamp
|
|
]
|
|
if not valid_orders_detail:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
continue
|
|
|
|
for order_data in valid_orders_detail:
|
|
try:
|
|
if auto_commit:
|
|
with self.env.cr.savepoint():
|
|
shop._process_order_data(order_data)
|
|
else: # Avoid the savepoint in testing
|
|
shop._process_order_data(order_data)
|
|
except utils.ShopeeRateLimitError:
|
|
raise
|
|
except PG_CONCURRENCY_EXCEPTIONS_TO_RETRY:
|
|
shopee_order_ref = order_data['order_sn']
|
|
_logger.info(
|
|
"A concurrency error occurred while processing the order data "
|
|
"with shopee_order_ref %(order_ref)s for Shopee shop id %(id)s."
|
|
" Discarding the error to trigger the retry mechanism.",
|
|
{'order_ref': shopee_order_ref, 'id':shop.id},
|
|
)
|
|
# Let the error bubble up so that either the request can be retried
|
|
# up to 5 times or the cron job rollbacks the cursor and reschedules
|
|
# itself later, depending on which of the two called this method.
|
|
raise
|
|
except Exception as error:
|
|
shopee_order_ref = order_data['order_sn']
|
|
_logger.warning(
|
|
"A business error occurred while processing the order data"
|
|
" with shopee_order_ref %(order_ref)s for Shopee shop %(shop)s."
|
|
" Skipping the order data and moving to the next order.",
|
|
{'order_ref': shopee_order_ref, 'shop': shop.id},
|
|
exc_info=True
|
|
)
|
|
# Dismiss business errors to allow the synchronization to skip the
|
|
# problematic orders.
|
|
self.env.cr.rollback()
|
|
shop._handle_sync_failure(
|
|
flow='order_sync', shopee_order_ref=shopee_order_ref
|
|
)
|
|
continue
|
|
|
|
if auto_commit:
|
|
self.env.cr.commit() # Commit to mitigate a potential cron kill.
|
|
|
|
# The sync of this orders batch went through, save the last status update time
|
|
valid_update_times = [order['update_time'] for order in valid_orders_detail]
|
|
if valid_update_times and len(orders_detail) == const.ORDER_DETAIL_SIZE_LIMIT:
|
|
shop.last_orders_sync_date = datetime.fromtimestamp(max(valid_update_times))
|
|
else:
|
|
shop.last_orders_sync_date = status_update_upper_limit
|
|
|
|
except utils.ShopeeRateLimitError as error:
|
|
_logger.info(
|
|
"Rate limit reached while synchronizing sales orders for Shopee account with"
|
|
" id %(account)d. Operation: %(error_operation)s",
|
|
{'account': shop.account_id.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 the order list from Shopee.
|
|
|
|
:param datetime time_from: Lower limit for pulling orders.
|
|
:param datetime time_to: Upper limit for pulling orders.
|
|
:return: A list composed of Shopee's orders' unique identifiers.
|
|
:rtype: list
|
|
"""
|
|
response = utils.make_shopee_api_request(self, 'get_order_list', {
|
|
'time_range_field': 'update_time',
|
|
'time_from': int(time_from.timestamp()),
|
|
'time_to': int(time_to.timestamp()),
|
|
'page_size': const.ORDER_LIST_SIZE_LIMIT,
|
|
'cursor': 0,
|
|
})
|
|
order_sn_list = [order['order_sn'] for order in response['order_list'] or []]
|
|
return order_sn_list
|
|
|
|
def _fetch_orders_detail(self, order_sn_list):
|
|
""" Fetches the order details from Shopee.
|
|
|
|
:param list order_sn_list: A list composed of Shopee's orders' unique identifiers.
|
|
:return: The orders detail.
|
|
:rtype: list
|
|
"""
|
|
# Slicing the order list in multiple batches of fewer orders than ORDER_DETAIL_SIZE_LIMIT
|
|
batches = [order_sn_list[i:i + const.ORDER_DETAIL_SIZE_LIMIT] for i in range(
|
|
0, len(order_sn_list), const.ORDER_DETAIL_SIZE_LIMIT
|
|
)]
|
|
fields_to_fetch = [
|
|
'buyer_user_id', 'buyer_username', 'estimated_shipping_fee', 'recipient_address',
|
|
'actual_shipping_fee', 'item_list', 'actual_shipping_fee_confirmed',
|
|
'fulfillment_flag', 'package_list', 'shipping_carrier', 'total_amount'
|
|
]
|
|
fields_to_fetch = ','.join(fields_to_fetch)
|
|
orders_detail = []
|
|
for batch in batches:
|
|
response = utils.make_shopee_api_request(
|
|
self,
|
|
'get_order_detail',
|
|
{'order_sn_list': ','.join(batch), 'response_optional_fields': fields_to_fetch},
|
|
)
|
|
orders_detail += response.get('order_list', [])
|
|
|
|
return orders_detail
|
|
|
|
def _fetch_order_income(self, order_sn):
|
|
"""Fetch the buyer-side income breakdown of a Shopee order from its escrow detail.
|
|
|
|
Only the order-level discounts paid by the buyer are read by the caller
|
|
(``voucher_from_seller``, ``voucher_from_shopee``, ``coins``); seller-side fees are out of
|
|
scope. An empty dict is returned when the escrow detail is not yet available so that order
|
|
creation can proceed, the residue then falling into the amount adjustment line as before.
|
|
|
|
:param str order_sn: Shopee's unique order identifier.
|
|
:return: The ``order_income`` mapping, or an empty dict.
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
try:
|
|
response = utils.make_shopee_api_request(
|
|
self, "get_escrow_detail", {"order_sn": order_sn}
|
|
)
|
|
except utils.ShopeeRateLimitError:
|
|
raise
|
|
except (UserError, ValidationError):
|
|
_logger.warning(
|
|
"Could not fetch the escrow detail for Shopee order %(ref)s at shop with id"
|
|
" %(id)s; order-level discounts will fall into the amount adjustment line.",
|
|
{"ref": order_sn, "id": self.id},
|
|
)
|
|
return {}
|
|
return response.get("order_income") or {}
|
|
|
|
def _process_order_data(self, order_data):
|
|
""" Process the provided order details and return the matching sales order, if any.
|
|
|
|
- If the order doesn't exist in Odoo and order status in const.ORDER_STATUSES_TO_SYNC, an
|
|
order will be created with the data from Shopee.
|
|
- If the order exists in Odoo, depending on the status:
|
|
-> Status = "CANCELLED" -> we cancel the order in Odoo if it's not already cancelled.
|
|
-> Status = "SHIPPED"/"COMPLETED" -> we update the status of the order in Odoo and
|
|
validate the picking.
|
|
|
|
Note: self.ensure_one()
|
|
|
|
:param dict order_data: The order data to process.
|
|
:return: The matching Shopee order, if any, as a `sale.order` record.
|
|
:rtype: recordset of `sale.order`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
shopee_order_ref = order_data['order_sn']
|
|
shopee_status = order_data['order_status']
|
|
order = self.env['sale.order'].search(
|
|
[('shopee_order_ref', '=', shopee_order_ref), ('shopee_shop_id', '=', self.id)], limit=1
|
|
)
|
|
fulfillment_type = const.FULFILLMENT_TYPE_MAPPING[order_data['fulfillment_flag']]
|
|
if not order:
|
|
if shopee_status in const.ORDER_STATUSES_TO_SYNC[fulfillment_type]:
|
|
order = self._create_order_from_data(order_data)
|
|
if order.shopee_fulfillment_type == 'fbs':
|
|
self._generate_stock_moves(order)
|
|
elif order.shopee_fulfillment_type in ['fbm', 'hybrid']:
|
|
order.with_context(mail_notrack=True).action_lock()
|
|
_logger.info(
|
|
"Created a new sales order with shopee_order_ref %(ref)s for Shopee shop"
|
|
" with id %(id)s.",
|
|
{'ref': shopee_order_ref, 'id': self.id},
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"Ignored Shopee order with reference %(ref)s and status %(status)s for Shopee"
|
|
" shop with id %(id)s.",
|
|
{'ref': shopee_order_ref, 'status': shopee_status, 'id': self.id},
|
|
)
|
|
else: # order exists, update the status
|
|
if shopee_status == 'CANCELLED' and order.state != 'cancel':
|
|
order._action_cancel()
|
|
_logger.info(
|
|
"Cancelled sales order with shopee_order_ref %(ref)s for Shopee shop with id"
|
|
" %(id)s.",
|
|
{'ref': shopee_order_ref, 'id': self.id},
|
|
)
|
|
elif shopee_status in ('SHIPPED', 'COMPLETED') and fulfillment_type != 'fbs':
|
|
# If the order is shipped or completed in Shopee, it means Shopee identifies the
|
|
# package as picked up by the carrier. We then mark the picking as done in Odoo.
|
|
# This avoids fetching the shipping label again in stock picking.
|
|
valid_picks = order.picking_ids.filtered(lambda pick: pick.state != 'cancel')
|
|
not_stored_label = valid_picks.filtered(lambda p: p.shopee_label_status != 'stored')
|
|
if not_stored_label:
|
|
not_stored_label.shopee_label_status = 'stored'
|
|
_logger.info(
|
|
"Forced label to be stored for sales order with Shopee order"
|
|
" reference %(ref)s and Shopee shop with id %(id)s.",
|
|
{'ref': shopee_order_ref, 'id': self.id},
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"Ignored already synchronized sales order with shopee_order_ref %(ref)s for"
|
|
" Shopee shop with id %(id)s.",
|
|
{'ref': shopee_order_ref, 'id': self.id},
|
|
)
|
|
|
|
# Update the delivery status to keep it in sync with Shopee
|
|
order.shopee_delivery_status = const.DELIVERY_STATUS_MAPPING.get(shopee_status, 'error')
|
|
return order
|
|
|
|
def _create_order_from_data(self, order_data):
|
|
""" Create the order from data.
|
|
|
|
:param dict order_data: A dict of order data.
|
|
:return: The created order.
|
|
:rtype: sale.order
|
|
"""
|
|
shipping_code = order_data.get("shipping_carrier")
|
|
shipping_product = self._find_matching_product(
|
|
shipping_code, 'default_shipping_product', 'Shopee Shipping', 'service'
|
|
)
|
|
currency = self.env['res.currency'].with_context(active_test=False).search(
|
|
[('name', '=', order_data['currency'])], limit=1
|
|
)
|
|
shopee_order_ref = order_data['order_sn']
|
|
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)
|
|
|
|
order_income = self._fetch_order_income(shopee_order_ref)
|
|
order_lines_values = (
|
|
self.with_context(order_income=order_income)
|
|
._prepare_order_lines_values(order_data, currency, fiscal_position, shipping_product)
|
|
)
|
|
fulfillment_type = order_data['fulfillment_flag']
|
|
order_lines = [
|
|
Command.create(order_line_values) for order_line_values in order_lines_values
|
|
]
|
|
origin = _(
|
|
"Shopee Order %(shopee_order_ref)s at %(shop_name)s",
|
|
shopee_order_ref=shopee_order_ref,
|
|
shop_name=self.name,
|
|
)
|
|
delivery_status = const.DELIVERY_STATUS_MAPPING.get(order_data['order_status'], 'draft')
|
|
order_vals = {
|
|
'origin': origin,
|
|
'state': 'sale',
|
|
# The order is first created unlocked and later locked to trigger the creation of a
|
|
# stock picking if fulfilled by merchant.
|
|
'locked': fulfillment_type == 'fulfilled_by_shopee',
|
|
'date_order': datetime.fromtimestamp(order_data['create_time']),
|
|
'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,
|
|
'shopee_order_ref': shopee_order_ref,
|
|
'shopee_shop_id': self.id,
|
|
'shopee_fulfillment_type': const.FULFILLMENT_TYPE_MAPPING[fulfillment_type],
|
|
'shopee_delivery_status': delivery_status,
|
|
}
|
|
if fulfillment_type == 'fulfilled_by_shopee' and self.fbs_location_id.warehouse_id:
|
|
order_vals['warehouse_id'] = self.fbs_location_id.warehouse_id.id
|
|
order = self.env['sale.order'].with_context(
|
|
mail_create_nosubscribe=True
|
|
).with_company(self.company_id).create(order_vals)
|
|
|
|
self._adjust_order_total(order, currency, order_data["total_amount"])
|
|
|
|
if order.picking_ids and shipping_code: # The buyer chose a specific delivery method
|
|
delivery_method = self._find_or_create_delivery_carrier(shipping_code, shipping_product)
|
|
order.picking_ids.carrier_id = delivery_method
|
|
return order
|
|
|
|
def _adjust_order_total(self, order, currency, shopee_total):
|
|
"""Adjust the order total to reconcile with Shopee's ``total_amount``.
|
|
|
|
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 shopee_total: The total amount from Shopee.
|
|
"""
|
|
residue = shopee_total - order.amount_total
|
|
if not order.amount_total or currency.is_zero(residue):
|
|
return
|
|
|
|
adjustment_product = self._find_matching_product(
|
|
"", "default_adjustment_product", "Shopee Amount Adjustment", "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(
|
|
"", "default_discount_product", "Shopee Order Discount", "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(tax_group_bases.items(), key=lambda item: item[1], reverse=True)
|
|
|
|
discount_lines_values = []
|
|
allocated_discount = 0.0
|
|
for index, (taxes, base) 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 * base / total_base)
|
|
allocated_discount += group_discount
|
|
if currency.is_zero(group_discount):
|
|
continue
|
|
discount_subtotal = self._recompute_subtotal(
|
|
-group_discount, 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=taxes.ids,
|
|
)
|
|
)
|
|
return discount_lines_values
|
|
|
|
def _find_matching_product(
|
|
self, internal_reference, default_xmlid, default_name, default_type, fallback=True
|
|
):
|
|
""" Find the matching product for a given internal reference.
|
|
|
|
If no product is found for the given internal reference, we fall back on the default
|
|
product. If the default product was deleted, we restore it.
|
|
|
|
:param str internal_reference: The internal reference of the product to be searched.
|
|
:param str default_xmlid: The xmlid of the default product to use as fallback.
|
|
:param str default_name: The name of the default product to use as fallback.
|
|
:param str default_type: The product type of the default product to use as fallback.
|
|
:param bool fallback: Whether we should fall back to the default product when no product
|
|
matching the provided internal reference is found.
|
|
:return: The matching product.
|
|
: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_shopee.{default_xmlid}', raise_if_not_found=False) \
|
|
or self.env['product.product']._restore_shopee_data_product(
|
|
default_name, default_type, default_xmlid
|
|
)
|
|
return product
|
|
|
|
def _find_or_create_partners_from_data(self, order_data):
|
|
""" Find or create the partners from data
|
|
|
|
:param dict order_data: A dict of order data.
|
|
:return: The contact partner and the delivery partner.
|
|
:rtype: tuple(res.partner, res.partner)
|
|
"""
|
|
|
|
def get_streets_from_full_address(address_):
|
|
""" Town, district and street will be stored in street and street2. """
|
|
blocks_ = address_.get('full_address').split(', ')
|
|
for key, value in address_.items():
|
|
if key in ['city', 'state', 'region', 'zipcode'] and value in blocks_:
|
|
blocks_.remove(value)
|
|
middle_index_ = math.ceil(len(blocks_) / 2)
|
|
street_ = ", ".join(blocks_[:middle_index_])
|
|
street2_ = ", ".join(blocks_[middle_index_:])
|
|
return street_, street2_ if street2_ else False
|
|
|
|
self.ensure_one()
|
|
|
|
shopee_order_ref = order_data['order_sn']
|
|
shopee_buyer_identifier = order_data['buyer_user_id']
|
|
buyer_name = order_data['buyer_username']
|
|
shipping_address_name = order_data['recipient_address']['name']
|
|
city = order_data['recipient_address']['city']
|
|
street, street2 = get_streets_from_full_address(order_data['recipient_address'])
|
|
zip_code = order_data['recipient_address']['zipcode']
|
|
country_code = order_data['recipient_address']['region']
|
|
state_name = order_data['recipient_address']['state']
|
|
phone = order_data['recipient_address']['phone']
|
|
country = self.env['res.country'].search([('code', '=', country_code)], limit=1)
|
|
state = self.env['res.country.state']
|
|
if country and state_name:
|
|
state = self.env['res.country.state'].search([
|
|
('name', '=ilike', state_name), # shopee can return a state name in capital letters
|
|
('country_id', '=', country.id),
|
|
], limit=1)
|
|
shopee_buyer_ref_field, shopee_buyer_identifier = (
|
|
("shopee_buyer_identifier", shopee_buyer_identifier)
|
|
if not shopee_buyer_identifier or shopee_buyer_identifier < 2**31
|
|
else ("ref", f"Shopee-Buyer-{shopee_buyer_identifier}")
|
|
)
|
|
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,
|
|
shopee_buyer_ref_field: shopee_buyer_identifier,
|
|
}
|
|
|
|
# The contact partner is searched based on all the personal information and only if the
|
|
# user id of the buyer is provided. A match thus only occurs if the customer had already
|
|
# made a previous order and if the personal information provided by the API did not change
|
|
# in the meantime. If there is no match, a new contact partner is created. This behavior is
|
|
# preferred over updating the personal information with new values because it allows using
|
|
# the correct contact details when invoicing the customer for an earlier order, should there
|
|
# be a change in the personal information.
|
|
contact = self.env['res.partner'].search([
|
|
*self.env['product.pricelist']._check_company_domain(self.company_id),
|
|
('type', '=', 'contact'),
|
|
('name', '=', buyer_name),
|
|
(shopee_buyer_ref_field, "=", shopee_buyer_identifier),
|
|
], limit=1) if shopee_buyer_identifier else None # Don't match random partners.
|
|
if not contact:
|
|
contact_name = buyer_name or _("Shopee Customer #%(order_no)s", order_no=shopee_order_ref)
|
|
contact = self.env['res.partner'].with_context(tracking_disable=True).create({
|
|
'name': contact_name,
|
|
**partner_vals,
|
|
})
|
|
|
|
# The contact partner acts as delivery partner if the address is strictly equal to that of
|
|
# the contact partner. If not, a delivery partner is created.
|
|
delivery = contact if (
|
|
contact.name == shipping_address_name
|
|
and contact.street == street
|
|
and contact.street2 == street2
|
|
and contact.zip == zip_code
|
|
and contact.city == city
|
|
and contact.country_id.id == country.id
|
|
and contact.state_id.id == state.id
|
|
) else None
|
|
if not delivery:
|
|
delivery = self.env['res.partner'].search([
|
|
*self.env['res.partner']._check_company_domain(self.company_id),
|
|
('type', '=', 'delivery'),
|
|
('parent_id', '=', contact.id),
|
|
('name', '=', shipping_address_name),
|
|
('street', '=', street),
|
|
('street2', '=', street2),
|
|
('zip', '=', zip_code),
|
|
('city', '=', city),
|
|
('country_id', '=', country.id),
|
|
('state_id', '=', state.id),
|
|
], limit=1)
|
|
if not delivery:
|
|
delivery = self.env['res.partner'].with_context(tracking_disable=True).create({
|
|
'name': shipping_address_name,
|
|
'type': 'delivery',
|
|
'parent_id': contact.id,
|
|
**partner_vals,
|
|
})
|
|
|
|
return contact, delivery
|
|
|
|
def _prepare_order_lines_values(self, order_data, currency, fiscal_pos, shipping_product):
|
|
"""Prepare the sale order lines values from the Shopee order data.
|
|
|
|
Each Shopee item becomes one `sale.order.line` values dict. The buyer-funded order-level
|
|
discount (vouchers and coins, read from the escrow ``order_income`` context key) is
|
|
distributed across the product tax groups, and shipping is appended as a final line when
|
|
the buyer-paid shipping fee is non-zero.
|
|
|
|
:param dict order_data: The order data from the Shopee API.
|
|
:param res.currency currency: The currency of the sale order.
|
|
:param account.fiscal.position fiscal_pos: The fiscal position of the sale order.
|
|
:param record shipping_product: The shipping product matching the shipping code, as a
|
|
`product.product` record.
|
|
: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 = {}
|
|
for item_data in order_data['item_list']:
|
|
sku = item_data['model_sku'] or item_data['item_sku']
|
|
fulfillment_type = const.FULFILLMENT_TYPE_MAPPING[order_data['fulfillment_flag']]
|
|
shopee_item = self._find_or_create_item(
|
|
sku, item_data['item_id'], item_data['model_id'], fulfillment_type
|
|
)
|
|
product_taxes = shopee_item.product_id.taxes_id._filter_taxes_by_company(
|
|
self.company_id
|
|
)
|
|
taxes = fiscal_pos.map_tax(product_taxes)
|
|
|
|
quantity = item_data['model_quantity_purchased']
|
|
paid_total = quantity * float(item_data["model_discounted_price"])
|
|
|
|
description = self._build_item_description(sku, item_data, currency)
|
|
subtotal = self._recompute_subtotal(paid_total, taxes, currency)
|
|
|
|
order_lines_values.append(
|
|
self._convert_to_order_line_values(
|
|
product_id=shopee_item.product_id.id,
|
|
subtotal=subtotal,
|
|
description=description,
|
|
quantity=quantity,
|
|
tax_ids=taxes.ids,
|
|
)
|
|
)
|
|
tax_group_bases[taxes] = tax_group_bases.get(taxes, 0.0) + paid_total
|
|
|
|
# Order-level discount lines (vouchers, coins), distributed per tax group.
|
|
discount_total = (
|
|
float(order_income.get("voucher_from_seller") or 0)
|
|
+ float(order_income.get("voucher_from_shopee") or 0)
|
|
+ float(order_income.get("coins") or 0)
|
|
)
|
|
order_lines_values += self._prepare_discount_lines_values(
|
|
currency, tax_group_bases, discount_total
|
|
)
|
|
|
|
# Shipping line. The buyer-paid fee comes from the escrow detail (it nets free-shipping
|
|
# subsidies, including a genuine 0); when the escrow detail is unavailable it defaults to 0
|
|
# and the shipping amount falls into the amount adjustment line instead.
|
|
shipping_fee = float(order_income.get("buyer_paid_shipping_fee", 0))
|
|
if not currency.is_zero(shipping_fee):
|
|
shipping_code = order_data.get("shipping_carrier")
|
|
shipping_product = self._find_matching_product(
|
|
shipping_code, "default_shipping_product", "Shopee Shipping", "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._recompute_subtotal(shipping_fee, shipping_taxes, currency)
|
|
order_lines_values.append(
|
|
self._convert_to_order_line_values(
|
|
product_id=shipping_product.id,
|
|
subtotal=shipping_subtotal,
|
|
description=self.env._("Shipping: %(carrier)s", carrier=shipping_code),
|
|
tax_ids=shipping_taxes.ids,
|
|
)
|
|
)
|
|
|
|
return order_lines_values
|
|
|
|
def _find_or_create_item(self, sku, item_identifier, model_identifier, fulfillment_type='fbm'):
|
|
""" Find or create the shopee item based on the SKU and shop.
|
|
|
|
Note: self.ensure_one()
|
|
|
|
:param str sku: The SKU of the product.
|
|
:param str item_identifier: The Shopee item identifier.
|
|
:param str model_identifier: The Shopee model identifier.
|
|
:param str fulfillment_type: The fulfillment type of the item.
|
|
:return: The found or created shopee item
|
|
:rtype: shopee.item
|
|
"""
|
|
self.ensure_one()
|
|
sync_to_shopee = fulfillment_type in ['fbm', 'hybrid']
|
|
shopee_item = self.shopee_item_ids.filtered(
|
|
lambda i: i.shopee_item_identifier == str(item_identifier)
|
|
)
|
|
if model_identifier:
|
|
shopee_item = shopee_item.filtered(
|
|
lambda i: i.shopee_model_identifier == str(model_identifier)
|
|
)
|
|
if not shopee_item:
|
|
shopee_item = self.env['shopee.item'].with_context(tracking_disable=True).create({
|
|
'product_id': self._find_matching_product(
|
|
sku, 'default_sale_product', 'Shopee Sales', 'consu'
|
|
).id,
|
|
'shop_id': self.id,
|
|
'shopee_item_identifier': item_identifier,
|
|
'shopee_model_identifier': model_identifier,
|
|
'sync_to_shopee': sync_to_shopee,
|
|
})
|
|
# If the item has been linked with the default product, search if another product has now
|
|
# been assigned the current SKU as internal reference and update the item if so.
|
|
# This trades off a bit of performance in exchange for a more expected behavior for the
|
|
# matching of products if one was assigned the right SKU after that the item was created.
|
|
elif 'sale_shopee.default_sale_product' in shopee_item.product_id._get_external_ids().get(
|
|
shopee_item.product_id.id, []
|
|
):
|
|
product = self._find_matching_product(sku, '', '', '', fallback=False)
|
|
if product:
|
|
shopee_item.product_id = product.id
|
|
|
|
return shopee_item
|
|
|
|
def _get_promotion_type(self, promotion_type):
|
|
""" Get the promotion type from the promotion type mapping.
|
|
|
|
:param str promotion_type: The promotion type.
|
|
:return: The promotion type.
|
|
:rtype: str
|
|
"""
|
|
promotion_type_mapping = {
|
|
'product_promotion': _("Product Promotion"),
|
|
'flash_sale': _("Flash Sale"),
|
|
'bundle_deal': _("Bundle Deal"),
|
|
'add_on_deal_main': _("Add-on Deal Main"),
|
|
'add_on_deal_sub': _("Add-on Deal Sub"),
|
|
}
|
|
return promotion_type_mapping.get(promotion_type, _("Unknown Promotion Type"))
|
|
|
|
def _recompute_subtotal(self, total, taxes, currency):
|
|
""" Compute the subtotal from the taxes.
|
|
|
|
Shopee does not include 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 Shopee, 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 _convert_to_order_line_values(self, **kwargs):
|
|
"""Convert values to sale order line format.
|
|
|
|
:param dict kwargs: Values including product_id, quantity, subtotal, 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')
|
|
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,
|
|
}
|
|
|
|
def _build_item_description(self, sku, item_data, currency):
|
|
"""Build the base description text for a Shopee item line.
|
|
|
|
Includes header, optional promotion line (guarded against absence of
|
|
`promotion_id` to stay robust against future Shopee API changes), 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 Shopee 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['item_name']}"]
|
|
promotion_id = item_data.get("promotion_id")
|
|
if promotion_id:
|
|
lines.append(
|
|
self.env._(
|
|
"Promotion: %(promotion_type)s - id: %(promotion_id)d\nOriginal price: %(price)s",
|
|
promotion_type=self._get_promotion_type(item_data.get("promotion_type")),
|
|
promotion_id=promotion_id,
|
|
price=currency.format(float(item_data["model_original_price"])),
|
|
)
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
def _find_or_create_pricelist(self, currency):
|
|
""" Find or create the pricelist.
|
|
|
|
:param res.currency currency: The currency of the pricelist
|
|
:rtype: product.pricelist
|
|
"""
|
|
self.ensure_one()
|
|
pricelist = self.env['product.pricelist'].with_context(active_test=False).search([
|
|
*self.env['product.pricelist']._check_company_domain(self.company_id),
|
|
('currency_id', '=', currency.id),
|
|
], limit=1)
|
|
if not pricelist:
|
|
pricelist = self.env['product.pricelist'].with_context(tracking_disable=True).create({
|
|
'name': _("Shopee Pricelist %(currency)s", currency=currency.name),
|
|
'active': False,
|
|
'currency_id': currency.id,
|
|
'company_id': self.company_id.id,
|
|
})
|
|
return pricelist
|
|
|
|
def _find_or_create_delivery_carrier(self, shipping_code, shipping_product):
|
|
""" Find or create a delivery carrier based on the shipping code.
|
|
|
|
:param str shipping_code: The shipping code.
|
|
:param record shipping_product: The shipping product matching the shipping code, as a
|
|
`product.product` record.
|
|
:return: The delivery carrier.
|
|
:rtype: delivery.carrier
|
|
"""
|
|
delivery_method = self.env['delivery.carrier'].search(
|
|
[('name', '=', shipping_code)], limit=1,
|
|
)
|
|
if not delivery_method:
|
|
delivery_method = self.env['delivery.carrier'].create({
|
|
'name': shipping_code, 'product_id': shipping_product.id
|
|
})
|
|
return delivery_method
|
|
|
|
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 l: l.product_id.type == 'consu'):
|
|
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.fbs_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 _handle_sync_failure(self, flow='', shopee_order_ref=False, error_messages=False):
|
|
""" Send a mail to the responsible persons to report a synchronization failure.
|
|
|
|
:param str flow: The flow for which the failure mail is requested. Supported flows are:
|
|
`order_sync`, `inventory_sync`, and `picking_sync`.
|
|
:param str shopee_order_ref: The shopee references of the orders that failed to synchronize.
|
|
:param list[dict] error_messages: A list containing the referenced Shopee orders and their
|
|
linked errors in the format [{"order_ref": "error"}].
|
|
Required for the `picking_sync` flow.
|
|
:return: None
|
|
"""
|
|
if flow == 'order_sync':
|
|
_logger.exception(
|
|
"Failed to synchronize order with shopee reference %(ref)s for shopee.shop with "
|
|
"id %(shop_id)s (Shope Name: %(shop_name)s)."
|
|
"Please create the order manually.",
|
|
{'ref': shopee_order_ref, 'shop_id': self.id, 'shop_name': self.name},
|
|
)
|
|
mail_template_id = 'sale_shopee.order_sync_failure'
|
|
elif flow == 'inventory_sync':
|
|
_logger.exception(
|
|
"Failed to synchronize the inventory for items in shopee.shop with id "
|
|
"%(shop_id)s (Shope Name: %(shop_name)s).",
|
|
{'shop_id': self.id, 'shop_name': self.name},
|
|
)
|
|
mail_template_id = 'sale_shopee.inventory_sync_failure'
|
|
else: # flow == 'picking_sync':
|
|
_logger.exception(
|
|
"Failed to synchronize pickings for shopee.shop with id "
|
|
"%(shop_id)s (Shope Name: %(shop_name)s).",
|
|
{'shop_id': self.id, 'shop_name': self.name},
|
|
)
|
|
mail_template_id = 'sale_shopee.picking_sync_failure'
|
|
|
|
mail_template = self.env.ref(mail_template_id, raise_if_not_found=False)
|
|
if not mail_template:
|
|
_logger.warning(
|
|
"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),
|
|
shopee_order_ref=shopee_order_ref,
|
|
error_messages=error_messages,
|
|
shopee_shop=self.name,
|
|
).send_mail(self.env.user.id)
|
|
_logger.info(
|
|
"Sent synchronization failure notification email to %(emails)s",
|
|
{'emails': ', '.join(responsible_emails)},
|
|
)
|
|
|
|
def _sync_inventory(self, auto_commit=True):
|
|
""" Synchronize the inventory level of products sold on Shopee.
|
|
|
|
If called on an empty recordset, the products of all active shops with inventory
|
|
synchronization are synchronized instead.
|
|
|
|
:param bool auto_commit: Whether the database cursor should be committed as soon as an item
|
|
is successfully synchronized.
|
|
:return: None
|
|
"""
|
|
shops = self or self.search([])
|
|
for shop in shops:
|
|
shop._update_shop_information()
|
|
if shop.status != 'active' or not shop.synchronize_inventory:
|
|
continue
|
|
shop.shopee_item_ids._sync_inventory(auto_commit)
|
|
|
|
def _reset_tokens(self):
|
|
self.update({
|
|
'access_token': False,
|
|
'access_token_expiration_date': False,
|
|
'refresh_token': False,
|
|
'authorization_expiration_date': False,
|
|
})
|