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

337 lines
14 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import split_every
from odoo.addons.sale_amazon import utils as amazon_utils
_logger = logging.getLogger(__name__)
class AmazonOffer(models.Model):
_name = 'amazon.offer'
_description = "Amazon Offer"
@api.model
def default_get(self, fields):
result = super().default_get(fields)
# Default account_id.
if not result.get('account_id'):
accounts = self.env['amazon.account'].search([
('refresh_token', '!=', False),
*self.env['amazon.account']._check_company_domain(self.env.company),
], limit=2)
if len(accounts) == 1:
result['account_id'] = accounts.id
# Default marketplace_id.
if (account_id := result.get('account_id')) and not result.get('marketplace_id'):
account = self.env['amazon.account'].browse(account_id)
if len(marketplaces := account.active_marketplace_ids) == 1:
result['marketplace_id'] = marketplaces.id
return result
account_id = fields.Many2one(
string="Account",
help="The seller account used to manage this product.",
comodel_name='amazon.account',
required=True,
index=True,
ondelete='cascade',
) # The default account provided in the context of the list view.
company_id = fields.Many2one(related='account_id.company_id', readonly=True)
active_marketplace_ids = fields.Many2many(related='account_id.active_marketplace_ids')
marketplace_id = fields.Many2one(
string="Marketplace",
help="The marketplace of this offer.",
comodel_name='amazon.marketplace',
required=True,
domain="[('id', 'in', active_marketplace_ids)]",
)
product_id = fields.Many2one(
string="Product", comodel_name='product.product', required=True, ondelete='cascade'
)
product_template_id = fields.Many2one(
related="product_id.product_tmpl_id", store=True, readonly=True
)
sku = fields.Char(string="Amazon SKU", help="The Stock Keeping Unit.", required=True)
amazon_sync_status = fields.Selection(
string="Amazon Synchronization Status",
help="The synchronization status of the product's stock level to Amazon:\n"
"- Processing: The stock level has been sent and is being processed.\n"
"- Done: The stock level has been processed.\n"
"- Error: The synchronization of the stock level failed.\n"
"- Reset: The next stock synchronization will reset the FBM stock level to 0.",
selection=[
('processing', "Processing"),
('done', "Done"),
('error', "Error"),
('reset', "Reset FBM stock"),
],
readonly=True,
)
amazon_feed_ref = fields.Char(string="Amazon Feed Reference", readonly=True)
amazon_channel = fields.Selection(
string="Fulfillment Channel",
help="The channel will be updated with the incomming orders or during the next stock"
" synchronization.",
selection=[('fbm', "Fulfilled by Merchant"), ('fba', "Fulfilled by Amazon")],
)
sync_stock = fields.Boolean(
string="Stock Synchronization", compute='_compute_sync_stock', store=True, readonly=False,
)
_unique_sku = models.Constraint(
'UNIQUE(account_id, sku)',
"SKU must be unique for a given account.",
)
@api.depends('amazon_channel', 'account_id.synchronize_inventory', 'product_id.is_storable')
def _compute_sync_stock(self):
for offer in self:
offer.sync_stock = (
offer.account_id.synchronize_inventory
and offer.product_id.is_storable
and (not offer.amazon_channel or offer.amazon_channel == 'fbm')
)
@api.onchange('product_id')
def _onchange_product_id(self):
""" Set the SKU to the internal reference of the product if it exists. """
for offer in self:
offer.sku = offer.product_id.default_code
@api.constrains('sync_stock')
def _check_product_is_storable(self):
for offer in self.filtered('sync_stock'):
if not offer.product_id.is_storable:
raise ValidationError(self.env._("Non-storable product cannot be synced."))
def action_view_online(self):
self.ensure_one()
url = f'{self.marketplace_id.seller_central_url}/skucentral?mSku={self.sku}'
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}
def _get_feed_data(self):
"""Load the necessary data for the inventory feed, and fetch the missing ones when possible.
:return: A dictionary per offer in `self` containing at least the Amazon product type.
:rtype: dict[amazon.offer, dict['productType: str, ...]]
"""
feed_data_by_offer = {}
for offer in self:
try:
feed_data = json.loads(offer.amazon_feed_ref)
except (json.JSONDecodeError, TypeError): # Field is either incorrect JSON, or False
feed_data = None
if isinstance(feed_data, dict): # In case old `amazon_feed_ref` are still stored
feed_data_by_offer[offer] = feed_data
feed_data_by_offer.update(
self.filtered(lambda o: not (
o in feed_data_by_offer
and 'productType' in feed_data_by_offer[o]
and o.amazon_channel
))._fetch_and_save_feed_data() # Fetch missing data
)
return feed_data_by_offer
def _fetch_and_save_feed_data(self):
"""Fetch data necessary for the inventory feed to work and save it in the record.
Necessary data are:
- productType: Amazon product type
- amazon_channel: fulfillment channel
:return: A mapping of offer to feed data
:rtype: dict['amazon.offer', dict]
"""
feed_data_by_offer = {}
to_fetch = self.sorted(lambda o:
o.amazon_sync_status in ('reset', 'error') # Fetch priority offers first
).grouped(lambda o: (o.account_id, o.marketplace_id))
# `searchListingsItems` only supports up to 20 SKUs
to_fetch = (
(group, batch)
for group, offers in to_fetch.items()
for batch in split_every(20, offers.ids, self.env['amazon.offer'].browse)
)
for (account_id, marketplace_id), offers in to_fetch:
try:
response = amazon_utils.make_sp_api_request(
account=account_id,
operation='searchListingsItems',
path_parameter=account_id.seller_key,
payload={
'marketplaceIds': marketplace_id.api_ref,
"includedData": "fulfillmentAvailability,productTypes",
'identifiersType': 'SKU',
'identifiers': ','.join(offer.sku.replace(',', '') for offer in offers),
'pageSize': len(offers),
},
)
except amazon_utils.AmazonRateLimitError:
_logger.warning("Could not fetch every offers infos due to rate limit from Amazon.")
# Mark failed offers, to be prioritized on next try
offers.filtered(
lambda o: o.amazon_sync_status != 'reset'
).amazon_sync_status = 'error'
continue
# Parse product data
offer_by_sku = offers.grouped('sku')
# Default to FBA to not fetch the info everytime when the offer isn't found by Amazon
# Default to PRODUCT in case the fulfillment channel is manually updated afterward.
offers.amazon_channel = 'fba'
feed_data_by_offer.update({offer: {"productType": "PRODUCT"} for offer in offers})
for item in response['items']:
offer = offer_by_sku[item['sku']]
feed_data_by_offer[offer]['productType'] = (
item['productTypes'] and item['productTypes'][0]['productType']
or 'PRODUCT'
)
is_fbm = any(
fulfillment["fulfillmentChannelCode"] == "DEFAULT"
for fulfillment in item.get("fulfillmentAvailability", [])
)
offer.amazon_channel = 'fbm' if is_fbm else 'fba'
if is_fbm and offer.amazon_sync_status == 'reset':
offer.amazon_sync_status = False # Reset only applies to FBA offers
# Save data to reduce api calls
AmazonOffer._save_feed_data(feed_data_by_offer)
return feed_data_by_offer
@classmethod
def _save_feed_data(cls, feed_data_by_offer):
"""Save inventory feed data.
:param dict['amazon.offer', dict] feed_data_by_offer: A mapping from offer to data necessary
for the inventory feed.
"""
for offer, feed_info in feed_data_by_offer.items():
offer.amazon_feed_ref = json.dumps(feed_info, separators=(',', ':'))
def _update_inventory_availability(self, account):
"""Update the stock quantity of Amazon products to Amazon.
:param amazon.account account: The Amazon account to which the stock update should apply.
"""
feed_data_by_offer = self._get_feed_data()
# `_get_feed_data` can fail for some offers (rate limit)
offers_to_sync = self.filtered(lambda o: o in feed_data_by_offer)
# Inventory feed can only apply to one marketplace
for marketplace_id, offers in offers_to_sync.grouped('marketplace_id').items():
offers._send_inventory_feed(
account,
{o: feed_data_by_offer[o] for o in offers},
marketplace_id,
)
def _send_inventory_feed(self, account, feed_data_by_offer, marketplace_id):
"""Send the inventory feed for the given marketplace.
Note: A feed of type `JSON_LISTINGS_FEED` can be applied to only one marketplace.
:param 'amazon.account' account: The Amazon account on behalf of which the feed should be
built and submitted.
:param dict['amazon.offer', dict] feed_data_by_offer: A mapping of offer to their feed data,
i.e. at least the 'productType' of the offer.
:param 'amazon.marketplace' marketplace_id: The marketplace to which the feed should apply.
"""
if not self: # Don't send empty feed
return
for i, feed_info in enumerate(feed_data_by_offer.values(), start=1):
# Assign and save the message id to later match error message(s)
feed_info['messageId'] = i
messages = self._build_feed_messages(feed_data_by_offer)
json_feed = amazon_utils.build_json_feed(account, messages)
try:
feed_ref = amazon_utils.submit_feed(
account,
json_feed,
'JSON_LISTINGS_FEED',
feed_content_type='application/json; charset=UTF-8',
marketplace_api_refs=[marketplace_id.api_ref],
)
except amazon_utils.AmazonRateLimitError:
_logger.info(
"Rate limit reached while sending inventory availability notification for Amazon"
" account with id %s.", account.id
)
else:
_logger.info(
"Sent inventory availability notification (feed_ref %s) to Amazon for account ID"
" %s. SKUs: %s.",
feed_ref,
account.id,
', '.join(self.mapped('sku')),
)
self.amazon_sync_status = 'processing'
# Save feed reference to later sync any error that might have occurred
for feed_info in feed_data_by_offer.values():
feed_info['amazon_feed_ref'] = feed_ref
# Save message IDs
self._save_feed_data(feed_data_by_offer)
def _build_feed_messages(self, feed_data_by_offer):
"""Constructs the inventory feed messages.
:param dict['amazon.offer', dict] feed_data_by_offer: A dict mapping offer with the
necessary feed data, i.e. productType and messageId.
:rtype: list[dict]
"""
return [
{
'messageId': feed_data['messageId'],
'sku': offer.sku,
"operationType": "PATCH",
'productType': feed_data['productType'],
"patches": [
{
# Merge so as to not override fulfillment configurations
# (lead_time_to_ship_max_days, restock_date)
"op": "merge",
"path": "/attributes/fulfillment_availability",
"value": [
{
"fulfillment_channel_code": "DEFAULT",
"quantity": (
0
if offer.amazon_sync_status == "reset"
else max(int(offer._get_available_product_qty()), 0)
),
}
],
}
],
}
for offer, feed_data in feed_data_by_offer.items()
]
def _get_available_product_qty(self):
""" Retrieve the current available and free product quantity.
This hook can be overridden to set a finer quantity.
:return: The free quantity.
:rtype: float
"""
self.ensure_one()
return self.product_id.free_qty