diff --git a/.gitmodules b/.gitmodules index 34475284..2d922d0a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,6 @@ [submodule "external/hibou-oca/pos"] path = external/hibou-oca/pos url = https://github.com/hibou-io/oca-pos.git +[submodule "external/python-amazon-sp-api"] + path = external/python-amazon-sp-api + url = https://github.com/hibou-io/python-amazon-sp-api.git diff --git a/Dockerfile b/Dockerfile index b890494b..e443259d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \ && cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \ ; +USER 0 +RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \ + && pip install . +USER 104 + EXPOSE 3000 ENV SHELL=/bin/bash \ THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins diff --git a/Dockerfile-GitLab b/Dockerfile-GitLab index 23ecf558..2b13fbb7 100644 --- a/Dockerfile-GitLab +++ b/Dockerfile-GitLab @@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \ && cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \ ; +USER 0 +RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \ + && pip install . +USER 104 + EXPOSE 3000 ENV SHELL=/bin/bash \ THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins diff --git a/connector_amazon_sp/__init__.py b/connector_amazon_sp/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/connector_amazon_sp/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/connector_amazon_sp/__manifest__.py b/connector_amazon_sp/__manifest__.py new file mode 100755 index 00000000..1d6a73d3 --- /dev/null +++ b/connector_amazon_sp/__manifest__.py @@ -0,0 +1,45 @@ +{ + "name": "Amazon Selling Partner Connector", + "version": "11.0.1.0.0", + "depends": [ + "connector_ecommerce", + "sale_order_dates", + "sale_sourced_by_line", + "delivery_hibou", + "sale_planner", + ], + "data": [ + "security/ir.model.access.csv", + "data/connector_amazon_sp_data.xml", + "views/amazon_menus.xml", + "views/amazon_backend_views.xml", + "views/amazon_feed_views.xml", + "views/amazon_product_views.xml", + "views/amazon_sale_views.xml", + "views/delivery_carrier_views.xml", + "views/stock_views.xml", + ], + "author": "Hibou Corp.", + "license": "LGPL-3", + "description": """ +Amazon Selling Partner Connector +================================ + +* Import Orders from your Amazon Marketplaces. +* Deliver Amazon orders by purchasing shipping via the MerchantFulfillment API. +* Manage Listing SKUs and inventory. (Supports multiple warehouses via SKU+WH_Code) +* Manage Listing Pricing including using Price Lists + + """, + "summary": "", + "website": "https://hibou.io/", + "category": "Tools", + "auto_install": False, + "installable": True, + "application": True, + "external_dependencies": { + "python": [ + "sp_api", + ], + }, +} diff --git a/connector_amazon_sp/components/__init__.py b/connector_amazon_sp/components/__init__.py new file mode 100644 index 00000000..818555fa --- /dev/null +++ b/connector_amazon_sp/components/__init__.py @@ -0,0 +1,8 @@ +# © 2021 Hibou Corp. + +from . import api +from . import backend_adapter +from . import binder +from . import importer +from . import exporter +from . import mapper diff --git a/connector_amazon_sp/components/api/__init__.py b/connector_amazon_sp/components/api/__init__.py new file mode 100644 index 00000000..a06f315d --- /dev/null +++ b/connector_amazon_sp/components/api/__init__.py @@ -0,0 +1 @@ +from . import amazon diff --git a/connector_amazon_sp/components/api/amazon.py b/connector_amazon_sp/components/api/amazon.py new file mode 100644 index 00000000..4cc5cb39 --- /dev/null +++ b/connector_amazon_sp/components/api/amazon.py @@ -0,0 +1,182 @@ +# © 2021 Hibou Corp. + +# imports for Client and CredentialProvider patch +from os import environ +import json +from requests import request +import boto3 +from botocore.config import Config as BotoConfig + +from sp_api.base.client import Client +from sp_api.base.config import CredentialProvider +from sp_api.base.ApiResponse import ApiResponse +from sp_api.base.marketplaces import Marketplaces +from sp_api.auth import AccessTokenClient +from requests.exceptions import HTTPError + +# imports for Wrapping +from sp_api.api import Orders, \ + Shipping, \ + MerchantFulfillment, \ + Feeds + +from sp_api.base.exceptions import SellingApiException, \ + SellingApiForbiddenException + +amz_proxy_endpoint = environ.get('AMAZON_SP_ENDPOINT', 'https://amz-proxy.hibou.io') + +PROXY_ENDPOINT = amz_proxy_endpoint +PROXY = amz_proxy_endpoint.split('//')[1] + + +class RequestRateError(Exception): + def __init__(self, message, exception=None): + super().__init__(message) + self.exception = exception + + +class WrappedAPI: + SellingApiException = SellingApiException + SellingApiForbiddenException = SellingApiForbiddenException + + def __init__(self, env, refresh_token, lwa_client_id, lwa_client_secret, aws_access_key, aws_secret_key, role_arn): + self.env = env + get_param = env['ir.config_parameter'].sudo().get_param + self.credentials = { + 'refresh_token': refresh_token, + 'lwa_app_id': lwa_client_id, + 'lwa_client_secret': lwa_client_secret, + 'aws_access_key': aws_access_key, + 'aws_secret_key': aws_secret_key, + 'role_arn': role_arn, + # 'db_uid': get_param('database.uuid', ''), + # 'pro_code': get_param('database.hibou_professional_code', ''), + } + + def orders(self): + return Orders(credentials=self.credentials) + + def shipping(self): + return Shipping(credentials=self.credentials) + + def merchant_fulfillment(self): + return MerchantFulfillment(credentials=self.credentials) + + def feeds(self): + return Feeds(credentials=self.credentials) + + +# patch the Client +def __init__( + self, + marketplace: Marketplaces = Marketplaces.US, + *, + refresh_token=None, + account='default', + credentials=None +): + super(Client, self).__init__(account, credentials) + self.boto3_client = boto3.client( + 'sts', + # aws_access_key_id=self.credentials.aws_access_key, + # aws_secret_access_key=self.credentials.aws_secret_key + config=BotoConfig(proxies={'http': PROXY, 'https': PROXY}) + ) + self.endpoint = marketplace.endpoint + self.marketplace_id = marketplace.marketplace_id + self.region = marketplace.region + self._auth = AccessTokenClient(refresh_token=refresh_token, account=account, credentials=credentials) + + +def _sign_request(self): + return None + + +def _request(self, path: str, *, data: dict = None, params: dict = None, headers=None, + add_marketplace=True) -> ApiResponse: + if params is None: + params = {} + if data is None: + data = {} + + self.method = params.pop('method', data.pop('method', 'GET')) + + if add_marketplace: + self._add_marketplaces(data if self.method in ('POST', 'PUT') else params) + + # auth=None because we don't sign the request anymore + # proxy setup... + # url = self.endpoint + path + url = PROXY_ENDPOINT + path + headers = headers or self.headers + headers['x-orig-host'] = headers['host'] + del headers['host'] + headers['x-db-uuid'] = self.credentials.db_uid + headers['x-pro-code'] = self.credentials.pro_code + res = request(self.method, url, params=params, + data=json.dumps(data) if data and self.method in ('POST', 'PUT') else None, headers=headers, + auth=self._sign_request()) + try: + res.raise_for_status() # proxy does not return json errors + except HTTPError as e: + status_code = e.response.status_code + if str(status_code) == '429': + raise RequestRateError('HTTP 429', exception=e) + raise e + return self._check_response(res) + +# Patch _request to have timeout, not signing differences above. +def _request(self, path: str, *, data: dict = None, params: dict = None, headers=None, + add_marketplace=True) -> ApiResponse: + if params is None: + params = {} + if data is None: + data = {} + + self.method = params.pop('method', data.pop('method', 'GET')) + + if add_marketplace: + self._add_marketplaces(data if self.method in ('POST', 'PUT') else params) + + res = request(self.method, self.endpoint + path, params=params, + data=json.dumps(data) if data and self.method in ('POST', 'PUT') else None, headers=headers or self.headers, + auth=self._sign_request(), + timeout=60) + + return self._check_response(res) + +# Client.__init__ = __init__ +# Client._sign_request = _sign_request +Client._request = _request + + +# patch the CredentialProvider +class Config: + def __init__(self, + refresh_token, + lwa_app_id, + lwa_client_secret, + aws_access_key, + aws_secret_key, + role_arn, + db_uid, + pro_code, + ): + self.refresh_token = refresh_token + self.lwa_app_id = lwa_app_id + self.lwa_client_secret = lwa_client_secret + self.aws_access_key = aws_access_key + self.aws_secret_key = aws_secret_key + self.role_arn = role_arn + self.db_uid = db_uid + self.pro_code = pro_code + + def check_config(self): + errors = [] + for k, v in self.__dict__.items(): + if not v and k != 'refresh_token': + errors.append(k) + return errors + + +# CredentialProvider.Config = Config diff --git a/connector_amazon_sp/components/backend_adapter.py b/connector_amazon_sp/components/backend_adapter.py new file mode 100644 index 00000000..aeeb8da2 --- /dev/null +++ b/connector_amazon_sp/components/backend_adapter.py @@ -0,0 +1,79 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.core import AbstractComponent + +# Feed API +from datetime import datetime +from xml.etree import ElementTree + + +class BaseAmazonConnectorComponent(AbstractComponent): + """ Base Amazon Connector Component + + All components of this connector should inherit from it. + """ + _name = 'base.amazon.connector' + _inherit = 'base.connector' + _collection = 'amazon.backend' + + +class AmazonAdapter(AbstractComponent): + _name = 'amazon.adapter' + _inherit = ['base.backend.adapter', 'base.amazon.connector'] + + ElementTree = ElementTree + FEED_ENCODING = 'iso-8859-1' + + def search(self, filters=None): + """ Search records according to some criterias + and returns a list of ids """ + raise NotImplementedError + + def read(self, id, attributes=None): + """ Returns the information of a record """ + raise NotImplementedError + + def search_read(self, filters=None): + """ Search records according to some criterias + and returns their information""" + raise NotImplementedError + + def create(self, data): + """ Create a record on the external system """ + raise NotImplementedError + + def write(self, id, data): + """ Update records on the external system """ + raise NotImplementedError + + def delete(self, id): + """ Delete a record on the external system """ + raise NotImplementedError + + def _feed(self, message_type, backend): + root = self.ElementTree.Element('AmazonEnvelope', + {'{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation': 'amzn-envelope.xsd'}) + header = self.ElementTree.SubElement(root, 'Header') + self.ElementTree.SubElement(header, 'DocumentVersion').text = '1.01' + self.ElementTree.SubElement(header, 'MerchantIdentifier').text = backend.merchant_id + self.ElementTree.SubElement(root, 'MessageType').text = message_type + + # note that you can remove and add your own Message node + message = self.ElementTree.SubElement(root, 'Message') + self.ElementTree.SubElement(message, 'MessageID').text = str(int(datetime.now().timestamp())) + return root, message + + def _feed_string(self, node): + return self.ElementTree.tostring(node, encoding=self.FEED_ENCODING, method='xml') + + @property + def api_instance(self): + try: + amazon_api = getattr(self.work, 'amazon_api') + except AttributeError: + raise AttributeError( + 'You must provide a amazon_api attribute with a ' + 'Amazon instance to be able to use the ' + 'Backend Adapter.' + ) + return amazon_api diff --git a/connector_amazon_sp/components/binder.py b/connector_amazon_sp/components/binder.py new file mode 100644 index 00000000..0c60d2f5 --- /dev/null +++ b/connector_amazon_sp/components/binder.py @@ -0,0 +1,22 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.core import Component + + +class AmazonModelBinder(Component): + """ Bind records and give odoo/amazon ids correspondence + + Binding models are models called ``amazon.{normal_model}``, + like ``amazon.sale.order`` or ``amazon.product.product``. + They are ``_inherits`` of the normal models and contains + the Amazon ID, the ID of the Amazon Backend and the additional + fields belonging to the Amazon instance. + """ + _name = 'amazon.binder' + _inherit = ['base.binder', 'base.amazon.connector'] + _apply_on = [ + 'amazon.product.product', + 'amazon.sale.order', + 'amazon.sale.order.line', + 'amazon.stock.picking', + ] diff --git a/connector_amazon_sp/components/exporter.py b/connector_amazon_sp/components/exporter.py new file mode 100644 index 00000000..2381e24e --- /dev/null +++ b/connector_amazon_sp/components/exporter.py @@ -0,0 +1,310 @@ +# © 2021 Hibou Corp. + +import logging + +from contextlib import contextmanager +from datetime import datetime + +import psycopg2 + +import odoo +from odoo import _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import (IDMissingInBackend, + RetryableJobError) + +_logger = logging.getLogger(__name__) + + +class AmazonBaseExporter(AbstractComponent): + """ Base exporter for Amazon """ + + _name = 'amazon.base.exporter' + _inherit = ['base.exporter', 'base.amazon.connector'] + _usage = 'record.exporter' + + def __init__(self, working_context): + super(AmazonBaseExporter, self).__init__(working_context) + self.binding = None + self.external_id = None + + def run(self, binding, *args, **kwargs): + """ Run the synchronization + + :param binding: binding record to export + """ + self.binding = binding + self.external_id = self.binder.to_external(self.binding) + + result = self._run(*args, **kwargs) + + self.binder.bind(self.external_id, self.binding) + # Commit so we keep the external ID when there are several + # exports (due to dependencies) and one of them fails. + # The commit will also release the lock acquired on the binding + # record + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + + self._after_export() + return result + + def _run(self): + """ Flow of the synchronization, implemented in inherited classes""" + raise NotImplementedError + + def _after_export(self): + """ Can do several actions after exporting a record to Amazon """ + pass + + +class AmazonExporter(AbstractComponent): + """ A common flow for the exports to Amazon """ + + _name = 'amazon.exporter' + _inherit = 'amazon.base.exporter' + + def __init__(self, working_context): + super(AmazonExporter, self).__init__(working_context) + self.binding = None + + def _lock(self): + """ Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + + """ + sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % + self.model._table) + try: + self.env.cr.execute(sql, (self.binding.id, ), + log_exceptions=False) + except psycopg2.OperationalError: + _logger.info('A concurrent job is already exporting the same ' + 'record (%s with id %s). Job delayed later.', + self.model._name, self.binding.id) + raise RetryableJobError( + 'A concurrent job is already exporting the same record ' + '(%s with id %s). The job will be retried later.' % + (self.model._name, self.binding.id)) + + def _has_to_skip(self): + """ Return True if the export can be skipped """ + return False + + @contextmanager + def _retry_unique_violation(self): + """ Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "amazon_product_product_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + .. warning:: The unique constraint must be created on the + binding record to prevent 2 bindings to be created + for the same Amazon record. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + 'A database error caused the failure of the job:\n' + '%s\n\n' + 'Likely due to 2 concurrent jobs wanting to create ' + 'the same record. The job will be retried later.' % err) + else: + raise + + def _export_dependency(self, relation, binding_model, + component_usage='record.exporter', + binding_field='amazon_bind_ids', + binding_extra_vals=None): + """ + Export a dependency. The exporter class is a subclass of + ``AmazonExporter``. If a more precise class need to be defined, + it can be passed to the ``exporter_class`` keyword argument. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care not to modify the Odoo + database during an export, excepted when writing + back the external ID or eventually to store + external data that we have to keep on this side. + + You should call this method only at the beginning + of the exporter synchronization, + in :meth:`~._export_dependencies`. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component_usage: 'usage' to look for to find the Component to + for the export, by default 'record.exporter' + :type exporter: str | unicode + :param binding_field: name of the one2many field on a normal + record that points to the binding record + (default: amazon_bind_ids). + It is used only when the relation is not + a binding but is a normal record. + :type binding_field: str | unicode + :binding_extra_vals: In case we want to create a new binding + pass extra values for this binding + :type binding_extra_vals: dict + """ + if not relation: + return + rel_binder = self.binder_for(binding_model) + # wrap is typically True if the relation is for instance a + # 'product.product' record but the binding model is + # 'amazon.product.product' + wrap = relation._name != binding_model + + if wrap and hasattr(relation, binding_field): + domain = [('odoo_id', '=', relation.id), + ('backend_id', '=', self.backend_record.id)] + binding = self.env[binding_model].search(domain) + if binding: + assert len(binding) == 1, ( + 'only 1 binding for a backend is ' + 'supported in _export_dependency') + # we are working with a unwrapped record (e.g. + # product.category) and the binding does not exist yet. + # Example: I created a product.product and its binding + # amazon.product.product and we are exporting it, but we need to + # create the binding for the product.category on which it + # depends. + else: + bind_values = {'backend_id': self.backend_record.id, + 'odoo_id': relation.id} + if binding_extra_vals: + bind_values.update(binding_extra_vals) + # If 2 jobs create it at the same time, retry + # one later. A unique constraint (backend_id, + # odoo_id) should exist on the binding model + with self._retry_unique_violation(): + binding = (self.env[binding_model] + .with_context(connector_no_export=True) + .sudo() + .create(bind_values)) + # Eager commit to avoid having 2 jobs + # exporting at the same time. The constraint + # will pop if an other job already created + # the same binding. It will be caught and + # raise a RetryableJobError. + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + else: + # If amazon_bind_ids does not exist we are typically in a + # "direct" binding (the binding record is the same record). + # If wrap is True, relation is already a binding record. + binding = relation + + if not rel_binder.to_external(binding): + exporter = self.component(usage=component_usage, + model_name=binding_model) + exporter.run(binding) + + def _export_dependencies(self): + """ Export the dependencies for the record""" + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.binding) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _validate_update_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.update`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_create` """ + return map_record.values(for_create=True, fields=fields, **kwargs) + + def _create(self, data): + """ Create the Amazon record """ + # special check on data before export + self._validate_create_data(data) + return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_update` """ + return map_record.values(fields=fields, **kwargs) + + def _update(self, data): + """ Update an Amazon record """ + assert self.external_id + # special check on data before export + self._validate_update_data(data) + self.backend_adapter.write(self.external_id, data) + + def _run(self, fields=None): + """ Flow of the synchronization, implemented in inherited classes""" + assert self.binding + + if not self.external_id: + fields = None # should be created with all the fields + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + map_record = self._map_data() + + if self.external_id: + record = self._update_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self._update(record) + else: + record = self._create_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self.external_id = self._create(record) + return _('Record exported with ID %s on Amazon.') % self.external_id diff --git a/connector_amazon_sp/components/importer.py b/connector_amazon_sp/components/importer.py new file mode 100644 index 00000000..bdbd714d --- /dev/null +++ b/connector_amazon_sp/components/importer.py @@ -0,0 +1,323 @@ +# © 2021 Hibou Corp. + +""" + +Importers for Amazon. + +An import can be skipped if the last sync date is more recent than +the last update in Amazon. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +from odoo import fields, _ +from odoo.addons.component.core import AbstractComponent, Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob + + +_logger = logging.getLogger(__name__) + + +class AmazonImporter(AbstractComponent): + """ Base importer for Amazon """ + + _name = 'amazon.importer' + _inherit = ['base.importer', 'base.amazon.connector'] + _usage = 'record.importer' + + def __init__(self, work_context): + super(AmazonImporter, self).__init__(work_context) + self.external_id = None + self.amazon_record = None + + def _get_amazon_data(self): + """ Return the raw Amazon data for ``self.external_id`` """ + return self.backend_adapter.read(self.external_id) + + def _before_import(self): + """ Hook called before the import, when we have the Amazon + data""" + + def _is_uptodate(self, binding): + """Return True if the import should be skipped because + it is already up-to-date in Odoo""" + assert self.amazon_record + if not self.amazon_record.get('updated_at'): + return # no update date on Amazon, always import it. + if not binding: + return # it does not exist so it should not be skipped + sync = binding.sync_date + if not sync: + return + from_string = fields.Datetime.from_string + sync_date = from_string(sync) + amazon_date = from_string(self.amazon_record['updated_at']) + # if the last synchronization date is greater than the last + # update in amazon, we skip the import. + # Important: at the beginning of the exporters flows, we have to + # check if the amazon_date is more recent than the sync_date + # and if so, schedule a new import. If we don't do that, we'll + # miss changes done in Amazon + return amazon_date < sync_date + + def _import_dependency(self, external_id, binding_model, + importer=None, always=False): + """ Import a dependency. + + The importer class is a class or subclass of + :class:`AmazonImporter`. A specific class can be defined. + + :param external_id: id of the related binding to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param importer_component: component to use for import + By default: 'importer' + :type importer_component: Component + :param always: if True, the record is updated even if it already + exists, note that it is still skipped if it has + not been modified on Amazon since the last + update. When False, it will import it only when + it does not yet exist. + :type always: boolean + """ + if not external_id: + return + binder = self.binder_for(binding_model) + if always or not binder.to_internal(external_id): + if importer is None: + importer = self.component(usage='record.importer', + model_name=binding_model) + try: + importer.run(external_id) + except NothingToDoJob: + _logger.info( + 'Dependency import of %s(%s) has been ignored.', + binding_model._name, external_id + ) + + def _import_dependencies(self): + """ Import the dependencies for the record + + Import of dependencies can be done manually or by calling + :meth:`_import_dependency` for each dependency. + """ + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.amazon_record) + + def _validate_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``_create`` or + ``_update`` if some fields are missing or invalid. + + Raise `InvalidDataError` + """ + return + + def _must_skip(self): + """ Hook called right after we read the data from the backend. + + If the method returns a message giving a reason for the + skipping, the import will be interrupted and the message + recorded in the job (if the import is called directly by the + job, not by dependencies). + + If it returns None, the import will continue normally. + + :returns: None | str | unicode + """ + return + + def _get_binding(self): + return self.binder.to_internal(self.external_id) + + def _create_data(self, map_record, **kwargs): + return map_record.values(for_create=True, **kwargs) + + def _create(self, data): + """ Create the OpenERP record """ + # special check on data before import + self._validate_data(data) + model = self.model.with_context(connector_no_export=True) + binding = model.create(data) + _logger.debug('%d created from amazon %s', binding, self.external_id) + return binding + + def _update_data(self, map_record, **kwargs): + return map_record.values(**kwargs) + + def _update(self, binding, data): + """ Update an OpenERP record """ + # special check on data before import + self._validate_data(data) + binding.with_context(connector_no_export=True).write(data) + _logger.debug('%d updated from amazon %s', binding, self.external_id) + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + def run(self, external_id, force=False): + """ Run the synchronization + + :param external_id: identifier of the record on Amazon + """ + self.external_id = external_id + lock_name = 'import({}, {}, {}, {})'.format( + self.backend_record._name, + self.backend_record.id, + self.work.model_name, + external_id, + ) + + try: + self.amazon_record = self._get_amazon_data() + except IDMissingInBackend: + return _('Record no longer exists in Amazon') + + skip = self._must_skip() + if skip: + return skip + + binding = self._get_binding() + + if not force and self._is_uptodate(binding): + return _('Already up-to-date.') + + # Keep a lock on this import until the transaction is committed + # The lock is kept since we have detected that the informations + # will be updated into Odoo + self.advisory_lock_or_retry(lock_name) + self._before_import() + + # import the missing linked resources + self._import_dependencies() + + map_record = self._map_data() + + if binding: + record = self._update_data(map_record) + self._update(binding, record) + else: + record = self._create_data(map_record) + binding = self._create(record) + + self.binder.bind(self.external_id, binding) + + self._after_import(binding) + + +class BatchImporter(AbstractComponent): + """ The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = 'amazon.batch.importer' + _inherit = ['base.importer', 'base.amazon.connector'] + _usage = 'batch.importer' + + def run(self, filters=None): + """ Run the synchronization """ + record_ids = self.backend_adapter.search(filters) + for record_id in record_ids: + self._import_record(record_id) + + def _import_record(self, external_id): + """ Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class DirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = 'amazon.direct.batch.importer' + _inherit = 'amazon.batch.importer' + + def _import_record(self, external_id): + """ Import the record directly """ + self.model.import_record(self.backend_record, external_id) + + +class DelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = 'amazon.delayed.batch.importer' + _inherit = 'amazon.batch.importer' + + def _import_record(self, external_id, job_options=None, **kwargs): + """ Delay the import of the records""" + delayable = self.model.with_delay(**job_options or {}) + delayable.import_record(self.backend_record, external_id, **kwargs) + + +# class SimpleRecordImporter(Component): +# """ Import one Amazon Website """ +# +# _name = 'amazon.simple.record.importer' +# _inherit = 'amazon.importer' +# _apply_on = [ +# 'amazon.res.partner.category', +# ] + + +# class TranslationImporter(Component): +# """ Import translations for a record. +# +# Usually called from importers, in ``_after_import``. +# For instance from the products and products' categories importers. +# """ +# +# _name = 'amazon.translation.importer' +# _inherit = 'amazon.importer' +# _usage = 'translation.importer' +# +# def _get_amazon_data(self, storeview_id=None): +# """ Return the raw Amazon data for ``self.external_id`` """ +# return self.backend_adapter.read(self.external_id, storeview_id) +# +# def run(self, external_id, binding, mapper=None): +# self.external_id = external_id +# storeviews = self.env['amazon.storeview'].search( +# [('backend_id', '=', self.backend_record.id)] +# ) +# default_lang = self.backend_record.default_lang_id +# lang_storeviews = [sv for sv in storeviews +# if sv.lang_id and sv.lang_id != default_lang] +# if not lang_storeviews: +# return +# +# # find the translatable fields of the model +# fields = self.model.fields_get() +# translatable_fields = [field for field, attrs in fields.items() +# if attrs.get('translate')] +# +# if mapper is None: +# mapper = self.mapper +# else: +# mapper = self.component_by_name(mapper) +# +# for storeview in lang_storeviews: +# lang_record = self._get_amazon_data(storeview.external_id) +# map_record = mapper.map_record(lang_record) +# record = map_record.values() +# +# data = dict((field, value) for field, value in record.items() +# if field in translatable_fields) +# +# binding.with_context(connector_no_export=True, +# lang=storeview.lang_id.code).write(data) diff --git a/connector_amazon_sp/components/mapper.py b/connector_amazon_sp/components/mapper.py new file mode 100644 index 00000000..3e8ad54c --- /dev/null +++ b/connector_amazon_sp/components/mapper.py @@ -0,0 +1,23 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.core import AbstractComponent + + +class AmazonImportMapper(AbstractComponent): + _name = 'amazon.import.mapper' + _inherit = ['base.amazon.connector', 'base.import.mapper'] + _usage = 'import.mapper' + + +class AmazonExportMapper(AbstractComponent): + _name = 'amazon.export.mapper' + _inherit = ['base.amazon.connector', 'base.export.mapper'] + _usage = 'export.mapper' + + +def normalize_datetime(field): + def modifier(self, record, to_attr): + val = record.get(field, '') + val = val.replace('T', ' ').replace('Z', '') + return val + return modifier diff --git a/connector_amazon_sp/data/connector_amazon_sp_data.xml b/connector_amazon_sp/data/connector_amazon_sp_data.xml new file mode 100644 index 00000000..9353fe1e --- /dev/null +++ b/connector_amazon_sp/data/connector_amazon_sp_data.xml @@ -0,0 +1,123 @@ + + + + + + Amazon SP - Import Sales Orders + + code + + 1 + hours + -1 + + + model._scheduler_import_sale_orders() + + + + Amazon SP - Export Product Inventory + + code + + 8 + hours + -1 + + + model._scheduler_export_product_inventory() + + + + Amazon SP - Export Product Price + + code + + 24 + hours + -1 + + + model._scheduler_export_product_price() + + + + Amazon SP - Queue Job Watchdog + + code + + 10 + minutes + -1 + + + +# find queue jobs that were started more than X min ago +offset = 60 * 10 +now = datetime.datetime.now().replace(microsecond=0) +start = now - datetime.timedelta(seconds=offset) +# uncomment and run manually to see the results +# raise Warning('now: ' + str(now) + ' start: ' + str(start)) + +jobs = env['queue.job'].search([ + ('state', '=', 'started'), + ('date_started', '<', str(start)), + ('channel', 'like', 'amazon'), + ]) + +# uncomment and run manually to see the results +# raise Warning(str(jobs)) + +if jobs: + jobs.requeue() + + + + + Total Amount differs from Amazon + The amount computed in Odoo doesn't match with the amount in Amazon. + +Cause: +The taxes are probably different between Odoo and Amazon. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + failed = sale.amazon_bind_ids and abs(sale.amount_total - sale.amazon_bind_ids[0].total_amount) >= 0.01 + + + + + Submit Product + + + code + +records.button_submit_product() + + + + + Update Inventory + + + code + +records.button_update_inventory() + + + + + Update Price + + + code + +records.button_update_price() + + + + + diff --git a/connector_amazon_sp/models/__init__.py b/connector_amazon_sp/models/__init__.py new file mode 100644 index 00000000..4ff318bc --- /dev/null +++ b/connector_amazon_sp/models/__init__.py @@ -0,0 +1,11 @@ +# © 2021 Hibou Corp. + +from . import api +from . import amazon_backend +from . import amazon_binding +from . import amazon_feed +from . import delivery_carrier +# from . import partner +from . import product +from . import sale_order +from . import stock_picking diff --git a/connector_amazon_sp/models/amazon_backend/__init__.py b/connector_amazon_sp/models/amazon_backend/__init__.py new file mode 100644 index 00000000..b9b38f48 --- /dev/null +++ b/connector_amazon_sp/models/amazon_backend/__init__.py @@ -0,0 +1,3 @@ +# © 2021 Hibou Corp. + +from . import common diff --git a/connector_amazon_sp/models/amazon_backend/common.py b/connector_amazon_sp/models/amazon_backend/common.py new file mode 100644 index 00000000..e5c44404 --- /dev/null +++ b/connector_amazon_sp/models/amazon_backend/common.py @@ -0,0 +1,208 @@ +# © 2021 Hibou Corp. + +from datetime import datetime, timedelta +from logging import getLogger +from contextlib import contextmanager + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from ...components.api.amazon import WrappedAPI + +_logger = getLogger(__name__) + +IMPORT_DELTA_BUFFER = 600 # seconds + + +class AmazonBackend(models.Model): + _name = 'amazon.backend' + _description = 'Amazon Backend' + _inherit = 'connector.backend' + + name = fields.Char(string='Name') + active = fields.Boolean(default=True) + + api_refresh_token = fields.Text(string='API Refresh Token', required=True) + api_lwa_client_id = fields.Char(string='API LWA Client ID', required=True) + api_lwa_client_secret = fields.Char(string='API LWA Client Secret', required=True) + api_aws_access_key = fields.Char(string='API AWS Access Key', required=True) + api_aws_secret_key = fields.Char(string='API AWS Secret Key', required=True) + api_role_arn = fields.Char(string='API AWS Role ARN', required=True) + + merchant_id = fields.Char(string='Amazon Merchant Identifier', required=True) + + warehouse_ids = fields.Many2many( + comodel_name='stock.warehouse', + string='Warehouses', + required=True, + help='Warehouses to use for delivery and stock.', + ) + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='Fiscal Position', + help='Fiscal position to use on orders.', + ) + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector.' + ) + team_id = fields.Many2one('crm.team', string='Sales Team') + user_id = fields.Many2one('res.users', string='Salesperson', + help='Default Salesperson for newly imported orders.') + sale_prefix = fields.Char( + string='Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'AMZ-', the sales " + "order 112-5571768504079 in Amazon, will be named 'AMZ-112-5571768504079' " + "in Odoo.", + ) + payment_mode_id = fields.Many2one('account.payment.mode', string='Payment Mode') + carrier_id = fields.Many2one('delivery.carrier', string='Delivery Method') + pricelist_id = fields.Many2one('product.pricelist', string='Pricelist') + buffer_qty = fields.Integer(string='Buffer Quantity', + help='Stock to hold back from Amazon for listings.', + default=0) + + fba_warehouse_ids = fields.Many2many( + comodel_name='stock.warehouse', + relation='amazon_backend_fba_stock_warehouse_rel', + string='FBA Warehouses', + required=False, + help='Warehouses to use for FBA delivery and stock.', + ) + fba_fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='FBA Fiscal Position', + help='Fiscal position to use on FBA orders.', + ) + fba_analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='FBA Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector.' + ) + fba_team_id = fields.Many2one('crm.team', string='FBA Sales Team') + fba_user_id = fields.Many2one('res.users', string='FBA Salesperson', + help='Default Salesperson for newly imported FBA orders.') + fba_sale_prefix = fields.Char( + string='FBA Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'FBA-', the sales " + "order 112-5571768504079 in Amazon, will be named 'FBA-112-5571768504079' " + "in Odoo.", + ) + fba_payment_mode_id = fields.Many2one('account.payment.mode', string='FBA Payment Mode') + fba_carrier_id = fields.Many2one('delivery.carrier', string='FBA Delivery Method') + fba_pricelist_id = fields.Many2one('product.pricelist', string='FBA Pricelist') + fba_buffer_qty = fields.Integer(string='FBA Buffer Quantity', + help='Stock to hold back from Amazon for FBA listings.', + default=0) + + # New Product fields. + product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category', + help='Default product category for newly created products.') + + # Automation + scheduler_order_import_running = fields.Boolean(string='Automatic Sale Order Import is Running', + compute='_compute_scheduler_running', + compute_sudo=True) + scheduler_order_import = fields.Boolean(string='Automatic Sale Order Import') + + scheduler_product_inventory_export_running = fields.Boolean(string='Automatic Product Inventory Export is Running', + compute='_compute_scheduler_running', + compute_sudo=True) + scheduler_product_inventory_export = fields.Boolean(string='Automatic Product Inventory Export') + + scheduler_product_price_export_running = fields.Boolean(string='Automatic Product Price Export is Running', + compute='_compute_scheduler_running', + compute_sudo=True) + scheduler_product_price_export = fields.Boolean(string='Automatic Product Price Export') + + import_orders_from_date = fields.Datetime( + string='Import sale orders from date', + ) + + @contextmanager + @api.multi + def work_on(self, model_name, **kwargs): + self.ensure_one() + amazon_api = self.get_wrapped_api() + with super().work_on(model_name, amazon_api=amazon_api, **kwargs) as work: + yield work + + def button_test(self): + self.ensure_one() + amazon_api = self.get_wrapped_api() + Shipping = amazon_api.shipping() + raise UserError(str(Shipping.get_account())) + + def get_wrapped_api(self): + self.ensure_one() + return WrappedAPI(self.env, + self.api_refresh_token, + self.api_lwa_client_id, + self.api_lwa_client_secret, + self.api_aws_access_key, + self.api_aws_secret_key, + self.api_role_arn) + + def _compute_scheduler_running(self): + sched_action_so_imp = self.env.ref('connector_amazon_sp.ir_cron_import_sale_orders', raise_if_not_found=False) + sched_action_pi_exp = self.env.ref('connector_amazon_sp.ir_cron_export_product_inventory', raise_if_not_found=False) + sched_action_pp_exp = self.env.ref('connector_amazon_sp.ir_cron_export_product_price', raise_if_not_found=False) + for backend in self: + backend.scheduler_order_import_running = bool(sched_action_so_imp and sched_action_so_imp.active) + backend.scheduler_product_inventory_export_running = bool(sched_action_pi_exp and sched_action_pi_exp.active) + backend.scheduler_product_price_export_running = bool(sched_action_pp_exp and sched_action_pp_exp.active) + + @api.model + def _scheduler_import_sale_orders(self): + # potential hook for customization (e.g. pad from date or provide its own) + backends = self.search([ + ('scheduler_order_import', '=', True), + ]) + return backends.import_sale_orders() + + @api.model + def _scheduler_export_product_inventory(self): + backends = self.search([ + ('scheduler_product_inventory_export', '=', True), + ]) + for backend in backends: + self.env['amazon.product.product'].update_inventory(backend) + + @api.model + def _scheduler_export_product_price(self): + backends = self.search([ + ('scheduler_product_price_export', '=', True), + ]) + for backend in backends: + self.env['amazon.product.product'].update_price(backend) + + @api.multi + def import_sale_orders(self): + self._import_from_date('amazon.sale.order', 'import_orders_from_date') + return True + + @api.multi + def _import_from_date(self, model_name, from_date_field): + import_start_time = datetime.now().replace(microsecond=0) - timedelta(seconds=IMPORT_DELTA_BUFFER) + for backend in self: + from_date = backend[from_date_field] + if from_date: + from_date = fields.Datetime.from_string(from_date) + else: + from_date = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER) + + self.env[model_name].with_delay(priority=5).import_batch( + backend, + # TODO which filters can we use in Amazon? + filters={'CreatedAfter': from_date.isoformat(), + 'CreatedBefore': import_start_time.isoformat()} + ) + # We add a buffer, but won't import them twice. + # NOTE this is 2x the offset from now() + next_time = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER) + next_time = fields.Datetime.to_string(next_time) + backend.write({from_date_field: next_time}) diff --git a/connector_amazon_sp/models/amazon_binding/__init__.py b/connector_amazon_sp/models/amazon_binding/__init__.py new file mode 100644 index 00000000..b9b38f48 --- /dev/null +++ b/connector_amazon_sp/models/amazon_binding/__init__.py @@ -0,0 +1,3 @@ +# © 2021 Hibou Corp. + +from . import common diff --git a/connector_amazon_sp/models/amazon_binding/common.py b/connector_amazon_sp/models/amazon_binding/common.py new file mode 100644 index 00000000..b7c21a69 --- /dev/null +++ b/connector_amazon_sp/models/amazon_binding/common.py @@ -0,0 +1,64 @@ +# © 2021 Hibou Corp. + +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action + + +class AmazonBinding(models.AbstractModel): + """ Abstract Model for the Bindings. + + All of the models used as bindings between Amazon and Odoo + (``amazon.sale.order``) should ``_inherit`` from it. + """ + _name = 'amazon.binding' + _inherit = 'external.binding' + _description = 'Amazon Binding (abstract)' + + backend_id = fields.Many2one( + comodel_name='amazon.backend', + string='Amazon Backend', + required=True, + ondelete='restrict', + ) + external_id = fields.Char(string='ID in Amazon') + + _sql_constraints = [ + ('Amazon_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Amazon ID.'), + ] + + @job(default_channel='root.amazon') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Amazon """ + if filters is None: + filters = {} + with backend.work_on(self._name) as work: + importer = work.component(usage='batch.importer') + return importer.run(filters=filters) + + @job(default_channel='root.amazon') + @related_action(action='related_action_unwrap_binding') + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Amazon record """ + with backend.work_on(self._name) as work: + importer = work.component(usage='record.importer') + return importer.run(external_id, force=force) + + # @job(default_channel='root.amazon') + # @related_action(action='related_action_unwrap_binding') + # @api.multi + # def export_record(self, fields=None): + # """ Export a record on Amazon """ + # self.ensure_one() + # with self.backend_id.work_on(self._name) as work: + # exporter = work.component(usage='record.exporter') + # return exporter.run(self, fields) + # + # @job(default_channel='root.amazon') + # @related_action(action='related_action_amazon_link') + # def export_delete_record(self, backend, external_id): + # """ Delete a record on Amazon """ + # with backend.work_on(self._name) as work: + # deleter = work.component(usage='record.exporter.deleter') + # return deleter.run(external_id) diff --git a/connector_amazon_sp/models/amazon_feed/__init__.py b/connector_amazon_sp/models/amazon_feed/__init__.py new file mode 100644 index 00000000..b9b38f48 --- /dev/null +++ b/connector_amazon_sp/models/amazon_feed/__init__.py @@ -0,0 +1,3 @@ +# © 2021 Hibou Corp. + +from . import common diff --git a/connector_amazon_sp/models/amazon_feed/common.py b/connector_amazon_sp/models/amazon_feed/common.py new file mode 100644 index 00000000..29c89fd2 --- /dev/null +++ b/connector_amazon_sp/models/amazon_feed/common.py @@ -0,0 +1,112 @@ +# © 2021 Hibou Corp. + +from io import BytesIO +from base64 import b64encode, b64decode +from json import loads, dumps + +from odoo import models, fields, api +from odoo.addons.queue_job.job import job +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +FEED_RETRY_PATTERN = { + 1: 1 * 60, + 5: 2 * 60, + 10: 10 * 60, +} + + +class AmazonFeed(models.Model): + _name = 'amazon.feed' + _description = 'Amazon Feed' + _order = 'id desc' + _rec_name = 'external_id' + + backend_id = fields.Many2one('amazon.backend', string='Backend') + external_id = fields.Char(string='Amazon Feed ID') + type = fields.Selection([ + ('POST_ORDER_FULFILLMENT_DATA', 'Order Fulfillment Data'), + ('POST_PRODUCT_DATA', 'Product Data'), + ('POST_INVENTORY_AVAILABILITY_DATA', 'Product Inventory'), + ('POST_PRODUCT_PRICING_DATA', 'Product Pricing'), + ], string='Feed Type') + content_type = fields.Selection([ + ('text/xml', 'XML'), + ], string='Content Type') + data = fields.Binary(string='Data', attachment=True) + response = fields.Binary(string='Response', attachment=True) + state = fields.Selection([ + ('new', 'New'), + ('submitted', 'Submitted'), + ('error_on_submit', 'Submission Error'), + ], string='State', default='new') + amazon_state = fields.Selection([ + ('not_sent', ''), + ('invalid', 'Invalid'), + ('UNCONFIRMED', 'Request Pending'), + ('SUBMITTED', 'Submitted'), + ('IN_SAFETY_NET', 'Safety Net'), + ('IN_QUEUE', 'Queued'), + ('IN_PROGRESS', 'Processing'), + ('DONE', 'Done'), + ('CANCELLED', 'Cancelled'), + ('AWAITING_ASYNCHRONOUS_REPLY', 'Awaiting Asynchronous Reply'), + ], default='not_sent') + amazon_stock_picking_id = fields.Many2one('amazon.stock.picking', + string='Shipment', + ondelete='set null') + amazon_product_product_id = fields.Many2one('amazon.product.product', + string='Listing', + ondelete='set null') + + @api.multi + @job(default_channel='root.amazon') + def submit_feed(self): + for feed in self: + api_instance = feed.backend_id.get_wrapped_api() + feeds_api = api_instance.feeds() + feed_io = BytesIO(b64decode(feed.data)) + res1, res2 = feeds_api.submit_feed(feed.type, feed_io, content_type=feed.content_type) + feed_id = res2.payload.get('feedId') + if not feed_id: + if res2.payload: + feed.response = b64encode(dumps(res2.payload)) + feed.state = 'error_on_submit' + else: + feed.state = 'submitted' + feed.external_id = feed_id + # First attempt will be delayed 1 minute + # Next 5 retries will be delayed 10 min each + # The rest will be delayed 30 min each + feed.with_delay(priority=100).check_feed() + + @api.multi + @job(default_channel='root.amazon', retry_pattern=FEED_RETRY_PATTERN) + def check_feed(self): + for feed in self.filtered('external_id'): + api_instance = feed.backend_id.get_wrapped_api() + feeds_api = api_instance.feeds() + res3 = feeds_api.get_feed(feed.external_id) + status = res3.payload['processingStatus'] + try: + feed.amazon_state = status + except ValueError: + feed.amazon_state = 'invalid' + if status in ('IN_QUEUE', 'IN_PROGRESS'): + raise RetryableJobError('Check back later on: ' + str(status), ignore_retry=True) + if status in ('DONE', ): + feed_document_id = res3.payload['resultFeedDocumentId'] + if feed_document_id: + response = feeds_api.get_feed_result_document(feed_document_id) + try: + feed.response = b64encode(response) + except TypeError: + feed.response = b64encode(response.encode()) + + # queue a job to process the response + feed.with_delay(priority=10).process_feed_result() + + @job(default_channel='root.amazon') + def process_feed_result(self): + for feed in self: + pass diff --git a/connector_amazon_sp/models/api.py b/connector_amazon_sp/models/api.py new file mode 100644 index 00000000..3566dc4e --- /dev/null +++ b/connector_amazon_sp/models/api.py @@ -0,0 +1,138 @@ +# © 2021 Hibou Corp. + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad +from base64 import b64decode, b64encode + +from odoo import api +from odoo.tools import pycompat + +PREFIX = 'amz_pii:' +PREFIX_LEN = len(PREFIX) +BLOCK_SIZE = 32 + +AMZ_PII_DECRYPT_STARTED = 1 +AMZ_PII_DECRYPT_FAIL = -1 + + +def make_amz_pii_decrypt(cipher): + def amz_pii_decrypt(value): + if value and isinstance(value, pycompat.string_types) and value.startswith(PREFIX): + try: + to_decrypt = b64decode(value[PREFIX_LEN:]) + # remove whitespace and `ack` + return cipher.decrypt(to_decrypt).decode().strip().strip('\x06') + except ValueError: + pass + except: + raise + return value + return amz_pii_decrypt + + +def make_amz_pii_encrypt(cipher): + def amz_pii_encrypt(value): + if value and isinstance(value, pycompat.string_types) and not value.startswith(PREFIX): + try: + to_encrypt = value.encode() + to_encrypt = pad(to_encrypt, BLOCK_SIZE) + # must be aligned, so pad with spaces (to remove in decrypter) + # need_padded = len(to_encrypt) % BLOCK_SIZE + # if need_padded: + # to_encrypt = to_encrypt + (b' ' * (BLOCK_SIZE - need_padded)) + to_encode = cipher.encrypt(to_encrypt) + return PREFIX + b64encode(to_encode).decode() + except ValueError: + pass + except: + raise + return value + return amz_pii_encrypt + + +def make_amz_pii_cipher(env): + # TODO we should try to get this from environment variable + # we should check 1. env variable 2. odoo config 3. database.secret + get_param = env['ir.config_parameter'].sudo().get_param + # we could get the 'database.uuid' + database_secret = get_param('database.secret') + if len(database_secret) < BLOCK_SIZE: + database_secret = database_secret.ljust(BLOCK_SIZE).encode() + else: + database_secret = database_secret[:BLOCK_SIZE].encode() + try: + cipher = AES.new(database_secret, AES.MODE_ECB) + except ValueError: + cipher = None + return cipher + +# No PII field has been observed in this method +# def set(self, record, field, value): +# """ Set the value of ``field`` for ``record``. """ +# amz_pii_decrypt = getattr(self, 'amz_pii_decrypt', None) +# c = record.env.context.get('amz_pii_decrypt') or True +# _logger.warn('set amz_pii_decrypt ' + str(c)) +# if not amz_pii_decrypt and c: +# # setup function to do the decryption +# get_param = record.env['ir.config_parameter'].sudo().get_param +# prefix = 'amz_pii:' +# prefix_len = len(prefix) +# block_size = 32 +# # we could get the 'database.uuid' +# database_secret = get_param('database.secret') +# if len(database_secret) < block_size: +# database_secret = database_secret.ljust(block_size).encode() +# else: +# database_secret = database_secret[:block_size].encode() +# try: +# cipher = AES.new(database_secret, AES.MODE_ECB) +# except ValueError: +# _logger.error('Cannot create AES256 decryption environment.') +# cipher = None +# self.amz_pii_decrypt = AMZ_PII_DECRYPT_FAIL +# +# if cipher: +# _logger.warn('created cipher') +# def amz_pii_decrypt(value): +# _logger.warn(' amz_pii_decrypt(' + str(value) + ')') +# if value and isinstance(value, pycompat.string_types) and value.startswith(prefix): +# try: +# to_decrypt = b64decode(value[prefix_len:]) +# v = cipher.decrypt(to_decrypt).decode().strip() +# _logger.warn(' decrypted to ' + str(v)) +# return v +# except: +# raise +# return value +# self.amz_pii_decrypt = amz_pii_decrypt +# elif amz_pii_decrypt and not isinstance(amz_pii_decrypt, int): +# value = amz_pii_decrypt(value) +# key = record.env.cache_key(field) +# self._data[key][field][record._ids[0]] = value + + +def update(self, records, field, values): + amz_pii_decrypt = getattr(self, 'amz_pii_decrypt', None) + amz_pii_decrypt_enabled = records.env.context.get('amz_pii_decrypt') + if not amz_pii_decrypt and amz_pii_decrypt_enabled: + self._start_amz_pii_decrypt(records.env) + elif amz_pii_decrypt_enabled and amz_pii_decrypt and not isinstance(amz_pii_decrypt, int): + for i, value in enumerate(values): + values[i] = amz_pii_decrypt(value) + + key = records.env.cache_key(field) + self._data[key][field].update(pycompat.izip(records._ids, values)) + + +def _start_amz_pii_decrypt(self, env): + self.amz_pii_decrypt = AMZ_PII_DECRYPT_STARTED + cipher = make_amz_pii_cipher(env) + if cipher: + self.amz_pii_decrypt = make_amz_pii_decrypt(cipher) + else: + self.amz_pii_decrypt = AMZ_PII_DECRYPT_FAIL + + +# api.Cache.set = set +api.Cache.update = update +api.Cache._start_amz_pii_decrypt = _start_amz_pii_decrypt diff --git a/connector_amazon_sp/models/delivery_carrier/__init__.py b/connector_amazon_sp/models/delivery_carrier/__init__.py new file mode 100644 index 00000000..b9b38f48 --- /dev/null +++ b/connector_amazon_sp/models/delivery_carrier/__init__.py @@ -0,0 +1,3 @@ +# © 2021 Hibou Corp. + +from . import common diff --git a/connector_amazon_sp/models/delivery_carrier/common.py b/connector_amazon_sp/models/delivery_carrier/common.py new file mode 100644 index 00000000..0986c242 --- /dev/null +++ b/connector_amazon_sp/models/delivery_carrier/common.py @@ -0,0 +1,415 @@ +# © 2021 Hibou Corp. + +import zlib +from datetime import date, datetime +from base64 import b64decode + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + +import logging +_logger = logging.getLogger(__name__) + + +class ProductPackaging(models.Model): + _inherit = 'product.packaging' + + amazon_sp_mfn_allowed_services = fields.Text( + string='Amazon SP MFN Allowed Methods', + help='Comma separated list. e.g. "FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB"') + + +class ProviderAmazonSP(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection(selection_add=[ + # ('amazon_sp', 'Amazon Selling Partner'), # TODO buy shipping for regular orders? + ('amazon_sp_mfn', 'Amazon SP Merchant Fulfillment') + ]) + + # Fields when uploading shipping to Amazon + amazon_sp_carrier_code = fields.Char(string='Amazon Carrier Code', + help='Specific carrier code, will default to "Other".') + amazon_sp_carrier_name = fields.Char(string='Amazon Carrier Name', + help='Specific carrier name, will default to regular name.') + amazon_sp_shipping_method = fields.Char(string='Amazon Shipping Method', + help='Specific shipping method, will default to "Standard"') + # Fields when purchasing shipping from Amazon + amazon_sp_mfn_allowed_services = fields.Text( + string='Allowed Methods', + help='Comma separated list. e.g. "FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB"', + default='FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB') + amazon_sp_mfn_label_formats = fields.Text( + string='Allowed Label Formats', + help='Comma separated list. e.g. "ZPL203,PNG"', + default='ZPL203,PNG') + + def send_shipping(self, pickings): + pickings = pickings.with_context(amz_pii_decrypt=1) + self = self.with_context(amz_pii_decrypt=1) + return super(ProviderAmazonSP, self).send_shipping(pickings) + + def is_amazon(self, order=None, picking=None): + # Override from `delivery_hibou` to be used in stamps etc.... + if picking and picking.sale_id: + so = picking.sale_id + if so.amazon_bind_ids: + return True + if order and order.amazon_bind_ids: + return True + return super().is_amazon(order=order, picking=picking) + + def _amazon_sp_mfn_get_order_details(self, order): + company = self.get_shipper_company(order=order) + wh_partner = self.get_shipper_warehouse(order=order) + + if not order.amazon_bind_ids: + raise ValidationError('Amazon shipping is not available for this order.') + + amazon_order_id = order.amazon_bind_ids[0].external_id + from_ = dict( + Name=company.name, + AddressLine1=wh_partner.street, + AddressLine2=wh_partner.street2 or '', + City=wh_partner.city, + StateOrProvinceCode=wh_partner.state_id.code, + PostalCode=wh_partner.zip, + CountryCode=wh_partner.country_id.code, + Email=company.email or '', + Phone=company.phone or '', + ) + return amazon_order_id, from_ + + def _amazon_sp_mfn_get_items_for_order(self, order): + items = order.order_line.filtered(lambda l: l.amazon_bind_ids) + return items.mapped(lambda l: (l.amazon_bind_ids[0].external_id, str(int(l.product_qty)))) + + def _amazon_sp_mfn_get_items_for_package(self, package, order): + items = [] + if not package.quant_ids: + for move_line in package.current_picking_move_line_ids: + line = order.order_line.filtered(lambda l: l.product_id.id == move_line.product_id.id and l.amazon_bind_ids) + if line: + items.append((line[0].amazon_bind_ids[0].external_id, int(move_line.qty_done), { + 'Unit': 'g', + 'Value': line.product_id.weight * move_line.qty_done * 1000, + }, line.name)) + else: + for quant in package.quant_ids: + line = order.order_line.filtered(lambda l: l.product_id.id == quant.product_id.id and l.amazon_bind_ids) + if line: + items.append((line[0].amazon_bind_ids[0].external_id, int(quant.quantity), { + 'Unit': 'g', + 'Value': line.product_id.weight * quant.quantity * 1000, + }, line.name)) + return items + + def _amazon_sp_mfn_convert_weight(self, weight): + return int(weight * 1000), 'g' + + def _amazon_sp_mfn_pick_service(self, api_services, package=None): + allowed_services = self.amazon_sp_mfn_allowed_services.split(',') + if package and package.packaging_id.amazon_sp_mfn_allowed_services: + allowed_services = package.packaging_id.amazon_sp_mfn_allowed_services.split(',') + + allowed_label_formats = self.amazon_sp_mfn_label_formats.split(',') + services = [] + api_service_list = api_services['ShippingServiceList'] + if not isinstance(api_service_list, list): + api_service_list = [api_service_list] + for s in api_service_list: + if s['ShippingServiceId'] in allowed_services: + s_available_formats = s['AvailableLabelFormats'] + for l in allowed_label_formats: + if l in s_available_formats: + services.append({ + 'service_id': s['ShippingServiceId'], + 'amount': float(s['Rate']['Amount']), + 'label_format': l + }) + break + if services: + return sorted(services, key=lambda s: s['amount'])[0] + + error = 'Cannot find applicable service. API Services: ' + \ + ','.join([s['ShippingServiceId'] for s in api_services['ShippingServiceList']]) + \ + ' Allowed Services: ' + self.amazon_sp_mfn_allowed_services + raise ValidationError(error) + + def amazon_sp_mfn_send_shipping(self, pickings): + res = [] + date_planned = datetime.now().replace(microsecond=0).isoformat() + + for picking in pickings: + shipments = [] + + picking_packages = picking.package_ids + package_carriers = picking_packages.mapped('carrier_id') + if package_carriers: + # only ship ours + picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref) + + if not picking_packages: + continue + + order = picking.sale_id.sudo() # for having access to amazon bindings and backend + + # API comes from the binding backend + if order.amazon_bind_ids: + amazon_order = order.amazon_bind_ids[0] + api_wrapped = amazon_order.backend_id.get_wrapped_api() + # must_arrive_by_date not used, and `amazon_order.requested_date` can be False + # so if it is to be used, we must decide what to do if there is no date. + # must_arrive_by_date = fields.Datetime.from_string(amazon_order.requested_date).isoformat() + api = api_wrapped.merchant_fulfillment() + + if not api: + raise UserError('%s cannot be shipped by Amazon SP without a Sale Order or backend to use.' % picking) + + amazon_order_id, from_ = self._amazon_sp_mfn_get_order_details(order) + for package in picking_packages: + dimensions = { + 'Length': package.packaging_id.length or 0.1, + 'Width': package.packaging_id.width or 0.1, + 'Height': package.packaging_id.height or 0.1, + 'Unit': 'inches', + } + weight, weight_unit = self._amazon_sp_mfn_convert_weight(package.shipping_weight) + items = self._amazon_sp_mfn_get_items_for_package(package, order) + # Declared value + inventory_value = self.get_inventory_value(picking=picking, package=package) + sig_req = self.get_signature_required(picking=picking, package=package) + + ShipmentRequestDetails = { + 'AmazonOrderId': amazon_order_id, + 'ShipFromAddress': from_, + 'Weight': {'Unit': weight_unit, 'Value': weight}, + 'SellerOrderId': order.name, + # The format of these dates cannot be determined, attempts: + # 2021-04-27 08:00:00 + # 2021-04-27T08:00:00 + # 2021-04-27T08:00:00Z + # 2021-04-27T08:00:00+00:00 + # 'ShipDate': date_planned, + # 'MustArriveByDate': must_arrive_by_date, + 'ShippingServiceOptions': { + 'DeliveryExperience': 'DeliveryConfirmationWithSignature' if sig_req else 'NoTracking', + # CarrierWillPickUp is required + 'CarrierWillPickUp': False, # Note: Scheduled carrier pickup is available only using Dynamex (US), DPD (UK), and Royal Mail (UK). + 'DeclaredValue': { + 'Amount': inventory_value, + 'CurrencyCode': 'USD' + }, + # Conflicts at time of shipping for the above + # 'CarrierWillPickUpOption': 'NoPreference', + 'LabelFormat': 'ZPL203' + }, + 'ItemList': [{ + 'OrderItemId': i[0], + 'Quantity': i[1], + 'ItemWeight': i[2], + 'ItemDescription': i[3], + } for i in items], + 'PackageDimensions': dimensions, + } + + try: + # api_services = api.get_eligible_shipment_services(ShipmentRequestDetails, ShippingOfferingFilter={ + # 'IncludePackingSlipWithLabel': False, + # 'IncludeComplexShippingOptions': False, + # 'CarrierWillPickUp': 'CarrierWillPickUp', + # 'DeliveryExperience': 'NoTracking', + # }) + api_services = api.get_eligible_shipment_services(ShipmentRequestDetails) + except api_wrapped.SellingApiForbiddenException: + raise UserError('Your Amazon SP API access does not include MerchantFulfillment') + except api_wrapped.SellingApiException as e: + raise UserError('API Exception: ' + str(e.message)) + + api_services = api_services.payload + service = self._amazon_sp_mfn_pick_service(api_services, package=package) + + try: + shipment = api.create_shipment(ShipmentRequestDetails, service['service_id']).payload + except api_wrapped.SellingApiForbiddenException: + raise UserError('Your Amazon SP API access does not include MerchantFulfillment') + except api_wrapped.SellingApiException as e: + raise UserError('API Exception: ' + str(e.message)) + + shipments.append((shipment, service)) + + carrier_price = 0.0 + tracking_numbers =[] + for shipment, service in shipments: + tracking_number = shipment['TrackingId'] + carrier_name = shipment['ShippingService']['CarrierName'] + label_data = shipment['Label']['FileContents']['Contents'] + + # So far, this is b64encoded and gzipped + try: + label_decoded = b64decode(label_data) + try: + label_decoded = zlib.decompress(label_decoded) + except: + label_decoded = zlib.decompress(label_decoded, zlib.MAX_WBITS | 16) + label_data = label_decoded + except: + # Oh well... + pass + + body = 'Shipment created into Amazon MFN
Tracking Number :
' + tracking_number + '
' + picking.message_post(body=body, attachments=[('Label%s-%s.%s' % (carrier_name, tracking_number, service['label_format']), label_data)]) + carrier_price += float(shipment['ShippingService']['Rate']['Amount']) + tracking_numbers.append(tracking_number) + shipping_data = {'exact_price': carrier_price, 'tracking_number': ','.join(tracking_numbers)} + res = res + [shipping_data] + + return res + + def amazon_sp_mfn_rate_shipment_multi(self, order=None, picking=None, packages=None): + if not packages: + return self._amazon_sp_mfn_rate_shipment_multi_package(order=order, picking=picking) + else: + rates = [] + for package in packages: + rates += self._amazon_sp_mfn_rate_shipment_multi_package(order=order, picking=picking, package=package) + return rates + + def _amazon_sp_mfn_rate_shipment_multi_package(self, order=None, picking=None, package=None): + res = [] + self.ensure_one() + date_planned = fields.Datetime.now() + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + + if order or not picking: + raise UserError('Amazon SP MFN is intended to be used on imported orders.') + if package: + packages = package + else: + packages = picking.package_ids + + if not packages: + raise UserError('Amazon SP MFN can only be used with packed items.') + + # to use current inventory in package + packages = packages.with_context(picking_id=picking.id) + + order = picking.sale_id.sudo() + api = None + if order.amazon_bind_ids: + amazon_order = order.amazon_bind_ids[0] + api_wrapped = amazon_order.backend_id.get_wrapped_api() + # must_arrive_by_date not used, and `amazon_order.requested_date` can be False + # so if it is to be used, we must decide what to do if there is no date. + # must_arrive_by_date = fields.Datetime.from_string(amazon_order.requested_date).isoformat() + api = api_wrapped.merchant_fulfillment() + + if not api: + raise UserError('%s cannot be shipped by Amazon SP without a Sale Order or backend to use.' % picking) + + amazon_order_id, from_ = self._amazon_sp_mfn_get_order_details(order) + for package in packages: + dimensions = { + 'Length': package.packaging_id.length or 0.1, + 'Width': package.packaging_id.width or 0.1, + 'Height': package.packaging_id.height or 0.1, + 'Unit': 'inches', + } + weight, weight_unit = self._amazon_sp_mfn_convert_weight(package.shipping_weight) + items = self._amazon_sp_mfn_get_items_for_package(package, order) + # Declared value + inventory_value = self.get_insurance_value(picking=picking, package=package) + sig_req = self.get_signature_required(picking=picking, package=packages) + + + ShipmentRequestDetails = { + 'AmazonOrderId': amazon_order_id, + 'ShipFromAddress': from_, + 'Weight': {'Unit': weight_unit, 'Value': weight}, + 'SellerOrderId': order.name, + # The format of these dates cannot be determined, attempts: + # 2021-04-27 08:00:00 + # 2021-04-27T08:00:00 + # 2021-04-27T08:00:00Z + # 2021-04-27T08:00:00+00:00 + # 'ShipDate': date_planned, + # 'MustArriveByDate': must_arrive_by_date, + 'ShippingServiceOptions': { + 'DeliveryExperience': 'DeliveryConfirmationWithSignature' if sig_req else 'NoTracking', + # CarrierWillPickUp is required + 'CarrierWillPickUp': False, + # Note: Scheduled carrier pickup is available only using Dynamex (US), DPD (UK), and Royal Mail (UK). + 'DeclaredValue': { + 'Amount': inventory_value, + 'CurrencyCode': 'USD' + }, + # Conflicts at time of shipping for the above + # 'CarrierWillPickUpOption': 'NoPreference', + 'LabelFormat': 'ZPL203' + }, + 'ItemList': [{ + 'OrderItemId': i[0], + 'Quantity': i[1], + 'ItemWeight': i[2], + 'ItemDescription': i[3], + } for i in items], + 'PackageDimensions': dimensions, + } + + try: + # api_services = api.get_eligible_shipment_services(ShipmentRequestDetails, ShippingOfferingFilter={ + # 'IncludePackingSlipWithLabel': False, + # 'IncludeComplexShippingOptions': False, + # 'CarrierWillPickUp': 'CarrierWillPickUp', + # 'DeliveryExperience': 'NoTracking', + # }) + api_services = api.get_eligible_shipment_services(ShipmentRequestDetails) + except api_wrapped.SellingApiForbiddenException: + raise UserError('Your Amazon SP API access does not include MerchantFulfillment') + except api_wrapped.SellingApiException as e: + raise UserError('API Exception: ' + str(e.message)) + + api_services = api_services.payload + # project into distinct carrier + allowed_services = self.amazon_sp_mfn_allowed_services.split(',') + if package and package.packaging_id.amazon_sp_mfn_allowed_services: + allowed_services = package.packaging_id.amazon_sp_mfn_allowed_services.split(',') + + api_service_list = api_services['ShippingServiceList'] + if not isinstance(api_service_list, list): + api_service_list = [api_service_list] + + for s in filter(lambda s: s['ShippingServiceId'] in allowed_services, api_service_list): + _logger.warning('ShippingService: ' + str(s)) + service_code = s['ShippingServiceId'] + carrier = self.amazon_sp_mfn_find_delivery_carrier_for_service(service_code) + if carrier: + res.append({ + 'carrier': carrier, + 'package': package or self.env['stock.quant.package'].browse(), + 'success': True, + 'price': s['Rate']['Amount'], + 'error_message': False, + 'warning_message': False, + # 'transit_days': transit_days, + 'date_delivered': s['LatestEstimatedDeliveryDate'] if s['LatestEstimatedDeliveryDate'] else s['EarliestEstimatedDeliveryDate'], + 'date_planned': date_planned, + 'service_code': service_code, + }) + if not res: + res.append({ + 'success': False, + 'price': 0.0, + 'error_message': 'No valid rates returned from AmazonSP-MFN', + 'warning_message': False + }) + return res + + def amazon_sp_mfn_find_delivery_carrier_for_service(self, service_code): + if self.amazon_sp_mfn_allowed_services == service_code: + return self + carrier = self.search([('amazon_sp_mfn_allowed_services', '=', service_code), + ('delivery_type', '=', 'amazon_sp_mfn') + ], limit=1) + return carrier diff --git a/connector_amazon_sp/models/partner/__init__.py b/connector_amazon_sp/models/partner/__init__.py new file mode 100644 index 00000000..b9b38f48 --- /dev/null +++ b/connector_amazon_sp/models/partner/__init__.py @@ -0,0 +1,3 @@ +# © 2021 Hibou Corp. + +from . import common diff --git a/connector_amazon_sp/models/partner/common.py b/connector_amazon_sp/models/partner/common.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/connector_amazon_sp/models/partner/common.py @@ -0,0 +1 @@ + diff --git a/connector_amazon_sp/models/product/__init__.py b/connector_amazon_sp/models/product/__init__.py new file mode 100644 index 00000000..1d379361 --- /dev/null +++ b/connector_amazon_sp/models/product/__init__.py @@ -0,0 +1,4 @@ +# © 2021 Hibou Corp. + +from . import common +from . import exporter diff --git a/connector_amazon_sp/models/product/common.py b/connector_amazon_sp/models/product/common.py new file mode 100644 index 00000000..f5c7f38f --- /dev/null +++ b/connector_amazon_sp/models/product/common.py @@ -0,0 +1,293 @@ +# © 2021 Hibou Corp. + +from base64 import b64encode +from datetime import timedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.addons.component.core import Component + +PRODUCT_SKU_WITH_WAREHOUSE = '%s-%s' + + +class AmazonProductProduct(models.Model): + _name = 'amazon.product.product' + _inherit = 'amazon.binding' + _inherits = {'product.product': 'odoo_id'} + _description = 'Amazon Product Listing' + _rec_name = 'external_id' + + odoo_id = fields.Many2one('product.product', + string='Product', + required=True, + ondelete='cascade') + asin = fields.Char(string='ASIN') + state = fields.Selection([ + ('draft', 'Draft'), + ('sent', 'Submitted'), + ], default='draft') + warehouse_id = fields.Many2one('stock.warehouse', + string='Warehouse', + ondelete='set null') + backend_warehouse_ids = fields.Many2many(related='backend_id.warehouse_ids') + backend_fba_warehouse_ids = fields.Many2many(related='backend_id.fba_warehouse_ids') + date_product_sent = fields.Datetime(string='Last Product Update') + date_price_sent = fields.Datetime(string='Last Price Update') + date_inventory_sent = fields.Datetime(string='Last Inventory Update') + buffer_qty = fields.Integer(string='Buffer Quantity', + help='Stock to hold back from Amazon for listings. (-1 means use the backend default)', + default=-1) + + @api.onchange('odoo_id', 'warehouse_id', 'default_code') + def _onchange_suggest_external_id(self): + with_code_and_warehouse = self.filtered(lambda p: p.default_code and p.warehouse_id) + with_code = (self - with_code_and_warehouse).filtered('default_code') + other = (self - with_code_and_warehouse - with_code) + for product in with_code_and_warehouse: + product.external_id = PRODUCT_SKU_WITH_WAREHOUSE % (product.default_code, product.warehouse_id.code) + for product in with_code: + product.external_id = product.default_code + for product in other: + product.external_id = product.external_id + + @api.multi + def button_submit_product(self): + backends = self.mapped('backend_id') + for backend in backends: + products = self.filtered(lambda p: p.backend_id == backend) + products._submit_product() + return 1 + + @api.multi + def button_update_inventory(self): + backends = self.mapped('backend_id') + for backend in backends: + products = self.filtered(lambda p: p.backend_id == backend) + products._update_inventory() + return 1 + + @api.multi + def button_update_price(self): + backends = self.mapped('backend_id') + for backend in backends: + products = self.filtered(lambda p: p.backend_id == backend) + products._update_price() + return 1 + + def _submit_product(self): + # this should be called on a product set that has the same backend + backend = self[0].backend_id + with backend.work_on(self._name) as work: + exporter = work.component(usage='amazon.product.product.exporter') + exporter.run(self) + self.write({'date_product_sent': fields.Datetime.now(), 'state': 'sent'}) + + def _update_inventory(self): + # this should be called on a product set that has the same backend + backend = self[0].backend_id + with backend.work_on(self._name) as work: + exporter = work.component(usage='amazon.product.product.exporter') + exporter.run_inventory(self) + self.write({'date_inventory_sent': fields.Datetime.now()}) + + def _update_price(self): + # this should be called on a product set that has the same backend + backend = self[0].backend_id + with backend.work_on(self._name) as work: + exporter = work.component(usage='amazon.product.product.exporter') + exporter.run_price(self) + self.write({'date_price_sent': fields.Datetime.now()}) + + def _update_for_backend_products(self, backend): + return self.search([ + ('backend_id', '=', backend.id), + ('state', '=', 'sent'), + ]) + + def update_inventory(self, backend): + products = self._update_for_backend_products(backend) + if products: + products._update_inventory() + + def update_price(self, backend): + products = self._update_for_backend_products(backend) + if products: + products._update_price() + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + amazon_bind_ids = fields.One2many('amazon.product.product', 'odoo_id', string='Amazon Listings') + + +class ProductAdapter(Component): + _name = 'amazon.product.product.adapter' + _inherit = 'amazon.adapter' + _apply_on = 'amazon.product.product' + + def _api(self): + return self.api_instance.feeds() + + def _submit_feed(self, bindings, type, content_type, data): + feed_values = { + 'backend_id': bindings[0].backend_id.id, + 'type': type, + 'content_type': content_type, + 'data': b64encode(data), + } + if len(bindings) == 1: + feed_values['amazon_product_product_id'] = bindings.id + feed = self.env['amazon.feed'].create(feed_values) + feed.with_delay(priority=19).submit_feed() # slightly higher than regular submit_feed calls + return feed + + def create(self, bindings): + feed_root, _message = self._product_data_feed(bindings) + feed_data = self._feed_string(feed_root) + self._submit_feed(bindings, 'POST_PRODUCT_DATA', 'text/xml', feed_data) + + def create_inventory(self, bindings): + feed_root, _message = self._product_inventory_feed(bindings) + feed_data = self._feed_string(feed_root) + self._submit_feed(bindings, 'POST_INVENTORY_AVAILABILITY_DATA', 'text/xml', feed_data) + + def create_price(self, bindings): + feed_root, _message = self._product_price_feed(bindings) + feed_data = self._feed_string(feed_root) + self._submit_feed(bindings, 'POST_PRODUCT_PRICING_DATA', 'text/xml', feed_data) + + def _process_product_data(self, bindings): + res = [] + for amazon_product in bindings: + # why iterate? because we probably need more data eventually... + if not amazon_product.external_id: + raise UserError('Amazon Product Listing (%s) must have an Amazon SKU filled.' % (amazon_product.id, )) + res.append({ + 'SKU': amazon_product.external_id, + }) + return res + + def _product_data_feed(self, bindings): + product_datas = self._process_product_data(bindings) + root, message = self._feed('Product', bindings[0].backend_id) + root.remove(message) + self.ElementTree.SubElement(root, 'PurgeAndReplace').text = 'false' + for i, product_data in enumerate(product_datas, 1): + message = self.ElementTree.SubElement(root, 'Message') + self.ElementTree.SubElement(message, 'MessageID').text = str(i) + # ElementTree.SubElement(message, 'OperationType').text = 'Update' + self.ElementTree.SubElement(message, 'OperationType').text = 'PartialUpdate' + product = self.ElementTree.SubElement(message, 'Product') + self.ElementTree.SubElement(product, 'SKU').text = product_data['SKU'] + # standard_product_id = ElementTree.SubElement(product, 'StandardProductID') + # ElementTree.SubElement(standard_product_id, 'Type').text = product_data['StandardProductID.Type'] + # ElementTree.SubElement(standard_product_id, 'Value').text = product_data['StandardProductID.Value'] + # description_data = ElementTree.SubElement(product, 'DescriptionData') + # ElementTree.SubElement(description_data, 'Title').text = product_data['Title'] + # ElementTree.SubElement(description_data, 'Brand').text = product_data['Brand'] + # ElementTree.SubElement(description_data, 'Description').text = product_data['Description'] + # for bullet in product_data['BulletPoints']: + # ElementTree.SubElement(description_data, 'BulletPoint').text = bullet + # ElementTree.SubElement(description_data, 'Manufacturer').text = product_data['Manufacturer'] + # ElementTree.SubElement(description_data, 'ItemType').text = product_data['ItemType'] + return root, message + + def _process_product_inventory(self, bindings): + def _qty(binding, buffer_qty): + # qty available is all up inventory, less outgoing inventory gives qty to send + qty = binding.qty_available - binding.outgoing_qty + if binding.buffer_qty >= 0.0: + return max((0.0, qty - binding.buffer_qty)) + return max((0.0, qty - buffer_qty)) + + res = [] + backend = bindings[0].backend_id + backend_warehouses = backend.warehouse_ids + backend_fba_warehouses = backend.fba_warehouse_ids + warehouses = bindings.mapped('warehouse_id') + for warehouse in warehouses: + wh_bindings = bindings.filtered(lambda p: p.warehouse_id == warehouse).with_context(warehouse=warehouse.id) + buffer_qty = backend.fba_buffer_qty if warehouse in backend_fba_warehouses else backend.buffer_qty + for binding in wh_bindings: + res.append((binding.external_id, _qty(binding, buffer_qty))) + + buffer_qty = backend.buffer_qty + for binding in bindings.filtered(lambda p: not p.warehouse_id).with_context(warehouse=backend_warehouses.ids): + res.append((binding.external_id, _qty(binding, buffer_qty))) + return res + + def _product_inventory_feed(self, bindings): + product_datas = self._process_product_inventory(bindings) + root, message = self._feed('Inventory', bindings[0].backend_id) + root.remove(message) + for i, product_data in enumerate(product_datas, 1): + sku, qty = product_data + message = self.ElementTree.SubElement(root, 'Message') + self.ElementTree.SubElement(message, 'MessageID').text = str(i) + # ElementTree.SubElement(message, 'OperationType').text = 'Update' + self.ElementTree.SubElement(message, 'OperationType').text = 'Update' + inventory = self.ElementTree.SubElement(message, 'Inventory') + self.ElementTree.SubElement(inventory, 'SKU').text = sku + self.ElementTree.SubElement(inventory, 'Quantity').text = str(int(qty)) + return root, message + + def _process_product_price(self, bindings): + def _process_product_price_internal(env, binding, pricelist, res): + price = binding.lst_price + sale_price = None + date_start = None + date_end = None + if pricelist: + rule = None + sale_price, rule_id = pricelist.get_product_price_rule(binding.odoo_id, 1.0, None) + if rule_id: + rule = env['product.pricelist.item'].browse(rule_id).exists() + if rule and (rule.date_start or rule.date_end): + date_start = rule.date_start + date_end = rule.date_end + res.append((binding.external_id, price, sale_price, date_start, date_end)) + + res = [] + backend = bindings[0].backend_id + pricelist = backend.pricelist_id + fba_pricelist = backend.fba_pricelist_id + backend_fba_warehouses = backend.fba_warehouse_ids + fba_bindings = bindings.filtered(lambda b: b.warehouse_id and b.warehouse_id in backend_fba_warehouses) + for binding in fba_bindings: + _process_product_price_internal(self.env, binding, fba_pricelist, res) + for binding in (bindings - fba_bindings): + _process_product_price_internal(self.env, binding, pricelist, res) + return res + + def _product_price_feed(self, bindings): + backend = bindings[0].backend_id + product_datas = self._process_product_price(bindings) + root, message = self._feed('Price', backend) + root.remove(message) + now = fields.Datetime.now() + tomorrow = str(fields.Datetime.from_string(now) + timedelta(days=1)) + + for i, product_data in enumerate(product_datas, 1): + sku, _price, _sale_price, date_start, date_end = product_data + message = self.ElementTree.SubElement(root, 'Message') + self.ElementTree.SubElement(message, 'MessageID').text = str(i) + # ElementTree.SubElement(message, 'OperationType').text = 'Update' + # self.ElementTree.SubElement(message, 'OperationType').text = 'Update' + price = self.ElementTree.SubElement(message, 'Price') + self.ElementTree.SubElement(price, 'SKU').text = sku + standard_price = self.ElementTree.SubElement(price, 'StandardPrice') + standard_price.text = '%0.2f' % (_price, ) + standard_price.attrib['currency'] = 'USD' # TODO gather currency + if _sale_price and abs(_price - _sale_price) > 0.01: + sale = self.ElementTree.SubElement(price, 'Sale') + if not date_start: + date_start = now + self.ElementTree.SubElement(sale, 'StartDate').text = fields.Datetime.from_string(date_start).isoformat() + if not date_end: + date_end = tomorrow + self.ElementTree.SubElement(sale, 'EndDate').text = fields.Datetime.from_string(date_end).isoformat() + sale_price = self.ElementTree.SubElement(sale, 'SalePrice') + sale_price.text = '%0.2f' % (_sale_price, ) + sale_price.attrib['currency'] = 'USD' # TODO gather currency + return root, message diff --git a/connector_amazon_sp/models/product/exporter.py b/connector_amazon_sp/models/product/exporter.py new file mode 100644 index 00000000..b84f6417 --- /dev/null +++ b/connector_amazon_sp/models/product/exporter.py @@ -0,0 +1,22 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.core import Component + + +class AmazonProductProductExporter(Component): + _name = 'amazon.product.product.exporter' + _inherit = 'amazon.exporter' + _apply_on = ['amazon.product.product'] + _usage = 'amazon.product.product.exporter' + + def run(self, bindings): + # TODO should exporter prepare feed data? + self.backend_adapter.create(bindings) + + def run_inventory(self, bindings): + # TODO should exporter prepare feed data? + self.backend_adapter.create_inventory(bindings) + + def run_price(self, bindings): + # TODO should exporter prepare feed data? + self.backend_adapter.create_price(bindings) diff --git a/connector_amazon_sp/models/sale_order/__init__.py b/connector_amazon_sp/models/sale_order/__init__.py new file mode 100644 index 00000000..4a460e02 --- /dev/null +++ b/connector_amazon_sp/models/sale_order/__init__.py @@ -0,0 +1,4 @@ +# © 2021 Hibou Corp. + +from . import common +from . import importer diff --git a/connector_amazon_sp/models/sale_order/common.py b/connector_amazon_sp/models/sale_order/common.py new file mode 100644 index 00000000..3e22c17a --- /dev/null +++ b/connector_amazon_sp/models/sale_order/common.py @@ -0,0 +1,283 @@ +# © 2021 Hibou Corp. + +import logging +from time import sleep + +import odoo.addons.decimal_precision as dp + +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from odoo.addons.queue_job.job import job, related_action +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +from ...components.api.amazon import RequestRateError + +SO_REQUEST_SLEEP_SECONDS = 30 + +_logger = logging.getLogger(__name__) + +SO_IMPORT_RETRY_PATTERN = { + 1: 10 * 60, + 2: 30 * 60, +} + + +class AmazonSaleOrder(models.Model): + _name = 'amazon.sale.order' + _inherit = 'amazon.binding' + _description = 'Amazon Sale Order' + _inherits = {'sale.order': 'odoo_id'} + _order = 'date_order desc, id desc' + + odoo_id = fields.Many2one(comodel_name='sale.order', + string='Sale Order', + required=True, + ondelete='cascade') + amazon_order_line_ids = fields.One2many( + comodel_name='amazon.sale.order.line', + inverse_name='amazon_order_id', + string='Amazon Order Lines' + ) + total_amount = fields.Float( + string='Total amount', + digits=dp.get_precision('Account') + ) + # total_amount_tax = fields.Float( + # string='Total amount w. tax', + # digits=dp.get_precision('Account') + # ) + # Ideally would be a selection, but there are/will be more codes we might + # not be able to predict like 'Second US D2D Dom' + # Standard, Expedited, Second US D2D Dom, + fulfillment_channel = fields.Selection([ + ('AFN', 'Amazon'), + ('MFN', 'Merchant'), + ], string='Fulfillment Channel') + ship_service_level = fields.Char(string='Shipping Service Level') + ship_service_level_category = fields.Char(string='Shipping Service Level Category') + marketplace = fields.Char(string='Marketplace') + order_type = fields.Char(string='Order Type') + is_business_order = fields.Boolean(string='Is Business Order') + is_prime = fields.Boolean(string='Is Prime') + is_global_express_enabled = fields.Boolean(string='Is Global Express') + is_premium = fields.Boolean(string='Is Premium') + is_sold_by_ab = fields.Boolean(string='Is Sold By AB') + is_amazon_order = fields.Boolean('Is Amazon Order', compute='_compute_is_amazon_order') + + def is_fba(self): + return self.fulfillment_channel == 'AFN' + + def _compute_is_amazon_order(self): + for so in self: + so.is_amazon_order = True + + @job(default_channel='root.amazon') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of Sales Orders from Amazon """ + return super(AmazonSaleOrder, self).import_batch(backend, filters=filters) + + @job(default_channel='root.amazon', retry_pattern=SO_IMPORT_RETRY_PATTERN) + @related_action(action='related_action_unwrap_binding') + @api.model + def import_record(self, backend, external_id, force=False): + return super().import_record(backend, external_id, force=force) + + @api.multi + def action_confirm(self): + res = self.odoo_id.action_confirm() + if res and hasattr(res, '__getitem__'): # Button returned an action: we need to set active_id to the amazon sale order + res.update({ + 'context': { + 'active_id': self.ids[0], + 'active_ids': self.ids + } + }) + return res + + @api.multi + def action_cancel(self): + return self.odoo_id.action_cancel() + + @api.multi + def action_draft(self): + return self.odoo_id.action_draft() + + @api.multi + def action_view_delivery(self): + res = self.odoo_id.action_view_delivery() + res.update({ + 'context': { + 'active_id': self.ids[0], + 'active_ids': self.ids + } + }) + return res + + # @job(default_channel='root.amazon') + # @api.model + # def acknowledge_order(self, backend, external_id): + # with backend.work_on(self._name) as work: + # adapter = work.component(usage='backend.adapter') + # return adapter.acknowledge_order(external_id) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + amazon_bind_ids = fields.One2many( + comodel_name='amazon.sale.order', + inverse_name='odoo_id', + string='Amazon Bindings', + ) + amazon_bind_id = fields.Many2one('amazon.sale.order', 'Amazon Binding', compute='_compute_amazon_bind_id') + is_amazon_order = fields.Boolean('Is Amazon Order', compute='_compute_is_amazon_order') + total_amount = fields.Float( + string='Total amount', + digits=dp.get_precision('Account'), + related='amazon_bind_id.total_amount' + ) + fulfillment_channel = fields.Selection(related='amazon_bind_id.fulfillment_channel') + ship_service_level = fields.Char(string='Shipping Service Level', related='amazon_bind_id.ship_service_level') + ship_service_level_category = fields.Char(string='Shipping Service Level Category', related='amazon_bind_id.ship_service_level_category') + marketplace = fields.Char(string='Marketplace', related='amazon_bind_id.marketplace') + order_type = fields.Char(string='Order Type', related='amazon_bind_id.order_type') + is_business_order = fields.Boolean(string='Is Business Order', related='amazon_bind_id.is_business_order') + is_prime = fields.Boolean(string='Is Prime', related='amazon_bind_id.is_prime') + is_global_express_enabled = fields.Boolean(string='Is Global Express', related='amazon_bind_id.is_global_express_enabled') + is_premium = fields.Boolean(string='Is Premium', related='amazon_bind_id.is_premium') + is_sold_by_ab = fields.Boolean(string='Is Sold By AB', related='amazon_bind_id.is_sold_by_ab') + + @api.depends('amazon_bind_ids') + def _compute_amazon_bind_id(self): + for so in self: + so.amazon_bind_id = so.amazon_bind_ids[:1].id + + def _compute_is_amazon_order(self): + for so in self: + so.is_amazon_order = False + + # @api.multi + # def action_confirm(self): + # res = super(SaleOrder, self).action_confirm() + # self.amazon_bind_ids.action_confirm() + # return res + + +class AmazonSaleOrderLine(models.Model): + _name = 'amazon.sale.order.line' + _inherit = 'amazon.binding' + _description = 'Amazon Sale Order Line' + _inherits = {'sale.order.line': 'odoo_id'} + + amazon_order_id = fields.Many2one(comodel_name='amazon.sale.order', + string='Amazon Sale Order', + required=True, + ondelete='cascade', + index=True) + odoo_id = fields.Many2one(comodel_name='sale.order.line', + string='Sale Order Line', + required=True, + ondelete='cascade') + backend_id = fields.Many2one( + related='amazon_order_id.backend_id', + string='Amazon Backend', + readonly=True, + store=True, + # override 'Amazon.binding', can't be INSERTed if True: + required=False, + ) + + @api.model + def create(self, vals): + amazon_order_id = vals['amazon_order_id'] + binding = self.env['amazon.sale.order'].browse(amazon_order_id) + vals['order_id'] = binding.odoo_id.id + binding = super(AmazonSaleOrderLine, self).create(vals) + return binding + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + amazon_bind_ids = fields.One2many( + comodel_name='amazon.sale.order.line', + inverse_name='odoo_id', + string="Amazon Bindings", + ) + + +class SaleOrderAdapter(Component): + _name = 'amazon.sale.order.adapter' + _inherit = 'amazon.adapter' + _apply_on = 'amazon.sale.order' + + def _api(self): + return self.api_instance.orders() + + def search(self, filters): + try: + res = self._api().get_orders(**filters) + if res.errors: + _logger.error('Error in Order search: ' + str(res.errors)) + except self.api_instance.SellingApiException as e: + raise ValidationError('SellingApiException: ' + str(e.message)) + return res.payload + + # Note that order_items_buyer_info has always returned only the order items numbers. + def read(self, order_id, + include_order_items=False, + include_order_address=False, + include_order_buyer_info=False, + include_order_items_buyer_info=False, + ): + try: + api = self._api() + order_res = api.get_order(order_id) + if order_res.errors: + _logger.error('Error in Order read: ' + str(order_res.errors)) + res = order_res.payload + + if include_order_items: + order_items_res = api.get_order_items(order_id) + if order_items_res.errors: + _logger.error('Error in Order Items read: ' + str(order_items_res.errors)) + # Note that this isn't the same as the ones below to simplify later code + # by being able to get an iterable at the top level for mapping purposes + res['OrderItems'] = order_items_res.payload.get('OrderItems', []) + + if include_order_address: + order_address_res = api.get_order_address(order_id) + if order_address_res.errors: + _logger.error('Error in Order Address read: ' + str(order_address_res.errors)) + res['OrderAddress'] = order_address_res.payload + + if include_order_buyer_info: + order_buyer_info_res = api.get_order_buyer_info(order_id) + if order_buyer_info_res.errors: + _logger.error('Error in Order Buyer Info read: ' + str(order_buyer_info_res.errors)) + res['OrderBuyerInfo'] = order_buyer_info_res.payload + + if include_order_items_buyer_info: + order_items_buyer_info_res = api.get_order_items_buyer_info(order_id) + if order_items_buyer_info_res.errors: + _logger.error('Error in Order Items Buyer Info read: ' + str(order_items_buyer_info_res.errors)) + res['OrderItemsBuyerInfo'] = order_items_buyer_info_res.payload + except self.api_instance.SellingApiException as e: + if e.message.find('You exceeded your quota for the requested resource.') >= 0: + self._sleep_rety() + raise ValidationError('SellingApiException: ' + str(e.message)) + except RequestRateError as e: + self._sleep_rety() + return res + + def _sleep_rety(self): + # we CANNOT control when the next job of this type will be scheduled (by def, the queue may even be running + # the same jobs at the same time) + # we CAN control how long we wait before we free up the current queue worker though... + # Note that we can make it so that this job doesn't re-queue right away via RetryableJobError mechanisms, + # but that is no better than the more general case of us just sleeping this long now. + _logger.warn(' !!!!!!!!!!!!! _sleep_rety !!!!!!!!!!!!') + sleep(SO_REQUEST_SLEEP_SECONDS) + raise RetryableJobError('We are being throttled and will retry later.') diff --git a/connector_amazon_sp/models/sale_order/importer.py b/connector_amazon_sp/models/sale_order/importer.py new file mode 100644 index 00000000..cfd407f2 --- /dev/null +++ b/connector_amazon_sp/models/sale_order/importer.py @@ -0,0 +1,417 @@ +# © 2021 Hibou Corp. + +import logging +from json import dumps + +from odoo import _ +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.queue_job.exception import RetryableJobError, NothingToDoJob +from ...components.mapper import normalize_datetime +from ..api import make_amz_pii_cipher, make_amz_pii_encrypt + +_logger = logging.getLogger(__name__) + + +class SaleOrderBatchImporter(Component): + _name = 'amazon.sale.order.batch.importer' + _inherit = 'amazon.delayed.batch.importer' + _apply_on = 'amazon.sale.order' + + def _import_record(self, external_id, job_options=None, **kwargs): + if not job_options: + job_options = { + 'max_retries': 0, + 'priority': 30, + } + return super(SaleOrderBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + if filters is None: + filters = {} + res = self.backend_adapter.search(filters) + orders = res.get('Orders', []) + for order in orders: + self._import_record(order['AmazonOrderId']) + + +class SaleOrderImportMapper(Component): + _name = 'amazon.sale.order.mapper' + _inherit = 'amazon.import.mapper' + _apply_on = 'amazon.sale.order' + + direct = [ + ('AmazonOrderId', 'external_id'), + (normalize_datetime('PurchaseDate'), 'effective_date'), + (normalize_datetime('LatestShipDate'), 'date_planned'), + (normalize_datetime('LatestDeliveryDate'), 'requested_date'), + ('ShipServiceLevel', 'ship_service_level'), + ('ShipmentServiceLevelCategory', 'ship_service_level_category'), + ('MarketplaceId', 'marketplace'), + ('OrderType', 'order_type'), + ('IsBusinessOrder', 'is_business_order'), + ('IsPrime', 'is_prime'), + ('IsGlobalExpressEnabled', 'is_global_express_enabled'), + ('IsPremiumOrder', 'is_premium'), + ('IsSoldByAB', 'is_sold_by_ab'), + ('FulfillmentChannel', 'fulfillment_channel'), + ] + + children = [ + ('OrderItems', 'amazon_order_line_ids', 'amazon.sale.order.line'), + ] + + def _add_shipping_line(self, map_record, values): + # Any reason it wouldn't always be free? + # We need a delivery line to prevent shipping from invoicing cost of shipping. + record = map_record.source + line_builder = self.component(usage='order.line.builder.shipping') + line_builder.price_unit = 0.0 + + if values.get('carrier_id'): + carrier = self.env['delivery.carrier'].browse(values['carrier_id']) + line_builder.product = carrier.product_id + + line = (0, 0, line_builder.get_line()) + values['order_line'].append(line) + return values + + def finalize(self, map_record, values): + values.setdefault('order_line', []) + self._add_shipping_line(map_record, values) + values.update({ + 'partner_id': self.options.partner_id, + 'partner_invoice_id': self.options.partner_invoice_id, + 'partner_shipping_id': self.options.partner_shipping_id, + }) + onchange = self.component( + usage='ecommerce.onchange.manager.sale.order' + ) + return onchange.play(values, values['amazon_order_line_ids']) + + def is_fba(self, record): + return record.get('FulfillmentChannel') == 'AFN' + + @mapping + def name(self, record): + name = record['AmazonOrderId'] + prefix = self.backend_record.fba_sale_prefix if self.is_fba(record) else self.backend_record.sale_prefix + if prefix: + name = prefix + name + return {'name': name} + + @mapping + def total_amount(self, record): + return {'total_amount': float(record.get('OrderTotal', {}).get('Amount', '0.0'))} + + @mapping + def currency_id(self, record): + currency_code = record.get('OrderTotal', {}).get('CurrencyCode') + if not currency_code: + # TODO default to company currency if not specified + return {} + currency = self.env['res.currency'].search([('name', '=', currency_code)], limit=1) + return {'currency_id': currency.id} + + @mapping + def warehouse_id(self, record): + warehouses = self.backend_record.warehouse_ids + self.backend_record.fba_warehouse_ids + postal_code = record.get('DefaultShipFromLocationAddress', {}).get('PostalCode') + if not warehouses or not postal_code: + # use default + warehouses = self.backend_record.fba_warehouse_ids if self.is_fba(record) else self.backend_record.warehouse_ids + for warehouse in warehouses: + # essentially the first of either regular or FBA warehouses + return {'warehouse_id': warehouse.id, 'company_id': warehouse.company_id.id} + return {} + warehouses = warehouses.filtered(lambda w: w.partner_id.zip == postal_code) + for warehouse in warehouses: + return {'warehouse_id': warehouse.id, 'company_id': warehouse.company_id.id} + return {} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def fiscal_position_id(self, record): + fiscal_position = self.backend_record.fba_fiscal_position_id if self.is_fba(record) else self.backend_record.fiscal_position_id + if fiscal_position: + return {'fiscal_position_id': fiscal_position.id} + + @mapping + def team_id(self, record): + team = self.backend_record.fba_team_id if self.is_fba(record) else self.backend_record.team_id + if team: + return {'team_id': team.id} + + @mapping + def user_id(self, record): + user = self.backend_record.fba_user_id if self.is_fba(record) else self.backend_record.user_id + if user: + return {'user_id': user.id} + + @mapping + def payment_mode_id(self, record): + payment_mode = self.backend_record.fba_payment_mode_id if self.is_fba(record) else self.backend_record.payment_mode_id + assert payment_mode, ("Payment mode must be specified on the Amazon Backend.") + return {'payment_mode_id': payment_mode.id} + + @mapping + def analytic_account_id(self, record): + analytic_account = self.backend_record.fba_analytic_account_id if self.is_fba(record) else self.backend_record.analytic_account_id + if analytic_account: + return {'analytic_account_id': analytic_account.id} + + @mapping + def carrier_id(self, record): + carrier = self.backend_record.fba_carrier_id if self.is_fba(record) else self.backend_record.carrier_id + if carrier: + return {'carrier_id': carrier.id} + + +class SaleOrderImporter(Component): + _name = 'amazon.sale.order.importer' + _inherit = 'amazon.importer' + _apply_on = 'amazon.sale.order' + + def _get_amazon_data(self): + """ Return the raw Amazon data for ``self.external_id`` """ + return self.backend_adapter.read(self.external_id, + include_order_items=True, + include_order_address=True, + include_order_buyer_info=True, + include_order_items_buyer_info=False, # this call doesn't add anything useful + ) + + def _must_skip(self): + if self.binder.to_internal(self.external_id): + return _('Already imported') + + def _before_import(self): + status = self.amazon_record.get('OrderStatus') + if status == 'Pending': + raise RetryableJobError('Order is Pending') + if status == 'Canceled': + raise NothingToDoJob('Order is Cancelled') + + def _create_partner(self, values): + return self.env['res.partner'].create(values) + + def _get_partner_values(self): + cipher = make_amz_pii_cipher(self.env) + if cipher: + amz_pii_encrypt = make_amz_pii_encrypt(cipher) + else: + def amz_pii_encrypt(value): + return value + + record = self.amazon_record + + # find or make partner with these details. + if 'OrderAddress' not in record or 'ShippingAddress' not in record['OrderAddress']: + raise ValueError('Order does not have OrderAddress.ShippingAddress in : ' + str(record)) + ship_info = record['OrderAddress']['ShippingAddress'] + + email = record.get('OrderBuyerInfo', {}).get('BuyerEmail', '') + phone = ship_info.get('Phone') or '' + if phone: + phone = amz_pii_encrypt(phone) + name = ship_info.get('Name') + if name: + name = amz_pii_encrypt(name) + else: + name = record['AmazonOrderId'] # customer will be named after order.... + + street = ship_info.get('AddressLine1') or '' + if street: + street = amz_pii_encrypt(street) + street2 = ship_info.get('AddressLine2') or '' + if street2: + street2 = amz_pii_encrypt(street2) + city = ship_info.get('City') or '' + country_code = ship_info.get('CountryCode') or '' + country_id = False + if country_code: + country_id = self.env['res.country'].search([('code', '=ilike', country_code)], limit=1).id + state_id = False + state_code = ship_info.get('StateOrRegion') or '' + if state_code: + state_domain = [('code', '=ilike', state_code)] + if country_id: + state_domain.append(('country_id', '=', country_id)) + state_id = self.env['res.country.state'].search(state_domain, limit=1).id + if not state_id and state_code: + # Amazon can send some strange stuff like 'TEXAS' + state_domain[0] = ('name', '=ilike', state_code) + state_id = self.env['res.country.state'].search(state_domain, limit=1).id + zip_ = ship_info.get('PostalCode') or '' + res = { + 'email': email, + 'name': name, + 'phone': phone, + 'street': street, + 'street2': street2, + 'zip': zip_, + 'city': city, + 'state_id': state_id, + 'country_id': country_id, + 'type': 'contact', + } + _logger.warn('partner values: ' + str(res)) + return res + + def _import_addresses(self): + partner_values = self._get_partner_values() + # Find or create a 'parent' partner for the address. + if partner_values['email']: + partner = self.env['res.partner'].search([ + ('email', '=', partner_values['email']), + ('parent_id', '=', False) + ], limit=1) + else: + partner = self.env['res.partner'].search([ + ('name', '=', partner_values['name']), + ('parent_id', '=', False) + ], limit=1) + if not partner: + # create partner. + partner = self._create_partner({'name': partner_values['name'], 'email': partner_values['email']}) + + partner_values['parent_id'] = partner.id + partner_values['type'] = 'other' + shipping_partner = self._create_partner(partner_values) + + self.partner = partner + self.shipping_partner = shipping_partner + + def _check_special_fields(self): + assert self.partner, ( + "self.partner should have been defined " + "in SaleOrderImporter._import_addresses") + assert self.shipping_partner, ( + "self.shipping_partner should have been defined " + "in SaleOrderImporter._import_addresses") + + def _create_data(self, map_record, **kwargs): + # non dependencies + self._check_special_fields() + return super(SaleOrderImporter, self)._create_data( + map_record, + partner_id=self.partner.id, + partner_invoice_id=self.shipping_partner.id, + partner_shipping_id=self.shipping_partner.id, + **kwargs + ) + + def _create_plan(self, binding): + plan = None + if not binding.is_fba(): + # I really do not like that we need to use planner here. + # it adds to the setup and relies on the planner being setup with the appropriate warehouses. + # Why Amazon, can you not just tell me which warehouse? + options = self.env['sale.order.make.plan'].generate_order_options(binding.odoo_id, plan_shipping=False) + if options: + plan = options[0] + + sub_options = plan.get('sub_options') + # serialize lines + if sub_options: + plan['sub_options'] = dumps(sub_options) + if plan: + option = self.env['sale.order.planning.option'].create(plan) + self.env['sale.order.make.plan'].plan_order_option(binding.odoo_id, option) + + def _create(self, data): + binding = super(SaleOrderImporter, self)._create(data) + self._create_plan(binding) + # Without this, it won't map taxes with the fiscal position. + if binding.fiscal_position_id: + binding.odoo_id._compute_tax_id() + + return binding + + def _import_dependencies(self): + record = self.amazon_record + + self._import_addresses() + + +class SaleOrderLineImportMapper(Component): + + _name = 'amazon.sale.order.line.mapper' + _inherit = 'amazon.import.mapper' + _apply_on = 'amazon.sale.order.line' + + direct = [ + ('OrderItemId', 'external_id'), + ('Title', 'name'), + ('QuantityOrdered', 'product_uom_qty'), + ] + + def _finalize_product_values(self, record, values): + # This would be a good place to create a vendor or add a route... + return values + + def _product_sku(self, record): + # This would be a good place to modify or map the SellerSKU + return record['SellerSKU'] + + def _product_values(self, record): + sku = self._product_sku(record) + name = record['Title'] + list_price = float(record.get('ItemPrice', {}).get('Amount', '0.0')) + values = { + 'default_code': sku, + 'name': name or sku, + 'type': 'product', + 'list_price': list_price, + 'categ_id': self.backend_record.product_categ_id.id, + } + return self._finalize_product_values(record, values) + + @mapping + def product_id(self, record): + asin = record['ASIN'] + sku = self._product_sku(record) + binder = self.binder_for('amazon.product.product') + product = None + amazon_product = binder.to_internal(sku) + if amazon_product: + # keep the asin up to date (or set for the first time!) + if amazon_product.asin != asin: + amazon_product.asin = asin + product = amazon_product.odoo_id # unwrap + if not product: + product = self.env['product.product'].search([ + ('default_code', '=', sku) + ], limit=1) + + if not product: + # we could use a record like (0, 0, values) + product = self.env['product.product'].create(self._product_values(record)) + amazon_product = self.env['amazon.product.product'].create({ + 'external_id': sku, + 'odoo_id': product.id, + 'backend_id': self.backend_record.id, + 'asin': asin, + 'state': 'sent', # Already exists in Amazon + }) + + return {'product_id': product.id} + + @mapping + def price_unit(self, record): + # Apparently these are all up, not per-qty + qty = float(record.get('QuantityOrdered', '1.0')) or 1.0 + price_unit = float(record.get('ItemPrice', {}).get('Amount', '0.0')) + discount = float(record.get('PromotionDiscount', {}).get('Amount', '0.0')) + # discount amount needs to be a percent... + discount = (discount / (price_unit or 1.0)) * 100.0 + return {'price_unit': price_unit / qty, 'discount': discount / qty} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} diff --git a/connector_amazon_sp/models/stock_picking/__init__.py b/connector_amazon_sp/models/stock_picking/__init__.py new file mode 100644 index 00000000..1d379361 --- /dev/null +++ b/connector_amazon_sp/models/stock_picking/__init__.py @@ -0,0 +1,4 @@ +# © 2021 Hibou Corp. + +from . import common +from . import exporter diff --git a/connector_amazon_sp/models/stock_picking/common.py b/connector_amazon_sp/models/stock_picking/common.py new file mode 100644 index 00000000..b2aee781 --- /dev/null +++ b/connector_amazon_sp/models/stock_picking/common.py @@ -0,0 +1,147 @@ +# © 2021 Hibou Corp. + +from base64 import b64encode + +from odoo import api, models, fields, _ +from odoo.addons.queue_job.job import job, related_action +from odoo.addons.component.core import Component + +import logging +_logger = logging.getLogger(__name__) + + +class AmazonStockPicking(models.Model): + _name = 'amazon.stock.picking' + _inherit = 'amazon.binding' + _inherits = {'stock.picking': 'odoo_id'} + _description = 'Amazon Delivery Order' + + odoo_id = fields.Many2one(comodel_name='stock.picking', + string='Stock Picking', + required=True, + ondelete='cascade') + amazon_order_id = fields.Many2one(comodel_name='amazon.sale.order', + string='Amazon Sale Order', + ondelete='set null') + + @job(default_channel='root.amazon') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_picking_done(self): + """ Export a complete or partial delivery order. """ + self.ensure_one() + self = self.sudo() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage='record.exporter') + return exporter.run(self) + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + amazon_bind_ids = fields.One2many( + comodel_name='amazon.stock.picking', + inverse_name='odoo_id', + string="Amazon Bindings", + ) + + def has_amazon_pii(self): + self.ensure_one() + partner = self.partner_id + if not partner or not partner.email: + return False + return partner.email.find('@marketplace.amazon.com') >= 0 + + +class StockPickingAdapter(Component): + _name = 'amazon.stock.picking.adapter' + _inherit = 'amazon.adapter' + _apply_on = 'amazon.stock.picking' + + def _api(self): + return self.api_instance.feeds() + + def create(self, amazon_picking, carrier_code, carrier_name, shipping_method, tracking): + amazon_order = amazon_picking.amazon_order_id + # api_instance = self.api_instance + # feeds_api = self._api() + + order_line_qty = self._process_picking_items(amazon_picking) + feed_root, _message = self._order_fulfillment_feed(amazon_picking, amazon_order, order_line_qty, carrier_code, carrier_name, shipping_method, tracking) + feed_data = self._feed_string(feed_root) + + feed = self.env['amazon.feed'].create({ + 'backend_id': amazon_order.backend_id.id, + 'type': 'POST_ORDER_FULFILLMENT_DATA', + 'content_type': 'text/xml', + 'data': b64encode(feed_data), + 'amazon_stock_picking_id': amazon_picking.id, + }) + + feed.with_delay(priority=20).submit_feed() + _logger.info('Feed for Amazon Order %s for tracking number %s created.' % (amazon_order.external_id, tracking)) + return True + + def _process_picking_items(self, amazon_picking): + amazon_order_line_to_qty = {} + amazon_so_lines = amazon_picking.move_lines.mapped('sale_line_id.amazon_bind_ids') + for so_line in amazon_so_lines: + stock_moves = amazon_picking.move_lines.filtered(lambda sm: sm.sale_line_id.amazon_bind_ids in so_line and sm.quantity_done) + if stock_moves: + amazon_order_line_to_qty[so_line.external_id] = sum(stock_moves.mapped('quantity_done')) + return amazon_order_line_to_qty + + def _order_fulfillment_feed(self, amazon_picking, amazon_order, order_line_qty, carrier_code, carrier_name, shipping_method, tracking): + root, message = self._feed('OrderFulfillment', amazon_order.backend_id) + order_fulfillment = self.ElementTree.SubElement(message, 'OrderFulfillment') + self.ElementTree.SubElement(order_fulfillment, 'AmazonOrderID').text = amazon_order.external_id + self.ElementTree.SubElement(order_fulfillment, 'FulfillmentDate').text = fields.Datetime.from_string(amazon_picking.create_date).isoformat() + fulfillment_data = self.ElementTree.SubElement(order_fulfillment, 'FulfillmentData') + self.ElementTree.SubElement(fulfillment_data, 'CarrierCode').text = carrier_code + self.ElementTree.SubElement(fulfillment_data, 'CarrierName').text = carrier_name + self.ElementTree.SubElement(fulfillment_data, 'ShippingMethod').text = shipping_method + self.ElementTree.SubElement(fulfillment_data, 'ShipperTrackingNumber').text = tracking + for num, qty in order_line_qty.items(): + item = self.ElementTree.SubElement(order_fulfillment, 'Item') + self.ElementTree.SubElement(item, 'AmazonOrderItemCode').text = num + self.ElementTree.SubElement(item, 'Quantity').text = str(int(qty)) # always whole + return root, message + + +class AmazonBindingStockPickingListener(Component): + _name = 'amazon.binding.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['amazon.stock.picking'] + + def on_record_create(self, record, fields=None): + record.with_delay(priority=10).export_picking_done() + + +class AmazonStockPickingListener(Component): + _name = 'amazon.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['stock.picking'] + + def on_picking_dropship_done(self, record, picking_method): + return self.on_picking_out_done(record, picking_method) + + def on_picking_out_done(self, record, picking_method): + """ + Create a ``amazon.stock.picking`` record. This record will then + be exported to Amazon. + + :param picking_method: picking_method, can be 'complete' or 'partial' + :type picking_method: str + """ + sale = record.sale_id + if not sale: + return + if record.carrier_id.delivery_type == 'amazon_sp_mfn': + # buying postage through Amazon already marks it shipped. + return + for amazon_sale in sale.amazon_bind_ids: + self.env['amazon.stock.picking'].sudo().create({ + 'backend_id': amazon_sale.backend_id.id, + 'odoo_id': record.id, + 'amazon_order_id': amazon_sale.id, + }) diff --git a/connector_amazon_sp/models/stock_picking/exporter.py b/connector_amazon_sp/models/stock_picking/exporter.py new file mode 100644 index 00000000..f4d933d0 --- /dev/null +++ b/connector_amazon_sp/models/stock_picking/exporter.py @@ -0,0 +1,41 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import NothingToDoJob + + +class AmazonPickingExporter(Component): + _name = 'amazon.stock.picking.exporter' + _inherit = 'amazon.exporter' + _apply_on = ['amazon.stock.picking'] + + def _get_tracking(self, binding): + return binding.carrier_tracking_ref or '' + + def _get_carrier_code(self, binding): + return binding.carrier_id.amazon_sp_carrier_code or 'Other' + + def _get_carrier_name(self, binding): + return binding.carrier_id.amazon_sp_carrier_name or binding.carrier_id.name or 'Other' + + def _get_shipping_method(self, binding): + return binding.carrier_id.amazon_sp_shipping_method or 'Standard' + + def run(self, binding): + """ + Export the picking to Amazon + :param binding: amazon.stock.picking + :return: + """ + if binding.external_id: + return 'Already exported' + + tracking = self._get_tracking(binding) + if not tracking: + raise NothingToDoJob('Cancelled: the delivery order does not contain tracking.') + carrier_code = self._get_carrier_code(binding) + carrier_name = self._get_carrier_name(binding) + shipping_method = self._get_shipping_method(binding) + _res = self.backend_adapter.create(binding, carrier_code, carrier_name, shipping_method, tracking) + # Note we essentially bind to our own ID because we just need to notify Amazon + self.binder.bind(str(binding.odoo_id), binding) diff --git a/connector_amazon_sp/security/ir.model.access.csv b/connector_amazon_sp/security/ir.model.access.csv new file mode 100644 index 00000000..d3427e51 --- /dev/null +++ b/connector_amazon_sp/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +manage_amazon_backend,amazon.backend,model_amazon_backend,connector.group_connector_manager,1,1,1,1 +manage_amazon_product_product,amazon.product.product,model_amazon_product_product,sales_team.group_sale_manager,1,1,1,1 +manage_amazon_feed,amazon.feed,model_amazon_feed,sales_team.group_sale_manager,1,1,1,1 +access_amazon_sale_order,amazon.sale.order,model_amazon_sale_order,sales_team.group_sale_salesman,1,1,1,0 +access_amazon_sale_order_line,amazon.sale.order.line,model_amazon_sale_order_line,sales_team.group_sale_salesman,1,1,1,1 +access_amazon_sale_order_manager,amazon.sale.order.manager,model_amazon_sale_order,sales_team.group_sale_manager,1,1,1,1 +access_amazon_sale_order_line_accountant,amazon.sale.order.line accountant,model_amazon_sale_order_line,account.group_account_user,1,1,0,0 +access_amazon_sale_order_accountant,amazon.sale.order.accountant,model_amazon_sale_order,account.group_account_user,1,1,0,0 +access_amazon_sale_order_invoicing_payments,amazon.sale.order,model_amazon_sale_order,account.group_account_invoice,1,1,0,0 +access_amazon_sale_order_line_invoicing_payments,amazon.sale.order.line,model_amazon_sale_order_line,account.group_account_invoice,1,1,0,0 diff --git a/connector_amazon_sp/static/description/icon.png b/connector_amazon_sp/static/description/icon.png new file mode 100644 index 00000000..4c2bbaae Binary files /dev/null and b/connector_amazon_sp/static/description/icon.png differ diff --git a/connector_amazon_sp/tests/__init__.py b/connector_amazon_sp/tests/__init__.py new file mode 100644 index 00000000..277da8a8 --- /dev/null +++ b/connector_amazon_sp/tests/__init__.py @@ -0,0 +1,4 @@ +# © 2021 Hibou Corp. + +from . import test_orders +from . import test_product_listing diff --git a/connector_amazon_sp/tests/api/__init__.py b/connector_amazon_sp/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/connector_amazon_sp/tests/api/feeds.py b/connector_amazon_sp/tests/api/feeds.py new file mode 100644 index 00000000..7ab9c9fd --- /dev/null +++ b/connector_amazon_sp/tests/api/feeds.py @@ -0,0 +1,88 @@ +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from sp_api.base.ApiResponse import ApiResponse +from unittest.mock import patch + + +@contextmanager +def mock_submit_feed_api(return_error=False): + + submit_feed_res1 = {'errors': None, + 'headers': {'Content-Length': '665', + 'Content-Type': 'application/json', + 'Date': datetime.now(tz=timezone.utc).strftime('%a, %d %b %Y %H:%M:00 %Z'), + }, + 'kwargs': {}, + 'next_token': None, + 'pagination': None, + 'payload': {'encryptionDetails': {'initializationVector': '', + 'key': '', + 'standard': 'AES'}, + 'feedDocumentId': '', + 'url': ''}} + + submit_feed_res2 = {'errors': None, + 'headers': {'Content-Length': '37', + 'Content-Type': 'application/json', + 'Date': datetime.now(tz=timezone.utc).strftime('%a, %d %b %Y %H:%M:00 %Z'), + }, + 'kwargs': {}, + 'next_token': None, + 'pagination': None, + 'payload': {'feedId': '555555555555'}} + if return_error: + submit_feed_res2['payload'] = {} + + with patch('odoo.addons.connector_amazon_sp.components.api.amazon.Feeds') as mock_feeds: + mock_feeds.return_value.submit_feed.return_value = ApiResponse(**submit_feed_res1), ApiResponse(**submit_feed_res2) + yield + +@contextmanager +def mock_check_feed_api(done=False): + check_feed_res3 = {'errors': None, + 'headers': {'Content-Length': '175', 'Content-Type': 'application/json', + 'Date': datetime.now(tz=timezone.utc).strftime('%a, %d %b %Y %H:%M:00 %Z')}, + 'kwargs': {}, + 'next_token': None, + 'pagination': None, + 'payload': {'createdTime': datetime.now(tz=timezone.utc).isoformat(timespec='seconds'), + 'feedId': '555555555555', + 'feedType': 'POST_PRODUCT_DATA', + 'marketplaceIds': ['555555555555'], + 'processingStatus': 'IN_QUEUE'}} + if done: + check_feed_res3['payload']['processingStatus'] = 'DONE' + end_time = datetime.now(tz=timezone.utc) + start_time = end_time - timedelta(minutes=2) + check_feed_res3['payload']['processingStartTime'] = start_time.isoformat(timespec='seconds') + check_feed_res3['payload']['processingEndTime'] = end_time.isoformat(timespec='seconds') + check_feed_res3['payload']['resultFeedDocumentId'] = 'xxxxxxxx' + + feed_result_document = """ + + +
+ 1.02 + 555555555555 +
+ ProcessingReport + + 1 + + 555555555555 + Complete + + 1 + 1 + 0 + 0 + + + +
+ """ + + with patch('odoo.addons.connector_amazon_sp.components.api.amazon.Feeds') as mock_feeds: + mock_feeds.return_value.get_feed.return_value = ApiResponse(**check_feed_res3) + mock_feeds.return_value.get_feed_result_document.return_value = feed_result_document + yield diff --git a/connector_amazon_sp/tests/api/orders.py b/connector_amazon_sp/tests/api/orders.py new file mode 100644 index 00000000..15418b1f --- /dev/null +++ b/connector_amazon_sp/tests/api/orders.py @@ -0,0 +1,74 @@ +from contextlib import contextmanager +from sp_api.base.ApiResponse import ApiResponse +from unittest.mock import patch + +get_order_response = {'payload': {'AmazonOrderId': '111-1111111-1111111', + 'PurchaseDate': '2021-04-24T20:22:03Z', + 'LastUpdateDate': '2021-04-26T17:25:41Z', + 'OrderStatus': 'Shipped', + 'FulfillmentChannel': 'MFN', + 'SalesChannel': 'Amazon.com', + 'ShipServiceLevel': 'Std US D2D Dom', + 'OrderTotal': {'CurrencyCode': 'USD', + 'Amount': '159.96'}, + 'NumberOfItemsShipped': 1, + 'NumberOfItemsUnshipped': 0, + 'PaymentMethod': 'Other', + 'PaymentMethodDetails': ['Standard'], + 'IsReplacementOrder': False, + 'MarketplaceId': 'ATVPDKIKX0DER', + 'ShipmentServiceLevelCategory': 'Standard', + 'OrderType': 'StandardOrder', + 'EarliestShipDate': '2021-04-26T07:00:00Z', + 'LatestShipDate': '2021-04-27T06:59:59Z', + 'EarliestDeliveryDate': '2021-04-30T07:00:00Z', + 'LatestDeliveryDate': '2021-05-01T06:59:59Z', + 'IsBusinessOrder': False, + 'IsPrime': True, + 'IsGlobalExpressEnabled': False, + 'IsPremiumOrder': False, + 'IsSoldByAB': False, + 'DefaultShipFromLocationAddress': {'Name': 'null', + 'AddressLine1': 'null', + 'AddressLine2': 'null', + 'City': 'SELLERSBURG', + 'StateOrRegion': 'IN', + 'PostalCode': '47172', + 'CountryCode': 'US'}, + 'IsISPU': False}} + +get_order_items_response = {'payload': {'AmazonOrderId': '111-1111111-1111111', + 'OrderItems': [ + {'ASIN': 'A1B1C1D1E1', + 'OrderItemId': '12345678901234', + 'SellerSKU': 'TEST_PRODUCT', + 'Title': 'Test Product Purchased From Amazon', + 'QuantityOrdered': 1, 'QuantityShipped': 1, + 'ProductInfo': {'NumberOfItems': '1'}, + 'ItemPrice': {'CurrencyCode': 'USD', 'Amount': '199.95'}, + 'ItemTax': {'CurrencyCode': 'USD', 'Amount': '0.00'}, + 'PromotionDiscount': {'CurrencyCode': 'USD', 'Amount': '39.99'}, + 'PromotionDiscountTax': {'CurrencyCode': 'USD', 'Amount': '0.00'}, + 'PromotionIds': ['Coupon'], + 'IsGift': 'false', + 'ConditionId': 'New', + 'ConditionSubtypeId': 'New', + 'IsTransparency': False}]}} + +get_order_address_response = {'payload': {'AmazonOrderId': '111-1111111-1111111', + 'ShippingAddress': {'StateOrRegion': 'FL', 'PostalCode': '34655-5649', + 'City': 'NEW PORT RICHEY', 'CountryCode': 'US', + 'Name': ''}}} + +get_order_buyer_info_response = {'payload': {'AmazonOrderId': '111-1111111-1111111', + 'BuyerEmail': 'obfuscated@marketplace.amazon.com'}} + + +@contextmanager +def mock_orders_api(): + with patch('odoo.addons.connector_amazon_sp.components.api.amazon.Orders') as mock_orders: + mock_orders.return_value.get_order.return_value = ApiResponse(**get_order_response) + mock_orders.return_value.get_order_items.return_value = ApiResponse(**get_order_items_response) + mock_orders.return_value.get_order_address.return_value = ApiResponse(**get_order_address_response) + mock_orders.return_value.get_order_buyer_info.return_value = ApiResponse(**get_order_buyer_info_response) + yield diff --git a/connector_amazon_sp/tests/common.py b/connector_amazon_sp/tests/common.py new file mode 100644 index 00000000..e943276c --- /dev/null +++ b/connector_amazon_sp/tests/common.py @@ -0,0 +1,39 @@ +# © 2021 Hibou Corp. + +from odoo.addons.component.tests.common import SavepointComponentCase +import odoo + + +class AmazonTestCase(SavepointComponentCase): + """ Base class - Test the imports from a Amazon Mock. """ + + def setUp(self): + super(AmazonTestCase, self).setUp() + # disable commits when run from pytest/nosetest + odoo.tools.config['test_enable'] = True + # We need a backend configured in the db to avoid storing credentials + self.backend = self.env['amazon.backend'].create({ + 'name': 'Test', + 'api_refresh_token': 'Not null', + 'api_lwa_client_id': 'Not null', + 'api_lwa_client_secret': 'Not null', + 'api_aws_access_key': 'Not Null', + 'api_aws_secret_key': 'Not Null', + 'api_role_arn': 'Not Null', + 'merchant_id': 'Test Merchant ID', + 'payment_mode_id': self.browse_ref('account_payment_mode.payment_mode_inbound_ct1').id, + 'product_categ_id': self.browse_ref('product.product_category_1').id, + 'sale_prefix': 'TEST', + }) + + def _import_record(self, model_name, amazon_id): + assert model_name.startswith('amazon.') + + self.env[model_name].import_record(self.backend, amazon_id) + + binding = self.env[model_name].search( + [('backend_id', '=', self.backend.id), + ('external_id', '=', str(amazon_id))] + ) + self.assertEqual(len(binding), 1) + return binding diff --git a/connector_amazon_sp/tests/test_orders.py b/connector_amazon_sp/tests/test_orders.py new file mode 100644 index 00000000..a7e61bc5 --- /dev/null +++ b/connector_amazon_sp/tests/test_orders.py @@ -0,0 +1,67 @@ +# © 2021 Hibou Corp. + +from .api.orders import mock_orders_api +from .common import AmazonTestCase + + +class TestSaleOrder(AmazonTestCase): + + def _import_sale_order(self, amazon_id): + with mock_orders_api(): + return self._import_record('amazon.sale.order', amazon_id) + + def test_import_sale_order(self): + """ Import sale order and test workflow""" + amazon_order_number = '111-1111111-1111111' + binding = self._import_sale_order(amazon_order_number) + # binding.external_id will be what we pass to import_record regardless of what the API returned + self.assertEqual(binding.external_id, amazon_order_number) + self.assertTrue(binding.is_amazon_order) + self.assertFalse(binding.odoo_id.is_amazon_order) + self.assertEqual(binding.effective_date, False) # This is a computed field, should it be in the mapper? + self.assertEqual(binding.date_planned, '2021-04-27 06:59:59') + self.assertEqual(binding.requested_date, '2021-05-01 06:59:59') + self.assertEqual(binding.ship_service_level, 'Std US D2D Dom') + self.assertEqual(binding.ship_service_level_category, 'Standard') + self.assertEqual(binding.marketplace, 'ATVPDKIKX0DER') + self.assertEqual(binding.order_type, 'StandardOrder') + self.assertFalse(binding.is_business_order) + self.assertTrue(binding.is_prime) + self.assertFalse(binding.is_global_express_enabled) + self.assertFalse(binding.is_premium) + self.assertFalse(binding.is_sold_by_ab) + self.assertEqual(binding.name, 'TEST' + amazon_order_number) + self.assertAlmostEqual(binding.total_amount, 159.96) + self.assertEqual(binding.currency_id, self.browse_ref('base.USD')) + default_warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1) + self.assertEqual(binding.warehouse_id, default_warehouse) + self.assertEqual(binding.payment_mode_id, self.browse_ref('account_payment_mode.payment_mode_inbound_ct1')) + + self.assertEqual(len(binding.amazon_order_line_ids), 1) + self._test_import_sale_order_line(binding.amazon_order_line_ids[0]) + + self.assertEqual(binding.state, 'draft') + binding.action_confirm() + self.assertEqual(binding.state, 'sale') + self.assertEqual(binding.delivery_count, 1) + + binding.action_cancel() + self.assertEqual(binding.state, 'cancel') + + binding.action_draft() + self.assertEqual(binding.state, 'draft') + + def _test_import_sale_order_line(self, binding_line): + self.assertEqual(binding_line.external_id, '12345678901234') + self.assertEqual(binding_line.name, 'Test Product Purchased From Amazon') + self.assertEqual(binding_line.product_uom_qty, 1) + self.assertAlmostEqual(binding_line.price_unit, 199.95) + self.assertAlmostEqual(binding_line.discount, 20.0) + product = binding_line.product_id + self.assertEqual(product.default_code, 'TEST_PRODUCT') + self.assertEqual(product.name, 'Test Product Purchased From Amazon') + self.assertAlmostEqual(product.list_price, 199.95) + self.assertEqual(product.categ_id, self.browse_ref('product.product_category_1')) + product_binding = product.amazon_bind_ids[0] + self.assertEqual(product_binding.external_id, product.default_code) + self.assertEqual(product_binding.asin, 'A1B1C1D1E1') diff --git a/connector_amazon_sp/tests/test_product_listing.py b/connector_amazon_sp/tests/test_product_listing.py new file mode 100644 index 00000000..e680bce9 --- /dev/null +++ b/connector_amazon_sp/tests/test_product_listing.py @@ -0,0 +1,179 @@ +# © 2021 Hibou Corp. + +from base64 import b64decode +from datetime import date, datetime, timedelta +from xml.etree import ElementTree +from .api.feeds import mock_submit_feed_api, mock_check_feed_api +from .common import AmazonTestCase +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo import fields + + +class TestProductListing(AmazonTestCase): + def setUp(self): + super(TestProductListing, self).setUp() + self.product = self.browse_ref('stock.product_icecream') + self.amazon_product = self.env['amazon.product.product'].create({ + 'external_id': 'Amazon Ice Cream', + 'odoo_id': self.product.id, + 'backend_id': self.backend.id, + 'asin': '', + 'lst_price': 12.99, + }) + + def test_00_create_feed(self): + self.assertEqual(self.amazon_product.state, 'draft') + self.amazon_product.button_submit_product() + self.assertEqual(self.amazon_product.state, 'sent') + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + merchant_id = root.find('./Header/MerchantIdentifier').text + self.assertEqual(merchant_id, self.backend.merchant_id) + product_elem = root.find('./Message/Product') + self.assertEqual(product_elem.find('SKU').text, self.amazon_product.external_id) + + with mock_submit_feed_api(return_error=True): + feed.submit_feed() + self.assertEqual(feed.state, 'error_on_submit') + + with mock_submit_feed_api(): + feed.submit_feed() + self.assertEqual(feed.state, 'submitted') + self.assertEqual(feed.external_id, '555555555555') + + with mock_check_feed_api(): + with self.assertRaises(RetryableJobError): + feed.check_feed() + self.assertEqual(feed.amazon_state, 'IN_QUEUE') + + with mock_check_feed_api(done=True): + feed.check_feed() + self.assertEqual(feed.amazon_state, 'DONE') + + def test_10_update_inventory(self): + stock_location = self.env.ref('stock.stock_location_stock') + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': stock_location.id, + 'quantity': 7.0, + }) + + self.assertFalse(self.amazon_product.date_inventory_sent) + self.amazon_product.button_update_inventory() + self.assertEqual(fields.Datetime.from_string(self.amazon_product.date_inventory_sent).date(), date.today()) + + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + inventory_elem = root.find('./Message/Inventory') + self.assertEqual(inventory_elem.find('SKU').text, self.amazon_product.external_id) + self.assertEqual(float(inventory_elem.find('Quantity').text), 7.0) + + def test_11_update_inventory_global_buffer(self): + test_qty = 7.0 + global_buffer = 2.0 + self.backend.buffer_qty = global_buffer + + stock_location = self.env.ref('stock.stock_location_stock') + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': stock_location.id, + 'quantity': test_qty, + }) + + self.assertFalse(self.amazon_product.date_inventory_sent) + self.amazon_product.button_update_inventory() + self.assertEqual(fields.Datetime.from_string(self.amazon_product.date_inventory_sent).date(), date.today()) + + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + inventory_elem = root.find('./Message/Inventory') + self.assertEqual(inventory_elem.find('SKU').text, self.amazon_product.external_id) + self.assertEqual(float(inventory_elem.find('Quantity').text), test_qty - global_buffer) + + def test_12_update_inventory_listing_buffer(self): + test_qty = 7.0 + global_buffer = 2.0 + product_buffer = 3.0 + self.backend.buffer_qty = global_buffer + self.amazon_product.buffer_qty = product_buffer + + stock_location = self.env.ref('stock.stock_location_stock') + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': stock_location.id, + 'quantity': test_qty, + }) + + self.assertFalse(self.amazon_product.date_inventory_sent) + self.amazon_product.button_update_inventory() + self.assertEqual(fields.Datetime.from_string(self.amazon_product.date_inventory_sent).date(), date.today()) + + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + inventory_elem = root.find('./Message/Inventory') + self.assertEqual(inventory_elem.find('SKU').text, self.amazon_product.external_id) + self.assertEqual(float(inventory_elem.find('Quantity').text), test_qty - product_buffer) + + def test_20_update_price_no_pricelist(self): + self.assertFalse(self.amazon_product.date_price_sent) + self.amazon_product.button_update_price() + self.assertEqual(fields.Datetime.from_string(self.amazon_product.date_price_sent).date(), date.today()) + + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + price_elem = root.find('./Message/Price') + self.assertEqual(price_elem.find('SKU').text, self.amazon_product.external_id) + self.assertEqual(float(price_elem.find('StandardPrice').text), 12.99) + self.assertIsNone(price_elem.find('SalePrice')) + + def test_30_update_price_with_pricelist(self): + today = date.today() + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + self.backend.pricelist_id = self.env['product.pricelist'].create({ + 'name': 'Test Pricelist', + 'item_ids': [(0, 0, { + 'applied_on': '1_product', + 'product_tmpl_id': self.product.product_tmpl_id.id, + 'compute_price': 'fixed', + 'fixed_price': 9.99, + 'date_start': yesterday.strftime(DEFAULT_SERVER_DATE_FORMAT), + 'date_end': tomorrow.strftime(DEFAULT_SERVER_DATE_FORMAT), + })], + }) + + self.amazon_product.button_update_price() + feed = self.env['amazon.feed'].search([('amazon_product_product_id', '=', self.amazon_product.id)]) + self.assertEqual(len(feed), 1) + self.assertEqual(feed.state, 'new') + self.assertEqual(feed.amazon_state, 'not_sent') + feed_contents = b64decode(feed.data).decode('iso-8859-1') + root = ElementTree.fromstring(feed_contents) + price_elem = root.find('./Message/Price') + self.assertEqual(price_elem.find('SKU').text, self.amazon_product.external_id) + self.assertEqual(float(price_elem.find('StandardPrice').text), 12.99) + sale_elem = price_elem.find('./Sale') + self.assertEqual(float(sale_elem.find('SalePrice').text), 9.99) + self.assertEqual(sale_elem.find('StartDate').text, datetime(yesterday.year, yesterday.month, yesterday.day).isoformat()) + self.assertEqual(sale_elem.find('EndDate').text, datetime(tomorrow.year, tomorrow.month, tomorrow.day).isoformat()) diff --git a/connector_amazon_sp/views/amazon_backend_views.xml b/connector_amazon_sp/views/amazon_backend_views.xml new file mode 100644 index 00000000..9929fb83 --- /dev/null +++ b/connector_amazon_sp/views/amazon_backend_views.xml @@ -0,0 +1,163 @@ + + + + + amazon.backend.form + amazon.backend + +
+
+
+ +