Files
enterprise-addons/sale_amazon/models/amazon_account.py
T

1508 lines
70 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from collections import defaultdict
from datetime import timedelta
import dateutil.parser
from markupsafe import Markup
from werkzeug.urls import url_encode
from odoo import _, api, exceptions, fields, models, modules
from odoo.exceptions import UserError, ValidationError
from odoo.service.model import PG_CONCURRENCY_EXCEPTIONS_TO_RETRY as CONCURRENCY_ERRORS
from odoo.tools import urls
from odoo.addons.sale_amazon import const
from odoo.addons.sale_amazon import utils as amazon_utils
from odoo.addons.sale_amazon.controllers.onboarding import compute_oauth_signature
_logger = logging.getLogger(__name__)
class AmazonAccount(models.Model):
_name = 'amazon.account'
_description = "Amazon Account"
_check_company_auto = True
name = fields.Char(
string="Name",
help="The user-defined name of the account.",
compute='_compute_name',
store=True,
readonly=False,
required=True,
)
company_id = fields.Many2one(
string="Company",
comodel_name='res.company',
default=lambda self: self.env.company,
required=True,
readonly=True,
)
offer_ids = fields.One2many(
string="Offers", comodel_name='amazon.offer', inverse_name='account_id', bypass_search_access=True
)
base_marketplace_id = fields.Many2one(
string="Home Marketplace",
help="The home marketplace of this account; used for authentication only.",
comodel_name='amazon.marketplace',
required=True,
)
available_marketplace_ids = fields.Many2many(
string="Available Marketplaces",
help="The marketplaces this account has access to.",
comodel_name='amazon.marketplace',
relation='amazon_account_marketplace_rel',
copy=False,
)
active_marketplace_ids = fields.Many2many(
string="Marketplaces",
help="The marketplaces this account sells on.",
comodel_name='amazon.marketplace',
relation='amazon_account_active_marketplace_rel',
domain='[("id", "in", available_marketplace_ids)]',
copy=False,
)
default_product_ids = fields.Many2many(
string="Default Products",
comodel_name='product.product',
compute='_compute_default_product_ids',
context={'active_test': False},
)
user_id = fields.Many2one(
string="Salesperson",
comodel_name='res.users',
default=lambda self: self.env.user,
check_company=True,
)
team_id = fields.Many2one(
string="Sales Team",
help="The Sales Team assigned to Amazon orders for reporting",
comodel_name='crm.team',
check_company=True,
)
location_id = fields.Many2one(
string="FBA Stock Location",
help="The location of the stock managed by Amazon under the Amazon Fulfillment program.",
comodel_name='stock.location',
domain='[("usage", "=", "internal")]',
check_company=True,
)
synchronize_inventory = fields.Boolean(
string="FBM Stock",
help="Whether the available quantities of FBM products linked to this account are"
" synchronized with Amazon.",
default=True,
)
last_orders_sync = fields.Datetime(
help="The last synchronization date for orders placed on this account. Orders whose status"
" has not changed since this date will not be created nor updated in Odoo.",
default=fields.Datetime.now,
required=True,
)
active = fields.Boolean(
string="Active",
help="If made inactive, this account will no longer be synchronized with Amazon.",
default=True,
required=True,
)
# Credentials fields.
seller_key = fields.Char()
refresh_token = fields.Char(
string="LWA Refresh Token",
help="The long-lived token that can be exchanged for a new access token.",
)
# The API credentials fields below are not stored because they are all short-lived. Their values
# are kept in memory for the duration of the request and they are re-used as long as they are
# not expired. If that happens, they are refreshed through an API call.
access_token = fields.Char(
string="LWA Access Token",
help="The short-lived token used to query Amazon API on behalf of a seller.",
store=False,
)
access_token_expiry = fields.Datetime(
string="LWA Access Token Expiry",
help="The moment at which the token becomes invalid.",
default='1970-01-01',
store=False,
)
restricted_data_token = fields.Char(
string="Restricted Data Token",
help="The short-lived token used instead of the LWA Access Token to access restricted data",
store=False,
)
restricted_data_token_expiry = fields.Datetime(
string="Restricted Data Token Expiry",
help="The moment at which the Restricted Data Token becomes invalid.",
default='1970-01-01',
store=False,
)
# Display fields.
state = fields.Selection(
string="State",
selection=[('disconnected', "Disconnected"), ('connected', "Connected")],
compute='_compute_state',
)
offer_count = fields.Integer(compute='_compute_offer_count')
order_count = fields.Integer(compute='_compute_order_count')
#=== COMPUTE METHODS ===#
@api.depends('base_marketplace_id')
def _compute_name(self):
for account in self:
if account.base_marketplace_id:
account.name = account.base_marketplace_id.name
def _compute_default_product_ids(self):
ProductProduct = self.env['product.product']
default_product = (
self.env.ref('sale_amazon.default_product', raise_if_not_found=False)
or ProductProduct._restore_data_product("Amazon Sales", 'consu', 'default_product')
)
shipping_product = (
self.env.ref('sale_amazon.shipping_product', raise_if_not_found=False)
or ProductProduct._restore_data_product("Amazon Shipping", 'consu', 'shipping_product')
)
for account in self:
account.default_product_ids = default_product + shipping_product
@api.depends('refresh_token')
def _compute_state(self):
for account in self:
account.state = 'connected' if account.refresh_token else 'disconnected'
def _compute_offer_count(self):
offers_data = self.env['amazon.offer']._read_group(
[('account_id', 'in', self.ids)], ['account_id'], ['__count']
)
accounts_data = {account.id: count for account, count in offers_data}
for account in self:
account.offer_count = accounts_data.get(account.id, 0)
def _compute_order_count(self):
orders_per_offer = self.env['sale.order.line']._read_group(
[('amazon_offer_id.account_id', 'in', self.ids)],
groupby=['amazon_offer_id'],
aggregates=['order_id:recordset'],
)
orders_per_account = defaultdict(lambda: self.env['sale.order'])
for offer, orders in orders_per_offer:
orders_per_account[offer.account_id] += orders
for account in self:
account.order_count = len(orders_per_account.get(account, []))
#=== ONCHANGE METHODS ===#
@api.onchange('last_orders_sync')
def _onchange_last_orders_sync(self):
""" Display a warning about the possible consequences of modifying the last orders sync. """
self.ensure_one()
if self._origin.id:
return {
'warning': {
'title': _("Warning"),
'message': _("If the date is set in the past, orders placed on this Amazon "
"Account before the first synchronization of the module might be "
"synchronized with Odoo.\n"
"If the date is set in the future, orders placed on this Amazon "
"Account between the previous and the new date will not be "
"synchronized with Odoo.")
}
}
#=== CONSTRAINT METHODS ===#
@api.constrains('active_marketplace_ids')
def _check_actives_subset_of_availables(self):
for account in self:
if account.active_marketplace_ids.filtered(
lambda m: m.id not in account.available_marketplace_ids.ids):
raise exceptions.ValidationError(_("Only available marketplaces can be selected"))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
amazon_accounts_rg = self._read_group([], ['team_id', 'location_id'])
amazon_teams_ids = [team.id for team, __ in amazon_accounts_rg]
amazon_locations_ids = [location.id for __, location in amazon_accounts_rg]
for vals in vals_list:
# Find or create the location of the Amazon warehouse to be associated with this account
location = self.env['stock.location'].search([
*self.env['stock.location']._check_company_domain(vals.get('company_id')),
('id', 'in', amazon_locations_ids),
], limit=1)
if not location:
parent_location_data = self.env['stock.warehouse'].search_read(
[*self.env['stock.warehouse']._check_company_domain(vals.get('company_id'))],
['view_location_id'],
limit=1,
)
location = self.env['stock.location'].create({
'name': 'Amazon',
'usage': 'internal',
'location_id': parent_location_data[0]['view_location_id'][0] if parent_location_data else False,
'company_id': vals.get('company_id'),
})
vals.update({'location_id': location.id})
# Find or create the sales team to be associated with this account
team = self.env['crm.team'].search([
*self.env['crm.team']._check_company_domain(vals.get('company_id')),
('id', 'in', amazon_teams_ids),
], limit=1)
if not team:
team = self.env['crm.team'].create({
'name': 'Amazon',
'company_id': vals.get('company_id'),
})
vals.update({'team_id': team.id})
return super().create(vals_list)
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_to_so(self):
has_linked_so_line = self.env['sale.order.line'].search(
[('amazon_offer_id.account_id', 'in', self.ids)], limit=1
)
if has_linked_so_line:
raise UserError(_(
"You cannot delete an account that is linked to sale orders. Please archive it"
" instead."
))
#=== ACTION METHODS ===#
def action_archive(self):
""" Override to disconnect the Amazon account before archiving it. """
for account in self:
account.action_reset_refresh_token()
return super().action_archive()
def action_redirect_to_oauth_url(self):
""" Build the OAuth redirect URL and redirect the user to it.
See step 1 of https://developer-docs.amazon.com/sp-api/docs/website-authorization-workflow.
Note: self.ensure_one()
:return: An action to redirect the user to the OAuth URL.
:rtype: ir.actions.act_url action
"""
self.ensure_one()
base_seller_central_url = self.base_marketplace_id.seller_central_url
oauth_url = urls.urljoin(base_seller_central_url, '/apps/authorize/consent')
base_database_url = self.get_base_url()
metadata = {
'account_id': self.id,
'return_url': urls.urljoin(base_database_url, 'amazon/return'),
'signature': compute_oauth_signature(self.id),
} # The metadata included in the redirect URL after authorizing the app on Amazon.
oauth_url_params = {
'application_id': const.APP_ID,
'state': json.dumps(metadata),
}
return {
'type': 'ir.actions.act_url',
'url': f'{oauth_url}?{url_encode(oauth_url_params)}',
'target': 'self',
}
def action_reset_refresh_token(self):
""" Reset the refresh token of the account.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
self.refresh_token = None
def action_update_available_marketplaces(self):
""" Update available marketplaces and assign new ones to the account.
:return: A rainbow-man action to inform the user about the successful update.
:rtype: dict
"""
for account in self:
available_marketplaces = account._get_available_marketplaces()
new_marketplaces = available_marketplaces - account.available_marketplace_ids
account.write({'available_marketplace_ids': [(6, 0, available_marketplaces.ids)]})
# Remove active marketplace that are no longer available
account.active_marketplace_ids &= account.available_marketplace_ids
account.active_marketplace_ids += new_marketplaces
return {
'effect': {
'type': 'rainbow_man',
'message': _("Successfully updated the marketplaces available to this account!"),
}
}
def action_view_offers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Offers'),
'res_model': 'amazon.offer',
'view_mode': 'list',
'domain': [('account_id', '=', self.id)],
'context': {'default_account_id': self.id},
}
def action_view_orders(self):
self.ensure_one()
order_lines = self.env['sale.order.line'].search(
[('amazon_offer_id', '!=', False), ('amazon_offer_id.account_id', '=', self.id)]
)
return {
'type': 'ir.actions.act_window',
'name': _('Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('id', 'in', order_lines.order_id.ids)],
'context': {'create': False},
}
def action_sync_orders(self):
self._sync_orders()
def action_sync_pickings(self):
self.env['stock.picking']._sync_pickings(tuple(self.ids))
def action_sync_inventory(self):
self._sync_inventory()
def action_sync_feeds_status(self):
self._sync_feeds()
def action_recover_order(self):
self.ensure_one()
return {
'name': _("Recover Order"),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'amazon.recover.order.wizard',
'target': 'new',
}
#=== BUSINESS METHODS ===#
def _get_available_marketplaces(self):
""" Fetch the API refs of the available marketplaces and return the corresponding recordset.
Note: self.ensure_one()
:return: The available marketplaces for the Amazon account.
:rtype: recordset of `amazon.marketplace`
:raise UserError: If the rate limit is reached.
"""
self.ensure_one()
amazon_utils.ensure_account_is_set_up(self, require_marketplaces=False)
try:
response_content = amazon_utils.make_sp_api_request(
self, 'getMarketplaceParticipations'
)
except amazon_utils.AmazonRateLimitError:
_logger.info(
"Rate limit reached while updating available marketplaces for Amazon account with "
"id %s.", self.id
)
raise UserError(_(
"You reached the maximum number of requests for this operation; please try again "
"later."
))
else:
available_marketplace_api_refs = [
marketplace['marketplace']['id'] for marketplace in response_content['payload']
]
return self.env['amazon.marketplace'].search(
[('api_ref', 'in', available_marketplace_api_refs)]
)
def _sync_orders(self, auto_commit=True):
""" Synchronize the accounts' sales orders that were recently updated on Amazon.
If called on an empty recordset, the orders of all active accounts are synchronized instead.
Note: This method is called by the `ir_cron_sync_amazon_orders` cron.
:param bool auto_commit: Whether the database cursor should be committed as soon as an order
is successfully synchronized.
:return: None
"""
accounts = self or self.search([])
for account in accounts:
account = account[0] # Avoid pre-fetching after each cache invalidation.
amazon_utils.ensure_account_is_set_up(account)
# The last synchronization date of the account is used as the lower limit on the orders'
# last status update date. The upper limit is determined by the API and returned with
# the request response, then saved on the account if the synchronization goes through.
sync_start = fields.Datetime.now()
last_updated_after = account.last_orders_sync # Lower limit for pulling orders.
status_update_upper_limit = None # Upper limit of synchronized orders.
# Pull all recently updated orders and save the progress during synchronization.
payload = {
'lastUpdatedAfter': last_updated_after.isoformat(sep='T') + 'Z',
'marketplaceIds': ','.join(account.active_marketplace_ids.mapped('api_ref')),
'includedData': (
'BUYER,'
'RECIPIENT,'
'FULFILLMENT,'
'PROCEEDS,'
'PROMOTION'
),
}
try:
# Orders are pulled in batches of up to 100 orders. If more can be synchronized, the
# request results are paginated and the next page holds another batch.
has_next_page = True
while has_next_page:
# Pull the next batch of orders data.
orders_batch_data, has_next_page = amazon_utils.pull_batch_data(
account, 'searchOrders', payload
)
orders_data = orders_batch_data['orders']
if last_updated_before := orders_batch_data.get('lastUpdatedBefore'):
status_update_upper_limit = dateutil.parser.parse(last_updated_before)
# Process the batch one order data at a time.
for order_data in orders_data:
try:
if auto_commit:
with self.env.cr.savepoint():
account._process_order_data(order_data)
else: # Avoid the savepoint in testing
account._process_order_data(order_data)
except amazon_utils.AmazonRateLimitError:
raise # Don't treat a rate limit error as a business error.
except Exception as error:
if modules.module.current_test:
# we are executing during testing, do not try to rollback
raise
amazon_order_ref = order_data['orderId']
if isinstance(error, CONCURRENCY_ERRORS):
_logger.info(
"A concurrency error occurred while processing the order data "
"with amazon_order_ref %s for Amazon account with id %s. "
"Discarding the error to trigger the retry mechanism.",
amazon_order_ref, account.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
else:
_logger.warning(
"A business error occurred while processing the order data "
"with amazon_order_ref %s for Amazon account with id %s. "
"Skipping the order data and moving to the next order.",
amazon_order_ref, account.id,
exc_info=True
)
# Dismiss business errors to allow the synchronization to skip the
# problematic orders and require synchronizing them manually.
self.env.cr.rollback()
account._handle_sync_failure(
flow='order_sync', amazon_order_ref=amazon_order_ref
)
continue # Skip these order data and resume with the next ones.
# The synchronization of this order went through, use its last status update
# as a backup and set it to be the last synchronization date of the account.
last_order_update = dateutil.parser.parse(order_data['lastUpdatedTime'])
account.last_orders_sync = last_order_update.replace(tzinfo=None)
if auto_commit:
with amazon_utils.preserve_credentials(account):
self.env.cr.commit() # Commit to mitigate an eventual cron kill.
except amazon_utils.AmazonRateLimitError as error:
_logger.info(
"Rate limit reached while synchronizing sales orders for Amazon account with "
"id %s. Operation: %s", account.id, error.operation
)
continue # The remaining orders will be pulled later when the cron runs again.
# There are no more orders to pull and the synchronization went through. Set the API
# upper limit on order status update to be the last synchronization date of the account.
if status_update_upper_limit:
account.last_orders_sync = status_update_upper_limit.replace(tzinfo=None)
else:
account.last_orders_sync = sync_start
def _sync_order_by_reference(self, amazon_order_ref):
""" Synchronize an order based on its Amazon order reference.
Note: `self.ensure_one()`
:param str amazon_order_ref: The amazon reference of the order to re-synchronize.
:return: The synchronized Amazon order act window.
:rtype: dict
:raise UserError: If the order reference is incorrect or the order is not for an active
marketplace.
:raise ValidationError: If the order is in a status that prevents its synchronization.
"""
self.ensure_one()
amazon_utils.ensure_account_is_set_up(self)
payload = {
'includedData': (
'BUYER,'
'RECIPIENT,'
'FULFILLMENT,'
'PROCEEDS,'
'PROMOTION'
),
}
order_data = amazon_utils.make_sp_api_request(
self, 'getOrder', path_parameter=amazon_order_ref, payload=payload
)['order']
if not order_data: # Order not found by Amazon
raise UserError(_("The provided reference does not match any Amazon order."))
if (
order_data['salesChannel'].get('marketplaceId')
not in self.active_marketplace_ids.mapped('api_ref')
):
raise UserError(_("The order was not found on this account's marketplaces."))
order = self._process_order_data(order_data)
if not order:
amazon_status = order_data['fulfillment']['fulfillmentStatus']
fulfillment_channel = order_data['fulfillment']['fulfilledBy']
raise ValidationError(_(
"The Amazon order with reference %(ref)s was not recovered because its status"
" (%(status)s) is not eligible for synchronization for its fulfillment channel"
" (%(channel)s).",
ref=amazon_order_ref,
status=amazon_status,
channel=fulfillment_channel,
))
return {
'name': order.display_name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order.id,
}
def _process_order_data(self, order_data):
""" Process the provided order data and return the matching sales order, if any.
If no matching sales order is found, a new one is created if it is in a 'synchronizable'
status: 'SHIPPED' or 'UNSHIPPED', if it is respectively an FBA or an FBM order. If the
matching sales order already exists and the Amazon order was canceled, the sales order is
also canceled. If the matching sales order already exists and the order data confirm that a
FBM order got shipped, we update the shipping status when it's needed.
Note: self.ensure_one()
:param dict order_data: The order data to process.
:return: The matching Amazon order, if any, as a `sale.order` record.
:rtype: recordset of `sale.order`
"""
self.ensure_one()
# Search for the sales order based on its Amazon order reference.
amazon_order_ref = order_data['orderId']
if not isinstance(amazon_order_ref, str):
raise TypeError(f"Invalid AmazonOrderId, should be a string: {amazon_order_ref!r}")
order = self.env['sale.order'].search(
[('amazon_order_ref', '=', amazon_order_ref)], limit=1
)
fulfillment_data = order_data['fulfillment']
amazon_status = fulfillment_data['fulfillmentStatus']
fulfillment_channel = fulfillment_data.get('fulfilledBy')
if not order: # No sales order was found with the given Amazon order reference.
if (
fulfillment_channel
and amazon_status in const.STATUS_TO_SYNCHRONIZE[fulfillment_channel]
):
# Create the sales order and generate stock moves depending on the Amazon channel.
order = self._create_order_from_data(order_data)
if order.amazon_channel == 'fba':
self._generate_stock_moves(order)
elif order.amazon_channel == 'fbm':
order.with_context(mail_notrack=True).action_lock()
_logger.info(
"Created a new sales order with amazon_order_ref %(ref)s for Amazon account"
" with id %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
else:
_logger.info(
"Ignored Amazon order with reference %(ref)s and status %(status)s for Amazon"
" account with id %(account_id)s.",
{'ref': amazon_order_ref, 'status': amazon_status, 'account_id': self.id},
)
else: # The sales order already exists.
unsynced_pickings = order.picking_ids.filtered(
lambda picking: picking.amazon_sync_status != 'done' and picking.state != 'cancel'
) # Consider any "unsynced" status so that we synchronize updates made from Amazon.
if amazon_status == 'CANCELLED' and order.state != 'cancel':
order._action_cancel()
_logger.info(
"Cancelled sales order with amazon_order_ref %(ref)s for Amazon account with id"
" %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
elif (
amazon_status == 'SHIPPED'
and fulfillment_channel == 'MERCHANT'
and unsynced_pickings
):
# This can happen in 3 cases:
# 1. The processing of the feed of a batch of pickings failed on Amazon side in a
# way that we couldn't tell which picking are faulty. In that case, all pickings of
# the batch were flagged as in error. The order status update allows correcting the
# status of non-faulty pickings while leaving the faulty ones in error.
# 2. The shipping was arranged directly from Amazon's backend.
# 3. The user uses a delivery method that contacted Amazon to send the picking
# information before we did.
unsynced_pickings.amazon_sync_status = 'done'
_logger.info(
"Forced the picking synchronization status to 'done' for sales order with"
" Amazon order reference %(ref)s and Amazon account with id %(id)s.",
{'ref': amazon_order_ref, 'id': self.id},
)
else:
_logger.info(
"Ignored already synchronized sales order with amazon_order_ref %(ref)s for"
" Amazon account with id %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
return order
def _create_order_from_data(self, order_data):
""" Create a new sales order based on the provided order data.
Note: self.ensure_one()
:param dict order_data: The order data to create a sales order from.
:return: The newly created sales order.
:rtype: record of `sale.order`
"""
self.ensure_one()
order_vals = self._prepare_order_values(order_data)
return self.env['sale.order'].with_context(
mail_create_nosubscribe=True
).with_company(self.company_id).create(order_vals)
def _prepare_order_values(self, order_data):
# Prepare the order line values.
shipping_code = order_data['fulfillment'].get('fulfillmentServiceLevel')
shipping_product = self._find_matching_product(
shipping_code, 'shipping_product', 'Shipping', 'service'
)
amazon_order_ref = order_data['orderId']
currency_code = order_data['proceeds'].get('grandTotal', {}).get('currencyCode')
if currency_code: # Not included in replacement order data
currency = self.env['res.currency'].with_context(active_test=False).search(
[('name', '=', currency_code)], limit=1
)
elif replaced_order_data := order_data.get('associatedOrders'): # It is a replacement order
replaced_order_ref = replaced_order_data[0].get('orderId')
replaced_order = self.env['sale.order'].search(
[('amazon_order_ref', '=', replaced_order_ref)], limit=1
)
currency = replaced_order.currency_id
else:
raise ValidationError(_(
"The Amazon order with reference %(ref)s was not recovered because its currency"
" was missing from the Amazon order data.",
ref=amazon_order_ref,
))
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_lines_values = self._prepare_order_lines_values(
order_data, currency, fiscal_position, shipping_product
)
fulfillment_channel = order_data['fulfillment'].get('fulfilledBy')
purchase_date = dateutil.parser.parse(order_data.get('createdTime')).replace(tzinfo=None)
order_vals = {
'origin': f"Amazon Order {amazon_order_ref}",
'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_channel == 'AMAZON',
'date_order': purchase_date,
'partner_id': contact_partner.id,
'pricelist_id': self._find_or_create_pricelist(currency).id,
'order_line': [(0, 0, order_line_values) for order_line_values in order_lines_values],
'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,
'amazon_order_ref': amazon_order_ref,
'amazon_channel': 'fba' if fulfillment_channel == 'AMAZON' else 'fbm',
}
if fulfillment_channel == 'AMAZON' and self.location_id.warehouse_id:
order_vals['warehouse_id'] = self.location_id.warehouse_id.id
return order_vals
def _find_or_create_partners_from_data(self, order_data):
""" Find or create the contact and delivery partners based on the provided order data.
Note: self.ensure_one()
:param dict order_data: The order data to find or create the partners from.
:return: The contact and delivery partners, as `res.partner` records. When the contact
partner acts as delivery partner, the records are the same.
:rtype: tuple[record of `res.partner`, record of `res.partner`]
"""
self.ensure_one()
amazon_order_ref = order_data['orderId']
buyer_info = order_data.get('buyer', {})
anonymized_email = buyer_info.get('buyerEmail', '')
buyer_name = buyer_info.get('buyerName', '')
fulfillment_channel = order_data.get('fulfillment', {}).get('fulfilledBy')
shipping_address_info = order_data.get('recipient', {}).get('deliveryAddress', {})
shipping_address_name = shipping_address_info.get('name', '')
street = shipping_address_info.get('addressLine1', '')
address_line2 = shipping_address_info.get('addressLine2', '')
address_line3 = shipping_address_info.get('addressLine3', '')
street2 = ("%s %s" % (address_line2, address_line3)).strip() or None
zip_code = shipping_address_info.get('postalCode', '')
city = shipping_address_info.get('city', '')
country_code = shipping_address_info.get('countryCode', '')
state_code = shipping_address_info.get('stateOrRegion', '')
phone = shipping_address_info.get('phone', '')
is_company = shipping_address_info.get('addressType') == 'COMMERCIAL'
country = self.env['res.country'].search([('code', '=', country_code)], limit=1)
state = self.env['res.country.state'].search([
('country_id', '=', country.id),
'|', ('code', '=ilike', state_code), ('name', '=ilike', state_code),
], limit=1)
# German addresses on Amazon are swapped, where AddressLine1 == company/building name, and
# AddressLine2 == street.
if country.code == 'DE' and street2:
street, street2 = street2, street
partner_vals = {
'street': street,
'street2': street2,
'zip': zip_code,
'city': city,
'country_id': country.id,
'state_id': state.id,
'phone': phone,
'customer_rank': 1,
'company_id': self.company_id.id,
'email': anonymized_email,
}
# The contact partner is searched based on all the personal information and only if the
# amazon email 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['res.partner']._check_company_domain(self.company_id),
('type', '=', 'contact'),
('name', '=', buyer_name),
('email', '=', anonymized_email),
], limit=1) if anonymized_email else None # Don't match random partners.
if not contact:
contact_name = buyer_name or f"Amazon Customer # {amazon_order_ref}"
contact = self.env['res.partner'].with_context(tracking_disable=True).create({
'name': contact_name,
'is_company': is_company,
**partner_vals,
})
if not contact.state_id and state_code and fulfillment_channel == 'MERCHANT':
contact._amazon_create_activity_set_state(self.user_id.id, state_code)
# 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 (not contact.street2 or 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),
('parent_id', '=', contact.id),
('type', '=', 'delivery'),
('name', '=', shipping_address_name),
('street', '=', street),
'|', ('street2', '=', False), ('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,
})
if not delivery.state_id and state_code and fulfillment_channel == 'MERCHANT':
delivery._amazon_create_activity_set_state(self.user_id.id, state_code)
return contact, delivery
def _prepare_order_lines_values(self, order_data, currency, fiscal_pos, shipping_product):
""" Prepare the values for the order lines to create based on Amazon data.
Note: self.ensure_one()
:param dict order_data: The order data related to the item data.
:param record currency: The currency of the sales order, as a `res.currency` record.
:param record fiscal_pos: The fiscal position of the sales order, as an
`account.fiscal.position` record.
:param record shipping_product: The shipping product matching the shipping code, as a
`product.product` record.
:return: The order lines values.
:rtype: dict
"""
self.ensure_one()
def get_financial_amount(target_type, target_subtype=None):
breakdown = next((b for b in breakdowns if b.get("type") == target_type), {})
if not target_subtype:
return float(breakdown.get('subtotal', {}).get('amount', '0.0'))
details = breakdown.get("detailedBreakdowns", [])
detail = next((d for d in details if d.get("subtype") == target_subtype), {})
return float(detail.get("value", {}).get("amount", "0.0"))
order_fulfillment_channel = (
'fba' if order_data['fulfillment'].get('fulfilledBy') == 'AMAZON' else 'fbm'
)
marketplace_api_ref = order_data['salesChannel'].get('marketplaceId')
order_last_update = dateutil.parser.parse(order_data['lastUpdatedTime'], ignoretz=True)
order_lines_values = []
for item_data in order_data['orderItems']:
# Prepare the values for the product line.
product_info = item_data['product']
sku = product_info.get('sellerSku', '')
marketplace = self.active_marketplace_ids.filtered(
lambda m: m.api_ref == marketplace_api_ref
)
offer = self._find_or_create_offer(sku, marketplace)
if (
offer.amazon_channel != order_fulfillment_channel
and self.last_orders_sync <= order_last_update
):
# The fulfillment channel of the offer was changed in Amazon, but due to a problem
# of ghost listings from Amazon side, we can't trust the order either.
# When an offer goes from FBM to FBA, we need to reset the FBM stock to avoid a
# known problem of ghost listings on Amazon side.
if (
offer.amazon_channel == 'fbm' and offer.sync_stock
and order_fulfillment_channel == 'fba'
):
offer.amazon_sync_status = 'reset'
_logger.info(
"Amazon fulfillment channel switch detected (FBM -> FBA). Scheduled an"
" inventory reset for offer: %s.",
offer.sku,
)
offer.amazon_channel = False
product_taxes = offer.product_id.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
condition_info = product_info.get('condition', {})
main_condition = condition_info.get('conditionType')
sub_condition = condition_info.get('conditionSubtype')
item_title = product_info.get('title', self.env._("Missing product name"))
if not main_condition or main_condition.lower() == 'new':
description = "[%s] %s" % (sku, item_title)
else:
description = _(
"[%(sku)s] %(item_title)s\nCondition: %(main_condition)s - %(sub_condition)s",
sku=sku,
item_title=item_title,
main_condition=main_condition,
sub_condition=sub_condition,
)
# Discount tax computation
breakdowns = item_data['proceeds'].get('breakdowns', [])
promo_discount = get_financial_amount('DISCOUNT', 'ITEM')
ship_discount = get_financial_amount('DISCOUNT', 'SHIPPING')
total_discount = get_financial_amount('DISCOUNT')
total_discount_tax = get_financial_amount('TAX', 'DISCOUNT')
promo_disc_tax = 0.0
ship_disc_tax = 0.0
if total_discount:
promo_disc_tax = total_discount_tax * (promo_discount / total_discount)
ship_disc_tax = total_discount_tax * (ship_discount / total_discount)
sales_price = get_financial_amount('ITEM')
tax_amount = get_financial_amount('TAX', 'ITEM')
taxes = fiscal_pos.map_tax(product_taxes) if fiscal_pos else product_taxes
subtotal = self._recompute_subtotal(
sales_price, tax_amount, taxes, currency, fiscal_pos
)
promo_discount_subtotal = self._recompute_subtotal(
promo_discount, promo_disc_tax, taxes, currency, fiscal_pos
)
amazon_item_ref = item_data['orderItemId']
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=offer.product_id.id,
description=description,
subtotal=subtotal,
tax_ids=taxes.ids,
quantity=item_data['quantityOrdered'],
discount=promo_discount_subtotal,
amazon_item_ref=amazon_item_ref,
amazon_offer_id=offer.id,
))
if gift_option := item_data['fulfillment'].get('packing', {}).get('giftOption', {}):
gift_wrap_code = gift_option.get('giftWrapLevel')
gift_wrap_price = get_financial_amount('GIFT_WRAP')
if gift_wrap_code and gift_wrap_price:
gift_wrap_product = self._find_matching_product(
gift_wrap_code, 'default_product', 'Amazon Sales', 'consu'
)
gift_wrap_product_taxes = gift_wrap_product.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
gift_wrap_taxes = fiscal_pos.map_tax(gift_wrap_product_taxes) \
if fiscal_pos else gift_wrap_product_taxes
gift_wrap_tax_amount = get_financial_amount('TAX', 'GIFT_WRAP')
gift_wrap_subtotal = self._recompute_subtotal(
gift_wrap_price,
gift_wrap_tax_amount,
gift_wrap_taxes,
currency,
fiscal_pos,
)
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=gift_wrap_product.id,
description=_(
"[%(gift_wrap_code)s] Gift Wrapping Charges for %(product)s",
gift_wrap_code=gift_wrap_code, product=offer.product_id.name,
),
subtotal=gift_wrap_subtotal,
tax_ids=gift_wrap_taxes.ids,
))
gift_message = gift_option.get('giftMessage')
if gift_message:
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
description=_("Gift message:\n%s", gift_message),
display_type='line_note',
))
# Shipping computation
shipping_code = order_data['fulfillment'].get('fulfillmentServiceLevel')
shipping_price = get_financial_amount('SHIPPING')
if shipping_price:
shipping_product_taxes = shipping_product.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
shipping_taxes = fiscal_pos.map_tax(shipping_product_taxes) if fiscal_pos \
else shipping_product_taxes
shipping_tax_amount = get_financial_amount('TAX', 'SHIPPING')
shipping_subtotal = self._recompute_subtotal(
shipping_price, shipping_tax_amount, shipping_taxes, currency, fiscal_pos
)
ship_discount_subtotal = self._recompute_subtotal(
ship_discount, ship_disc_tax, shipping_taxes, currency, fiscal_pos
)
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=shipping_product.id,
description=_(
"[%(shipping_code)s] Delivery Charges for %(product)s",
shipping_code=shipping_code, product=offer.product_id.name,
),
subtotal=shipping_subtotal,
tax_ids=shipping_taxes.ids,
discount=ship_discount_subtotal,
))
return order_lines_values
def _convert_to_order_line_values(self, **kwargs):
""" Convert and complete a dict of values to comply with fields of `sale.order.line`.
:param dict kwargs: The values to convert and complete.
:return: The completed values.
:rtype: dict
"""
subtotal = kwargs.get('subtotal', 0)
quantity = kwargs.get('quantity', 1)
return {
'name': kwargs.get('description', ''),
'product_id': kwargs.get('product_id'),
'price_unit': subtotal / quantity if quantity else 0,
'tax_ids': [(6, 0, kwargs.get('tax_ids', []))],
'product_uom_qty': quantity,
'discount': (kwargs.get('discount', 0) / subtotal) * 100 if subtotal else 0,
'display_type': kwargs.get('display_type', False),
'amazon_item_ref': kwargs.get('amazon_item_ref'),
'amazon_offer_id': kwargs.get('amazon_offer_id'),
}
def _find_or_create_offer(self, sku, marketplace):
""" Find or create the amazon offer based on the SKU and marketplace.
Note: self.ensure_one()
:param str sku: The SKU of the product.
:param recordset marketplace: The marketplace of the offer, as an `amazon.marketplace`
record.
:return: The amazon offer.
:rtype: record or `amazon.offer`
"""
self.ensure_one()
offer = self.offer_ids.filtered(lambda o: o.sku == sku)
if not offer:
offer = self.env['amazon.offer'].with_context(tracking_disable=True).create({
'account_id': self.id,
'marketplace_id': marketplace.id,
'product_id': self._find_matching_product(
sku, 'default_product', 'Amazon Sales', 'consu'
).id,
'sku': sku,
'amazon_feed_ref': '{}',
})
# If the offer has been linked with the default product, search if another product has now
# been assigned the current SKU as internal reference and update the offer 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 offer was created.
elif 'sale_amazon.default_product' in offer.product_id._get_external_ids().get(
offer.product_id.id, []
):
product = self._find_matching_product(sku, '', '', '', fallback=False)
if product:
offer.product_id = product.id
return offer
def _find_or_create_pricelist(self, currency):
""" Find or create the pricelist based on the currency.
Note: self.ensure_one()
:param recordset currency: The currency of the pricelist, as a `res.currency` record.
:return: The pricelist.
:rtype: record or `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': 'Amazon Pricelist %s' % currency.name,
'company_id': self.company_id.id,
'active': False,
'currency_id': currency.id,
})
return pricelist
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.
Note: self.ensure_one()
: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: record of `product.product`
"""
self.ensure_one()
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('sale_amazon.%s' % default_xmlid, raise_if_not_found=False)
if not product and fallback: # Restore the default product if it was deleted
product = self.env['product.product']._restore_data_product(
default_name, default_type, default_xmlid
)
return product
def _recompute_subtotal(self, subtotal, tax_amount, taxes, currency, _fiscal_pos=None):
""" Recompute the subtotal from the tax amount and the taxes.
As it is not always possible to find the right tax record for a tax rate computed from the
tax amount because of rounding errors or because of multiple taxes for a given rate, the
taxes on the product (or those given by the fiscal position) are used instead.
To achieve this, the subtotal is recomputed from the taxes for the total to match that of
the order in SellerCentral. If the taxes used are not identical to that used by Amazon, the
recomputed subtotal will differ from the original subtotal.
:param float subtotal: The original subtotal to use for the computation of the base total.
:param float tax_amount: The original tax amount to use for the computation of the base
total.
:param recordset taxes: The final taxes to use for the computation of the new subtotal, as
an `account.tax` recordset.
:param recordset currency: The currency used by the rounding methods, as a `res.currency`
record.
:param recordset _fiscal_pos: The fiscal position only used in overrides of this method, as
an `account.fiscal.position` recordset.
:return: The new subtotal.
:rtype: float
"""
total = subtotal + tax_amount
taxes_res = taxes.with_context(force_price_include=True).compute_all(
total, currency=currency
)
subtotal = taxes_res['total_excluded']
for tax_res in taxes_res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
if tax.price_include:
subtotal += tax_res['amount']
return subtotal
def _generate_stock_moves(self, order):
""" Generate a stock move for each product of the provided sales order.
:param recordset order: The sales order to generate the stock moves for, as a `sale.order`
record.
:return: The generated stock moves.
:rtype: recordset of `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 != 'service' and not l.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.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 _sync_inventory(self):
""" Synchronize the inventory availability of products sold on Amazon.
If called on an empty recordset, the products of all active accounts with inventory
synchronization are synchronized instead.
Note: This method is called by the `ir_cron_sync_amazon_inventory` cron.
:return: None
"""
self = self or self.search([])
accounts = self.filtered('synchronize_inventory')
if not accounts:
return
def to_sync(offer_):
return offer_.sync_stock or offer_.amazon_sync_status == 'reset'
# Cache `free_qty` of all products to avoid recomputing it for each offer.
accounts.offer_ids.filtered(to_sync).product_id._compute_quantities()
for account in accounts:
amazon_utils.ensure_account_is_set_up(account)
offers = account.offer_ids.filtered(to_sync)
offers._update_inventory_availability(account)
# As Amazon needs some time to process the feed, we trigger the cron to check the status of
# the feed after 10 minutes.
next_call = fields.Datetime.now() + timedelta(minutes=10)
self.env.ref('sale_amazon.ir_cron_sync_amazon_feeds')._trigger(at=next_call)
def _sync_feeds(self):
""" Synchronize the status of the accounts' feeds that were sent to Amazon.
If called on an empty recordset, the feeds of all active account are synchronized instead.
We assume that the combined set of feeds (of all accounts) to be handled will always be too
small for the cron to be killed before it finishes synchronizing all feeds.
Note: This method is called by the `ir_cron_sync_amazon_feeds` cron.
:return: None
"""
self = self or self.search([])
# Select accounts with offers or pickings requiring synchronization.
accounts_with_offers = self.filtered(
lambda a: any(o.amazon_sync_status == 'processing' for o in a.offer_ids)
)
pickings_by_account = self.env['stock.picking']._get_pickings_by_account(
'processing', tuple(self.ids)
)
# Syn feeds status.
for account in accounts_with_offers:
amazon_utils.ensure_account_is_set_up(account)
offers = account.offer_ids.filtered(lambda o: o.amazon_sync_status == 'processing')
account._pull_feeds_status(offers, 'inventory_sync')
for account, pickings in pickings_by_account.items():
amazon_utils.ensure_account_is_set_up(account)
account._pull_feeds_status(pickings, 'picking_sync')
# Re-schedule the cron if not all the offers and pickings reached a final status.
any_processing_feed = any(
offer.amazon_sync_status == 'processing' for offer in accounts_with_offers.offer_ids
) or any(
account_pickings.filtered(lambda p: p.amazon_sync_status == 'processing')
for account_pickings in pickings_by_account.values()
)
if any_processing_feed:
next_call = fields.Datetime.now() + timedelta(minutes=10)
self.env.ref('sale_amazon.ir_cron_sync_amazon_feeds')._trigger(at=next_call)
def _pull_feeds_status(self, records, flow):
""" Pull the status of the feeds corresponding to the provided recordset.
Note: `self.ensure_one()`
:param recordset records: The records whose feed status should be pulled. Only
`amazon.offer` and `stock.picking` are supported.
:param str flow: The feed name that must be fetched. Supported feeds are 'inventory_sync'
and 'picking_sync'.
:return: None
"""
self.ensure_one()
if flow == 'inventory_sync':
feed_data_by_offer = records._get_feed_data()
records_by_feed_ref = records.grouped(
lambda o: feed_data_by_offer.get(o, {}).get('amazon_feed_ref')
)
elif flow == 'picking_sync':
records_by_feed_ref = records.grouped('amazon_feed_ref')
else:
return
def parse_json_report_document(report_):
errors_ = {}
for result in report_['issues']:
if result['severity'] == 'ERROR':
errors_.setdefault(result.get('messageId'), []).append(
build_error_message(result['code'], result['message'])
)
return errors_
def parse_xml_report_document(report_):
errors_ = {}
for result_ in report_.find('Message/ProcessingReport').iter('Result'):
if result_.find('ResultCode').text != 'Error':
continue
# We can identify failed pickings.
if (order_ := result_.find('AdditionalInfo/AmazonOrderID')) is not None:
order_ref_ = order_.text
else: # Amazon doesn't specify which order (and thus picking) failed.
order_ref_ = None
errors_.setdefault(order_ref_, []).append(build_error_message(
result_.find('ResultMessageCode').text, result_.find('ResultDescription').text,
))
return errors_
def build_error_message(code, message):
return _("Error (%(code)s): %(message)s", code=code, message=message)
errors_by_record = {}
for feed_ref, feed_records in records_by_feed_ref.items():
if not feed_ref: # flow == 'inventory_sync'
_logger.warning(
"Failed to load the feed reference for the records with IDs"
" %(feed_record_ids)r. Skipping the synchronization attempt only for these IDs."
" The next synchronization will be attempted later.",
{'feed_record_ids': feed_records.ids},
)
continue
# Pull the status and result document reference for the current feed.
feed_data = amazon_utils.make_sp_api_request(self, 'getFeed', path_parameter=feed_ref)
feed_status = feed_data['processingStatus']
report_document_ref = feed_data.get('resultFeedDocumentId')
# Update the records according to their feed status.
if feed_status == 'DONE': # The feed was fully processed.
try:
report = amazon_utils.get_feed_document(self, report_document_ref)
except amazon_utils.AmazonRateLimitError:
raise # Don't treat a rate limit error as a business error.
except ValidationError:
_logger.exception(
"A business error occurred while processing feed %(feed_ref)s for Amazon"
" account with id %(account_id)s. Skipping the feed and moving to the next"
" one.", {'feed_ref': feed_ref, 'account_id': self.id}
)
else:
if flow == 'inventory_sync':
report_errors = parse_json_report_document(report)
message_key = lambda o: feed_data_by_offer[o].get('messageId')
else:
report_errors = parse_xml_report_document(report)
message_key = lambda p: p.sale_id.amazon_order_ref
if not report_errors:
feed_records.amazon_sync_status = 'done'
_logger.info(
"Synchronized feed %(feed_ref)s for Amazon account with id"
" %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
continue
# Iterate over the processing results and flag failed records.
error_records = feed_records.filtered(
lambda r: message_key(r) in report_errors
)
error_records.amazon_sync_status = 'error'
for record in error_records:
# Using a set to combine duplicates created by Amazon with every retry.
errors_by_record.setdefault(record, set()).update(
report_errors[message_key(record)]
)
unprocessed_records = feed_records.filtered(
lambda p: p.amazon_sync_status == 'processing'
) # The sync order might have run before, avoid changing back done records.
# Failed to link some messages, consider unprocessed records as failed
if None in report_errors:
unprocessed_records.amazon_sync_status = 'error'
for record in unprocessed_records:
errors_by_record.setdefault(record, set()).update(report_errors[None])
else: # All errors were identified, the remaining records succeeded.
unprocessed_records.amazon_sync_status = 'done'
_logger.info(
"Found errors while synchronizing feed %(feed_ref)s for Amazon account with"
" id %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
elif feed_status in ['IN_QUEUE', 'IN_PROGRESS']: # The feed has not yet been processed.
_logger.info(
"Ignoring in progress feed %(feed_ref)s for Amazon account with id"
" %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
elif feed_status == 'CANCELLED': # The feed has been canceled before being processed.
feed_records.amazon_sync_status = 'pending'
if flow == 'inventory_sync':
_logger.info(
"Re-scheduling a synchronization of inventory for offers of cancelled"
" feed %(feed_ref)s for Amazon account with id %(account_id)s.",
{'feed_ref': feed_ref, 'account_id': self.id},
)
elif flow == 'picking_sync':
_logger.info(
"Re-scheduling a synchronization for pickings of cancelled feed %(feed_ref)s"
" for Amazon account with id %(account_id)s.",
{'feed_ref': feed_ref, 'account_id': self.id},
)
elif feed_status == 'FATAL': # The feed failed with no further information.
for record in feed_records:
errors_by_record.setdefault(record, set()).add(None)
feed_records.amazon_sync_status = 'error'
if errors_by_record:
error_messages = []
for r, errors in errors_by_record.items():
for error in errors:
if error and flow == 'picking_sync':
r.message_post(body=Markup("%s<br/>%s") % (
_("The synchronization with Amazon failed. Amazon gave us this "
"information about the problem:"),
error,
))
if flow == 'inventory_sync':
error_messages.append({'sku': r.sku, 'message': error})
elif flow == 'picking_sync':
error_messages.append(
{'order_ref': r.sale_id.amazon_order_ref, 'message': error}
)
self._handle_sync_failure(flow=flow, error_messages=error_messages)
def _handle_sync_failure(self, flow, amazon_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 amazon_order_ref: The amazon reference of the order that failed to synchronize.
Required for the `order_sync` flow.
:param list[dict] error_messages: A list containing the referenced Amazon 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 amazon reference %(ref)s for amazon.account with "
"id %(account_id)s (seller id %(seller_id)s).",
{'ref': amazon_order_ref, 'account_id': self.id, 'seller_id': self.seller_key},
)
mail_template_id = 'sale_amazon.order_sync_failure'
elif flow == 'inventory_sync':
_logger.exception(
"Failed to synchronize the inventory for offers in amazon.account with id "
"%(account_id)s (seller id %(seller_id)s).",
{'account_id': self.id, 'seller_id': self.seller_key}
)
mail_template_id = 'sale_amazon.inventory_sync_failure'
else: # flow == 'picking_sync':
_logger.exception(
"Failed to synchronize pickings for amazon.account with id %(account_id)s "
"(seller id %(seller_id)s).", {'account_id': self.id, 'seller_id': self.seller_key}
)
mail_template_id = 'sale_amazon.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 %s has been deleted.", 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),
'amazon_order_ref': amazon_order_ref,
'error_messages': error_messages,
'amazon_account': self.name,
}).send_mail(self.env.user.id)
_logger.info(
"Sent synchronization failure notification email to %s",
', '.join(responsible_emails)
)