From bf7192f71a7dfa51618fc24ccda4d2cc63a7a942 Mon Sep 17 00:00:00 2001
From: Jared Kipe
Date: Fri, 4 Feb 2022 13:25:45 -0800
Subject: [PATCH 1/2] [REL] connector_amazon_sp: for 11.0
---
connector_amazon_sp/__init__.py | 1 +
connector_amazon_sp/__manifest__.py | 45 ++
connector_amazon_sp/components/__init__.py | 8 +
.../components/api/__init__.py | 1 +
connector_amazon_sp/components/api/amazon.py | 182 ++++++++
.../components/backend_adapter.py | 79 ++++
connector_amazon_sp/components/binder.py | 22 +
connector_amazon_sp/components/exporter.py | 310 +++++++++++++
connector_amazon_sp/components/importer.py | 323 ++++++++++++++
connector_amazon_sp/components/mapper.py | 23 +
.../data/connector_amazon_sp_data.xml | 123 ++++++
connector_amazon_sp/models/__init__.py | 11 +
.../models/amazon_backend/__init__.py | 3 +
.../models/amazon_backend/common.py | 208 +++++++++
.../models/amazon_binding/__init__.py | 3 +
.../models/amazon_binding/common.py | 64 +++
.../models/amazon_feed/__init__.py | 3 +
.../models/amazon_feed/common.py | 112 +++++
connector_amazon_sp/models/api.py | 138 ++++++
.../models/delivery_carrier/__init__.py | 3 +
.../models/delivery_carrier/common.py | 415 +++++++++++++++++
.../models/partner/__init__.py | 3 +
connector_amazon_sp/models/partner/common.py | 1 +
.../models/product/__init__.py | 4 +
connector_amazon_sp/models/product/common.py | 293 ++++++++++++
.../models/product/exporter.py | 22 +
.../models/sale_order/__init__.py | 4 +
.../models/sale_order/common.py | 283 ++++++++++++
.../models/sale_order/importer.py | 417 ++++++++++++++++++
.../models/stock_picking/__init__.py | 4 +
.../models/stock_picking/common.py | 147 ++++++
.../models/stock_picking/exporter.py | 41 ++
.../security/ir.model.access.csv | 11 +
.../static/description/icon.png | Bin 0 -> 5036 bytes
connector_amazon_sp/tests/__init__.py | 4 +
connector_amazon_sp/tests/api/__init__.py | 0
connector_amazon_sp/tests/api/feeds.py | 88 ++++
connector_amazon_sp/tests/api/orders.py | 74 ++++
connector_amazon_sp/tests/common.py | 39 ++
connector_amazon_sp/tests/test_orders.py | 67 +++
.../tests/test_product_listing.py | 179 ++++++++
.../views/amazon_backend_views.xml | 163 +++++++
.../views/amazon_feed_views.xml | 68 +++
connector_amazon_sp/views/amazon_menus.xml | 30 ++
.../views/amazon_product_views.xml | 80 ++++
.../views/amazon_sale_views.xml | 103 +++++
.../views/delivery_carrier_views.xml | 45 ++
connector_amazon_sp/views/stock_views.xml | 17 +
48 files changed, 4264 insertions(+)
create mode 100644 connector_amazon_sp/__init__.py
create mode 100755 connector_amazon_sp/__manifest__.py
create mode 100644 connector_amazon_sp/components/__init__.py
create mode 100644 connector_amazon_sp/components/api/__init__.py
create mode 100644 connector_amazon_sp/components/api/amazon.py
create mode 100644 connector_amazon_sp/components/backend_adapter.py
create mode 100644 connector_amazon_sp/components/binder.py
create mode 100644 connector_amazon_sp/components/exporter.py
create mode 100644 connector_amazon_sp/components/importer.py
create mode 100644 connector_amazon_sp/components/mapper.py
create mode 100644 connector_amazon_sp/data/connector_amazon_sp_data.xml
create mode 100644 connector_amazon_sp/models/__init__.py
create mode 100644 connector_amazon_sp/models/amazon_backend/__init__.py
create mode 100644 connector_amazon_sp/models/amazon_backend/common.py
create mode 100644 connector_amazon_sp/models/amazon_binding/__init__.py
create mode 100644 connector_amazon_sp/models/amazon_binding/common.py
create mode 100644 connector_amazon_sp/models/amazon_feed/__init__.py
create mode 100644 connector_amazon_sp/models/amazon_feed/common.py
create mode 100644 connector_amazon_sp/models/api.py
create mode 100644 connector_amazon_sp/models/delivery_carrier/__init__.py
create mode 100644 connector_amazon_sp/models/delivery_carrier/common.py
create mode 100644 connector_amazon_sp/models/partner/__init__.py
create mode 100644 connector_amazon_sp/models/partner/common.py
create mode 100644 connector_amazon_sp/models/product/__init__.py
create mode 100644 connector_amazon_sp/models/product/common.py
create mode 100644 connector_amazon_sp/models/product/exporter.py
create mode 100644 connector_amazon_sp/models/sale_order/__init__.py
create mode 100644 connector_amazon_sp/models/sale_order/common.py
create mode 100644 connector_amazon_sp/models/sale_order/importer.py
create mode 100644 connector_amazon_sp/models/stock_picking/__init__.py
create mode 100644 connector_amazon_sp/models/stock_picking/common.py
create mode 100644 connector_amazon_sp/models/stock_picking/exporter.py
create mode 100644 connector_amazon_sp/security/ir.model.access.csv
create mode 100644 connector_amazon_sp/static/description/icon.png
create mode 100644 connector_amazon_sp/tests/__init__.py
create mode 100644 connector_amazon_sp/tests/api/__init__.py
create mode 100644 connector_amazon_sp/tests/api/feeds.py
create mode 100644 connector_amazon_sp/tests/api/orders.py
create mode 100644 connector_amazon_sp/tests/common.py
create mode 100644 connector_amazon_sp/tests/test_orders.py
create mode 100644 connector_amazon_sp/tests/test_product_listing.py
create mode 100644 connector_amazon_sp/views/amazon_backend_views.xml
create mode 100644 connector_amazon_sp/views/amazon_feed_views.xml
create mode 100644 connector_amazon_sp/views/amazon_menus.xml
create mode 100644 connector_amazon_sp/views/amazon_product_views.xml
create mode 100644 connector_amazon_sp/views/amazon_sale_views.xml
create mode 100644 connector_amazon_sp/views/delivery_carrier_views.xml
create mode 100644 connector_amazon_sp/views/stock_views.xml
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 0000000000000000000000000000000000000000..4c2bbaae2581c7c93cb48eb2f0d7ff1a26a47b8b
GIT binary patch
literal 5036
zcmY*dc|26@+dng7-?!{CNR}}6eP4#LWpC_bnPIXwma#TSmPC4Fk4Z|hWC_VGq6{I0
zER8in2%(af=l8tN?|tv{xz6`{U*~&$ulqWG-lSNU$qqx@?U;d*7Hfo{1j+|O&oCmK+k>_
zAfTX#`_#=5YljR)T9~VQ1qH}>cn5i+W$*#PXH|eEUj38?phG>N_yGSvoH|}h_-}>!
zDL+fYgrR>+LjAOakrq}^gP?0@sFIALjI6LW9TW=HyylHjw=snOQ+~SB67~%Z4OWN2
zA|fJWA{1nTuKB>^)YQ~qvhpx_dFfMyG%hkQ)B`Uah!gpb$^ZHoqH$i=u)(3&pg`!E
zuZL$)Sg4k;@L8jO*MH6#ipBi5Qy}i2u}%ksoqd7H$;iU~^*$}tJj<$IzJ^7gc0Tjh
zmec%O`TuhN^wET!HUB?{`Ol<(XHQ4frqhJ|``fhX%zMNf0f5y7VW?+^2W=M7-f=PF
z>F+Go!m-xVE=}?m4!t=i)AE4EP?pm(B{Fhfvq52<^f96
zL!j`BZAo66EH#Csv8@o>NX$2PK!Ap}ZMM?tJWo`W3
z{ti%>x_ZHL+HEkqh0tXx+E*W0L3wgiG4(YWwP>iDjug;=y>~1wtG~+~^pOd_D5DZF
zuRp1Bi2`P_g-xbn$PZ{1QIURIa}6AG7>fI%g$iE^7-z=Q<{?29QUv+wj0n({4X$BJ
zg>NSPJD3TeaL}8N6xxA8YQ7>A3p92y>dTVc!^DM~T#_Gx*7}@ggJ3)7TDl*+RBk+sASZ9n6#UxnNC4P6v0bVmOdZce
z>Dydlh3|MOG+l_K4fq{A4;>YPmhB6>%VWwPk;-K#Sns|>}@eg}R
z3Mi9n-Q4)J@Oz{Db|5=JocDq>e*O!FKIl_o`5twyE}`g;Gp;}O-Ys#RE3-!V71&;H
zdfpb3>iL^Y9DfZ$L+x9VB=TcyAyNG5N-_BhDqHU;^e>E}YgZ#MFkp_bhJk=OvUFSH
zyM4&`8fOw`%x&_KJss^v)%OsskbZ{q$%2|ny^imDePC-Ci;Tjj}kv3htnIXdw!AE1H_T~e1-!HZamDLfpD)B2QoXAUDjrImKO-MU_>3M~x|Gkb5wR
zadrAL9Z1}YTNUg^4GsLUo44WUaZ8~KU0C(|LMEjrLs`t@I2eKzA+~*Mj_ZfT+8-%<
zAz-=TwJfqqIpQ0;5PmL`w#=XW;$RJ&vHs&WXH{%gS2Vlzh{n9rLWWL$VGMJn`oz8%
zQ*2G8Ja9GaML^?}b#pywFTb`9EIyInM}DM1nu{4vxbh4gWlSm$1N039#TGybSsLPN
z5>HKkRN4W$V6|g>@8o>O5#8H_B9d}$WP;vG!E5s`C*0fLFgNv`4Cr@CM!c?mF)?BL
zK5j>xYO7PZ>!s5!QJ)#fAL7hL*SK9dRg1_Nb67ybT`gn+XYeqE2-_~YI$;KK9}oOWfc
zDMNO^>_>xmMNhdrL_BAkj>^KXz|F9cCS|kMe;sGObI~TFV#ZD1)ed{RKH$xX1|d$G
z1xoeMF97yak`{`@zDT=E&$Qgwn`Ga5F{eL@?Z|fX>bCIsph)}3(`>H6C1hkpa%_9Z
zZ>Ev)QkRW!ip_D;YOlETAb)KMf4hrthM?BtFa}KvDfQ3_DG_9T83}k@o%7{gvgvP4
zb!L=ZVV}Nl_jTbNu^!OXE_!2kUVp{tVwmP&_7^7z09v7Iml
zf}%+`?2jgLC%fh2WzHZE2H|MTOo+RKM@AK^BINviw!+w=oMz1TwdKXjG$QfgJr|H`
zDt;?Nom;8PN*;}3D4EK8f7$DJSp%F=cgm2Uopi8jJ~8|p4H2KimU|Ab;ovcM_?CNb
z=BpiCMD9LI+m3i3VX(lUMU>;Kv4jqRZeveD6vN-PXZ9y}Yp9YY#q<96lo;^97p^<}
zFXuXX68fE|x`UZ{vLB}xdF_z1V11S!ug`U7?tFg0=rxcaNE+t)0-Gl}OF|2Blq_YP
z{{T?!0s1~Bw@t^3eGS>~6va$4c~;{op6t_0<2~gc%JbyT`E=jy?_tx{xbC_AN#{9v
zYU{hf@urmNozJqL+YPPNtP`5LTUbX&dmZbt)gSq+Jz^%U%@?oFY8Xc`>hN|a{gkc%IY{lf80l=2dqAw=+NZ2j+oow9l1s7c%RuQ=UV?Hb?P(q`|M@Yl}R
zrTIn&P4hMp1%?90)~#bWyO=~YS@~4k0CT#+%RAqihB&L@<~BBsxphH?_sPM&-O9ta
z6*FZK3?)hvGfOz=C*dv)wcp1t;v*^+H4Al3ju;<@b#N^t_|(Pw)Ou4H_|b+e;r{A%
z*~EDqI*j4J=~RrA!s8^s#FzT8N9ALCY&CN_cmSGc55+83)#7TJdwY;itog&np1tAiatnKrdNacqfnrdtEqw(q96}fpbCQ2=
ztm^dQgEl{P9`CUN7?17JSmkKypY;m-U6C&~Z)Q8wzwvoj9KYhwR5(LxR`}*-O{Ad(
z3Fc%uR)kv&l0vGzpP{{yZ*i1O`AqAE+N>okp!ADx)#onAW~1*NJ?Qm&vo^<9;M0hN
zwJ>qyGf_U$Kp~&+RolBKytKAMtPTLJgEP(k>N?KjhtL;VO^+g>MwW@_t0>
zMxjs+6^Dy?-DUUl1(buI&DyNH`#kSmd)6lE{YcjKU>wwuw`Rdfz1iwI>&|3_j!4tP
z*L{9Y5&Ws5Bt|Tzy0-h$R9W=Gm^=8@>eFVlkud_4-jNe-|`E>`?C%6{lYUR$lrUh9vjq&11
z+HbUe?;S_}lhpF2!IznC$y8%!N}0bgX60qM9M1evtRBsim@GqA@v#f=5Mltv>7uNt
z91$!HwnWgWaJWh2WyI!laH1fVE4P3Sm#f;$+mByK>DPhyr+UH{$c>*BY+Le`xBU;`
zIfH-9{i8f4=hTvSUkYWT_70d7TDObWW2t3jD*J|)KCz^yy30pwG}A_Ae5n}GEkk0G
zpRm2ST)G!d!mdlYh>)gRGK}JLd00KLe5l1m1xq*NGJCujaok;aka$aWkX=sclY%!(
zXYO-g((XIWnbUJVEwP==fH7I#Z319z3gR;EMySx-1@r-GoJA&41
z>q;J9gjBm~)Md=x8=%xqTE*z+=9}^4OPyq5hW;L`O)mdE(@uOflQkScl
zPN%|L()Mp>#c1nIp=Eh1ze?aU3&t`5%%x-mmPsI)xu-nUYr|Fbpn|05{Gz;E36h1~
zN&+L=*|@VGQ-9j_5bStEIMRDTuKngw6;IdOde{*WDYuk-N%IjhiJ
z>g^FT_pxPkeD7!@qkDr$uPH=V1Y&7>$?{h)?o~=Z)#a||y=>f768TinSgAycHAVN`
zhzB~xcjs2Sj02s)2sf{LeEprfon@f&G75^LA=#8J`6xi-c(tq`Eyb2O@#P>|EHxN}T_5}Kt<
zC!FgNWKL}-^*?N+MqR1|awaA0fm2A)P3@~>s5-s20E==yldq*irP5?0+un9b5=4cq
cJagcJ}TjV0OW}2HUIzs
literal 0
HcmV?d00001
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 = """
+
+
+
+ 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
+
+
+
+
+
+
+ amazon.backend.tree
+ amazon.backend
+
+
+
+
+
+
+
+
+
+ Amazon Backends
+ amazon.backend
+ form
+ tree,form
+
+
+
+
+
+
+
+
+
+
diff --git a/connector_amazon_sp/views/amazon_feed_views.xml b/connector_amazon_sp/views/amazon_feed_views.xml
new file mode 100644
index 00000000..b70f3afe
--- /dev/null
+++ b/connector_amazon_sp/views/amazon_feed_views.xml
@@ -0,0 +1,68 @@
+
+
+
+
+ amazon.feed.form
+ amazon.feed
+
+
+
+
+
+
+ amazon.feed.tree
+ amazon.feed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Amazon SP Feeds
+ amazon.feed
+ form
+ tree,form
+
+
+
+
+
diff --git a/connector_amazon_sp/views/amazon_menus.xml b/connector_amazon_sp/views/amazon_menus.xml
new file mode 100644
index 00000000..71754cd8
--- /dev/null
+++ b/connector_amazon_sp/views/amazon_menus.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/connector_amazon_sp/views/amazon_product_views.xml b/connector_amazon_sp/views/amazon_product_views.xml
new file mode 100644
index 00000000..8a585f81
--- /dev/null
+++ b/connector_amazon_sp/views/amazon_product_views.xml
@@ -0,0 +1,80 @@
+
+
+
+
+ amazon.product.product.form
+ amazon.product.product
+
+
+
+
+
+
+ amazon.product.product.tree
+ amazon.product.product
+
+
+
+
+
+
+
+
+
+
+
+
+ Amazon SP Listings
+ amazon.product.product
+ form
+ tree,form
+
+
+
+
+
+
+
+
diff --git a/connector_amazon_sp/views/amazon_sale_views.xml b/connector_amazon_sp/views/amazon_sale_views.xml
new file mode 100644
index 00000000..78e82c3e
--- /dev/null
+++ b/connector_amazon_sp/views/amazon_sale_views.xml
@@ -0,0 +1,103 @@
+
+
+
+
+ sale.order.form.inherit.amazon_sp
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ amazon.sale.order.tree
+ amazon.sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ amazon.sale.order.form
+ amazon.sale.order
+
+ primary
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Amazon SP Orders
+ amazon.sale.order
+ form
+ tree,form
+
+
+
+
+
+
diff --git a/connector_amazon_sp/views/delivery_carrier_views.xml b/connector_amazon_sp/views/delivery_carrier_views.xml
new file mode 100644
index 00000000..79a9697b
--- /dev/null
+++ b/connector_amazon_sp/views/delivery_carrier_views.xml
@@ -0,0 +1,45 @@
+
+
+
+
+ delivery.carrier.form.provider.amazon_sp
+ delivery.carrier
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This shipping method will pull details from a linked Sale Order.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product.packaging.form.delivery.amazon_sp
+ product.packaging
+
+
+
+
+
+
+
+
+
diff --git a/connector_amazon_sp/views/stock_views.xml b/connector_amazon_sp/views/stock_views.xml
new file mode 100644
index 00000000..98fc06de
--- /dev/null
+++ b/connector_amazon_sp/views/stock_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ not o.has_amazon_pii()
+
+
+
+
+
+ not o.has_amazon_pii()
+
+
+
+
From 5944db313efd5635adcac628c0e9c7a351c31e9a Mon Sep 17 00:00:00 2001
From: Jared Kipe
Date: Fri, 4 Feb 2022 15:23:27 -0800
Subject: [PATCH 2/2] [MIG] connector_amazon_sp: partial 14.0
Need to investigate form for Amazon orders. Installing it complains that `action_...` doesn't actually exist. I started making pass through methods like `action_unlock` in sale_order.common, but then came to an action that would only exist in inherited views and not in this specific new form.
---
.gitmodules | 3 +
connector_amazon_sp/__manifest__.py | 3 +-
.../data/connector_amazon_sp_data.xml | 3 +-
.../models/amazon_backend/common.py | 3 -
.../models/amazon_binding/common.py | 22 ------
.../models/amazon_feed/common.py | 6 --
connector_amazon_sp/models/api.py | 12 ++--
.../models/delivery_carrier/common.py | 2 +-
connector_amazon_sp/models/product/common.py | 3 -
.../models/sale_order/common.py | 22 +-----
.../models/stock_picking/common.py | 4 --
.../views/amazon_backend_views.xml | 3 +-
.../views/amazon_feed_views.xml | 1 -
.../views/amazon_product_views.xml | 1 -
.../views/amazon_sale_views.xml | 71 +++++++++----------
connector_amazon_sp/views/stock_views.xml | 11 +--
external/python-amazon-sp-api | 1 +
17 files changed, 58 insertions(+), 113 deletions(-)
create mode 160000 external/python-amazon-sp-api
diff --git a/.gitmodules b/.gitmodules
index 4896b5d9..0bd8ec76 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -41,3 +41,6 @@
[submodule "external/hibou-oca/product-attribute"]
path = external/hibou-oca/product-attribute
url = https://github.com/hibou-io/oca-product-attribute.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/connector_amazon_sp/__manifest__.py b/connector_amazon_sp/__manifest__.py
index 1d6a73d3..6f20ffe8 100755
--- a/connector_amazon_sp/__manifest__.py
+++ b/connector_amazon_sp/__manifest__.py
@@ -1,9 +1,8 @@
{
"name": "Amazon Selling Partner Connector",
- "version": "11.0.1.0.0",
+ "version": "14.0.1.0.0",
"depends": [
"connector_ecommerce",
- "sale_order_dates",
"sale_sourced_by_line",
"delivery_hibou",
"sale_planner",
diff --git a/connector_amazon_sp/data/connector_amazon_sp_data.xml b/connector_amazon_sp/data/connector_amazon_sp_data.xml
index 9353fe1e..1a25d227 100644
--- a/connector_amazon_sp/data/connector_amazon_sp_data.xml
+++ b/connector_amazon_sp/data/connector_amazon_sp_data.xml
@@ -84,8 +84,7 @@ 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
+ failed = object.amazon_bind_ids and abs(object.amount_total - object.amazon_bind_ids[0].total_amount) >= 0.01
diff --git a/connector_amazon_sp/models/amazon_backend/common.py b/connector_amazon_sp/models/amazon_backend/common.py
index e5c44404..3e464962 100644
--- a/connector_amazon_sp/models/amazon_backend/common.py
+++ b/connector_amazon_sp/models/amazon_backend/common.py
@@ -124,7 +124,6 @@ class AmazonBackend(models.Model):
)
@contextmanager
- @api.multi
def work_on(self, model_name, **kwargs):
self.ensure_one()
amazon_api = self.get_wrapped_api()
@@ -180,12 +179,10 @@ class AmazonBackend(models.Model):
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:
diff --git a/connector_amazon_sp/models/amazon_binding/common.py b/connector_amazon_sp/models/amazon_binding/common.py
index b7c21a69..f0f3ece0 100644
--- a/connector_amazon_sp/models/amazon_binding/common.py
+++ b/connector_amazon_sp/models/amazon_binding/common.py
@@ -1,7 +1,6 @@
# © 2021 Hibou Corp.
from odoo import api, models, fields
-from odoo.addons.queue_job.job import job, related_action
class AmazonBinding(models.AbstractModel):
@@ -26,7 +25,6 @@ class AmazonBinding(models.AbstractModel):
('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 """
@@ -36,29 +34,9 @@ class AmazonBinding(models.AbstractModel):
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/common.py b/connector_amazon_sp/models/amazon_feed/common.py
index 29c89fd2..ba79bb50 100644
--- a/connector_amazon_sp/models/amazon_feed/common.py
+++ b/connector_amazon_sp/models/amazon_feed/common.py
@@ -5,7 +5,6 @@ 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
@@ -59,8 +58,6 @@ class AmazonFeed(models.Model):
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()
@@ -80,8 +77,6 @@ class AmazonFeed(models.Model):
# 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()
@@ -106,7 +101,6 @@ class AmazonFeed(models.Model):
# 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
index 3566dc4e..f1bda1da 100644
--- a/connector_amazon_sp/models/api.py
+++ b/connector_amazon_sp/models/api.py
@@ -5,7 +5,6 @@ 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)
@@ -17,7 +16,7 @@ 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):
+ if value and isinstance(value, str) and value.startswith(PREFIX):
try:
to_decrypt = b64decode(value[PREFIX_LEN:])
# remove whitespace and `ack`
@@ -32,7 +31,7 @@ def make_amz_pii_decrypt(cipher):
def make_amz_pii_encrypt(cipher):
def amz_pii_encrypt(value):
- if value and isinstance(value, pycompat.string_types) and not value.startswith(PREFIX):
+ if value and isinstance(value, str) and not value.startswith(PREFIX):
try:
to_encrypt = value.encode()
to_encrypt = pad(to_encrypt, BLOCK_SIZE)
@@ -112,6 +111,7 @@ def make_amz_pii_cipher(env):
def update(self, records, field, values):
+ """ Set the values of ``field`` for several ``records``. """
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:
@@ -120,8 +120,10 @@ def update(self, records, field, values):
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))
+ field_cache = self._data[field]
+ if field.depends_context:
+ field_cache = field_cache.setdefault(records.env.cache_key(field), {})
+ field_cache.update(zip(records._ids, values))
def _start_amz_pii_decrypt(self, env):
diff --git a/connector_amazon_sp/models/delivery_carrier/common.py b/connector_amazon_sp/models/delivery_carrier/common.py
index 0986c242..4cc7020d 100644
--- a/connector_amazon_sp/models/delivery_carrier/common.py
+++ b/connector_amazon_sp/models/delivery_carrier/common.py
@@ -25,7 +25,7 @@ class ProviderAmazonSP(models.Model):
delivery_type = fields.Selection(selection_add=[
# ('amazon_sp', 'Amazon Selling Partner'), # TODO buy shipping for regular orders?
('amazon_sp_mfn', 'Amazon SP Merchant Fulfillment')
- ])
+ ], ondelete={'amazon_sp_mfn': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})})
# Fields when uploading shipping to Amazon
amazon_sp_carrier_code = fields.Char(string='Amazon Carrier Code',
diff --git a/connector_amazon_sp/models/product/common.py b/connector_amazon_sp/models/product/common.py
index f5c7f38f..e99f2af8 100644
--- a/connector_amazon_sp/models/product/common.py
+++ b/connector_amazon_sp/models/product/common.py
@@ -50,7 +50,6 @@ class AmazonProductProduct(models.Model):
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:
@@ -58,7 +57,6 @@ class AmazonProductProduct(models.Model):
products._submit_product()
return 1
- @api.multi
def button_update_inventory(self):
backends = self.mapped('backend_id')
for backend in backends:
@@ -66,7 +64,6 @@ class AmazonProductProduct(models.Model):
products._update_inventory()
return 1
- @api.multi
def button_update_price(self):
backends = self.mapped('backend_id')
for backend in backends:
diff --git a/connector_amazon_sp/models/sale_order/common.py b/connector_amazon_sp/models/sale_order/common.py
index 3e22c17a..17414e6d 100644
--- a/connector_amazon_sp/models/sale_order/common.py
+++ b/connector_amazon_sp/models/sale_order/common.py
@@ -7,7 +7,6 @@ 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
@@ -72,19 +71,15 @@ class AmazonSaleOrder(models.Model):
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
@@ -96,15 +91,12 @@ class AmazonSaleOrder(models.Model):
})
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({
@@ -115,12 +107,8 @@ class AmazonSaleOrder(models.Model):
})
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)
+ def action_unlock(self):
+ return self.odoo_id.action_unlock()
class SaleOrder(models.Model):
@@ -158,12 +146,6 @@ class SaleOrder(models.Model):
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'
diff --git a/connector_amazon_sp/models/stock_picking/common.py b/connector_amazon_sp/models/stock_picking/common.py
index b2aee781..30463887 100644
--- a/connector_amazon_sp/models/stock_picking/common.py
+++ b/connector_amazon_sp/models/stock_picking/common.py
@@ -3,7 +3,6 @@
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
@@ -24,9 +23,6 @@ class AmazonStockPicking(models.Model):
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()
diff --git a/connector_amazon_sp/views/amazon_backend_views.xml b/connector_amazon_sp/views/amazon_backend_views.xml
index 9929fb83..708ff5dd 100644
--- a/connector_amazon_sp/views/amazon_backend_views.xml
+++ b/connector_amazon_sp/views/amazon_backend_views.xml
@@ -106,7 +106,7 @@
-
+
@@ -139,7 +139,6 @@
Amazon Backends
amazon.backend
- form
tree,form
diff --git a/connector_amazon_sp/views/amazon_feed_views.xml b/connector_amazon_sp/views/amazon_feed_views.xml
index b70f3afe..0c986c82 100644
--- a/connector_amazon_sp/views/amazon_feed_views.xml
+++ b/connector_amazon_sp/views/amazon_feed_views.xml
@@ -55,7 +55,6 @@
Amazon SP Feeds
amazon.feed
- form
tree,form
diff --git a/connector_amazon_sp/views/amazon_product_views.xml b/connector_amazon_sp/views/amazon_product_views.xml
index 8a585f81..942c08ec 100644
--- a/connector_amazon_sp/views/amazon_product_views.xml
+++ b/connector_amazon_sp/views/amazon_product_views.xml
@@ -61,7 +61,6 @@
Amazon SP Listings
amazon.product.product
- form
tree,form
diff --git a/connector_amazon_sp/views/amazon_sale_views.xml b/connector_amazon_sp/views/amazon_sale_views.xml
index 78e82c3e..b6a1f693 100644
--- a/connector_amazon_sp/views/amazon_sale_views.xml
+++ b/connector_amazon_sp/views/amazon_sale_views.xml
@@ -41,7 +41,7 @@
-
+
@@ -51,45 +51,44 @@
-
- amazon.sale.order.form
- amazon.sale.order
-
- primary
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Amazon SP Orders
amazon.sale.order
- form
tree,form
diff --git a/connector_amazon_sp/views/stock_views.xml b/connector_amazon_sp/views/stock_views.xml
index 98fc06de..e91255d1 100644
--- a/connector_amazon_sp/views/stock_views.xml
+++ b/connector_amazon_sp/views/stock_views.xml
@@ -8,10 +8,11 @@
-
-
- not o.has_amazon_pii()
-
-
+
+
+
+
+
+
diff --git a/external/python-amazon-sp-api b/external/python-amazon-sp-api
new file mode 160000
index 00000000..6322ec97
--- /dev/null
+++ b/external/python-amazon-sp-api
@@ -0,0 +1 @@
+Subproject commit 6322ec978dc376396fa922d47b40e105c5bf233b