337 lines
14 KiB
Python
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
|