Merge branch 'new/11.0/connector_amazon_sp' into '11.0-test'

new/11.0/connector_amazon_sp into 11.0-test

See merge request hibou-io/hibou-odoo/suite!1349
This commit is contained in:
Hibou Bot
2022-02-04 21:27:00 +00:00
52 changed files with 4278 additions and 0 deletions

3
.gitmodules vendored
View File

@@ -44,3 +44,6 @@
[submodule "external/hibou-oca/pos"] [submodule "external/hibou-oca/pos"]
path = external/hibou-oca/pos path = external/hibou-oca/pos
url = https://github.com/hibou-io/oca-pos.git url = https://github.com/hibou-io/oca-pos.git
[submodule "external/python-amazon-sp-api"]
path = external/python-amazon-sp-api
url = https://github.com/hibou-io/python-amazon-sp-api.git

View File

@@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \
&& cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \ && cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \
; ;
USER 0
RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \
&& pip install .
USER 104
EXPOSE 3000 EXPOSE 3000
ENV SHELL=/bin/bash \ ENV SHELL=/bin/bash \
THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins

View File

@@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \
&& cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \ && cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \
; ;
USER 0
RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \
&& pip install .
USER 104
EXPOSE 3000 EXPOSE 3000
ENV SHELL=/bin/bash \ ENV SHELL=/bin/bash \
THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -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",
],
},
}

View File

@@ -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

View File

@@ -0,0 +1 @@
from . import amazon

View File

@@ -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

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="ir.cron" id="ir_cron_import_sale_orders" forcecreate="True">
<field name="name">Amazon SP - Import Sales Orders</field>
<field name="active" eval="False"/>
<field name="state">code</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field ref="connector_amazon_sp.model_amazon_backend" name="model_id"/>
<field name="code">model._scheduler_import_sale_orders()</field>
</record>
<record model="ir.cron" id="ir_cron_export_product_inventory" forcecreate="True">
<field name="name">Amazon SP - Export Product Inventory</field>
<field name="active" eval="False"/>
<field name="state">code</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">8</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field ref="connector_amazon_sp.model_amazon_backend" name="model_id"/>
<field name="code">model._scheduler_export_product_inventory()</field>
</record>
<record model="ir.cron" id="ir_cron_export_product_price" forcecreate="True">
<field name="name">Amazon SP - Export Product Price</field>
<field name="active" eval="False"/>
<field name="state">code</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">24</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field ref="connector_amazon_sp.model_amazon_backend" name="model_id"/>
<field name="code">model._scheduler_export_product_price()</field>
</record>
<record model="ir.cron" id="ir_cron_queue_job_watchdog" forcecreate="True">
<field name="name">Amazon SP - Queue Job Watchdog</field>
<field name="active" eval="True"/>
<field name="state">code</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field ref="connector_amazon_sp.model_amazon_backend" name="model_id"/>
<field name="code">
# 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', '&lt;', str(start)),
('channel', 'like', 'amazon'),
])
# uncomment and run manually to see the results
# raise Warning(str(jobs))
if jobs:
jobs.requeue()
</field>
</record>
<record id="excep_wrong_total_amount" model="exception.rule">
<field name="name">Total Amount differs from Amazon</field>
<field name="description">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.</field>
<field name="sequence">30</field>
<field name="model">sale.order</field>
<field name="rule_group">sale</field>
<field name="code">failed = sale.amazon_bind_ids and abs(sale.amount_total - sale.amazon_bind_ids[0].total_amount) >= 0.01</field>
<field name="active" eval="True"/>
</record>
<record model="ir.actions.server" id="action_submit_product">
<field name="name">Submit Product</field>
<field name="model_id" ref="model_amazon_product_product"/>
<field name="binding_model_id" ref="model_amazon_product_product" />
<field name="state">code</field>
<field name="code">
records.button_submit_product()
</field>
</record>
<record model="ir.actions.server" id="action_update_inventory">
<field name="name">Update Inventory</field>
<field name="model_id" ref="model_amazon_product_product"/>
<field name="binding_model_id" ref="model_amazon_product_product" />
<field name="state">code</field>
<field name="code">
records.button_update_inventory()
</field>
</record>
<record model="ir.actions.server" id="action_update_price">
<field name="name">Update Price</field>
<field name="model_id" ref="model_amazon_product_product"/>
<field name="binding_model_id" ref="model_amazon_product_product" />
<field name="state">code</field>
<field name="code">
records.button_update_price()
</field>
</record>
</data>
</odoo>

View File

@@ -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

View File

@@ -0,0 +1,3 @@
# © 2021 Hibou Corp.
from . import common

View File

@@ -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})

View File

@@ -0,0 +1,3 @@
# © 2021 Hibou Corp.
from . import common

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
# © 2021 Hibou Corp.
from . import common

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
# © 2021 Hibou Corp.
from . import common

View File

@@ -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<br/> <b>Tracking Number : <br/>' + tracking_number + '</b>'
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

View File

@@ -0,0 +1,3 @@
# © 2021 Hibou Corp.
from . import common

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,4 @@
# © 2021 Hibou Corp.
from . import common
from . import exporter

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
# © 2021 Hibou Corp.
from . import common
from . import importer

View File

@@ -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.')

View File

@@ -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}

View File

@@ -0,0 +1,4 @@
# © 2021 Hibou Corp.
from . import common
from . import exporter

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 manage_amazon_backend amazon.backend model_amazon_backend connector.group_connector_manager 1 1 1 1
3 manage_amazon_product_product amazon.product.product model_amazon_product_product sales_team.group_sale_manager 1 1 1 1
4 manage_amazon_feed amazon.feed model_amazon_feed sales_team.group_sale_manager 1 1 1 1
5 access_amazon_sale_order amazon.sale.order model_amazon_sale_order sales_team.group_sale_salesman 1 1 1 0
6 access_amazon_sale_order_line amazon.sale.order.line model_amazon_sale_order_line sales_team.group_sale_salesman 1 1 1 1
7 access_amazon_sale_order_manager amazon.sale.order.manager model_amazon_sale_order sales_team.group_sale_manager 1 1 1 1
8 access_amazon_sale_order_line_accountant amazon.sale.order.line accountant model_amazon_sale_order_line account.group_account_user 1 1 0 0
9 access_amazon_sale_order_accountant amazon.sale.order.accountant model_amazon_sale_order account.group_account_user 1 1 0 0
10 access_amazon_sale_order_invoicing_payments amazon.sale.order model_amazon_sale_order account.group_account_invoice 1 1 0 0
11 access_amazon_sale_order_line_invoicing_payments amazon.sale.order.line model_amazon_sale_order_line account.group_account_invoice 1 1 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,4 @@
# © 2021 Hibou Corp.
from . import test_orders
from . import test_product_listing

View File

@@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<AmazonEnvelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="amzn-envelope.xsd">
<Header>
<DocumentVersion>1.02</DocumentVersion>
<MerchantIdentifier>555555555555</MerchantIdentifier>
</Header>
<MessageType>ProcessingReport</MessageType>
<Message>
<MessageID>1</MessageID>
<ProcessingReport>
<DocumentTransactionID>555555555555</DocumentTransactionID>
<StatusCode>Complete</StatusCode>
<ProcessingSummary>
<MessagesProcessed>1</MessagesProcessed>
<MessagesSuccessful>1</MessagesSuccessful>
<MessagesWithError>0</MessagesWithError>
<MessagesWithWarning>0</MessagesWithWarning>
</ProcessingSummary>
</ProcessingReport>
</Message>
</AmazonEnvelope>
"""
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

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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())

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_amazon_backend_form" model="ir.ui.view">
<field name="name">amazon.backend.form</field>
<field name="model">amazon.backend</field>
<field name="arch" type="xml">
<form string="Amazon Backend">
<header>
</header>
<sheet>
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" class="oe_inline" />
</h1>
<group name="amazon" string="Amazon Configuration">
<notebook>
<page string="API" name="api">
<group>
<field name="api_refresh_token" />
<field name="api_lwa_client_id" />
<field name="api_lwa_client_secret" />
</group>
<group>
<field name="api_aws_access_key" />
<field name="api_aws_secret_key" />
<field name="api_role_arn" />
<field name="merchant_id" />
<field name="active"/>
</group>
</page>
</notebook>
</group>
<group name="main_configuration" string="Main Configuration">
<group name="order_configuration" string="Order Defaults">
<field name="warehouse_ids" widget="many2many_tags"/>
<field name="buffer_qty"/>
<field name="analytic_account_id"/>
<field name="fiscal_position_id"/>
<field name="team_id"/>
<field name="user_id"/>
<field name="sale_prefix"/>
<field name="payment_mode_id"/>
<field name="carrier_id"/>
<field name="pricelist_id"/>
<field name="product_categ_id"/>
</group>
<group name="fba_order_configuration" string="Amazon Fulfilled">
<field name="fba_warehouse_ids" widget="many2many_tags"/>
<field name="fba_buffer_qty"/>
<field name="fba_analytic_account_id"/>
<field name="fba_fiscal_position_id"/>
<field name="fba_team_id"/>
<field name="fba_user_id"/>
<field name="fba_sale_prefix"/>
<field name="fba_payment_mode_id"/>
<field name="fba_carrier_id"/>
<field name="fba_pricelist_id"/>
</group>
<group name="automation" string="Automation">
<field name="scheduler_order_import_running" invisible="1" />
<field name="scheduler_order_import"/>
<p attrs="{'invisible': [('scheduler_order_import_running', '=', True)]}" class="text-danger" colspan="2">
The automatic scheduler is not currently enabled.
</p>
<p attrs="{'invisible': [('scheduler_order_import_running', '=', False)]}" class="text-success" colspan="2">
The automatic scheduler is enabled.
</p>
<field name="scheduler_product_inventory_export_running" invisible="1" />
<field name="scheduler_product_inventory_export"/>
<p attrs="{'invisible': [('scheduler_product_inventory_export_running', '=', True)]}" class="text-danger" colspan="2">
The automatic scheduler is not currently enabled.
</p>
<p attrs="{'invisible': [('scheduler_product_inventory_export_running', '=', False)]}" class="text-success" colspan="2">
The automatic scheduler is enabled.
</p>
<field name="scheduler_product_price_export_running" invisible="1" />
<field name="scheduler_product_price_export"/>
<p attrs="{'invisible': [('scheduler_product_price_export_running', '=', True)]}" class="text-danger" colspan="2">
The automatic scheduler is not currently enabled.
</p>
<p attrs="{'invisible': [('scheduler_product_price_export_running', '=', False)]}" class="text-success" colspan="2">
The automatic scheduler is enabled.
</p>
</group>
</group>
<notebook>
<page name="import" string="Imports">
<p class="oe_grey oe_inline">
By clicking on the buttons,
you will initiate the synchronizations
with Amazon.
Note that the import or exports
won't be done directly,
they will create 'Jobs'
executed as soon as possible.
</p>
<p class="oe_grey oe_inline">
Once imported,
some types of records,
like the products or categories,
need a manual review.
You will find the list
of the new records to review
in the menu 'Connectors > Checkpoint'.
</p>
<group>
<div>
<label string="Import sale orders since" class="oe_inline"/>
<field name="import_orders_from_date"
class="oe_inline"
nolabel="1"/>
</div>
<button name="import_sale_orders"
type="object"
class="oe_highlight"
string="Import in background"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_amazon_backend_tree" model="ir.ui.view">
<field name="name">amazon.backend.tree</field>
<field name="model">amazon.backend</field>
<field name="arch" type="xml">
<tree string="Amazon Backend">
<field name="name"/>
<field name="import_orders_from_date"/>
</tree>
</field>
</record>
<record id="action_amazon_backend" model="ir.actions.act_window">
<field name="name">Amazon Backends</field>
<field name="res_model">amazon.backend</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_amazon_backend_tree"/>
</record>
<menuitem id="menu_amazon_root_connector"
parent="connector.menu_connector_root"
name="Amazon"
sequence="10"
groups="connector.group_connector_manager"/>
<menuitem id="menu_amazon_backend_connector"
name="Backends"
parent="menu_amazon_root_connector"
action="action_amazon_backend"/>
<menuitem id="menu_amazon_backend"
name="Backends"
parent="amazon_config_menu"
action="action_amazon_backend"/>
</odoo>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_feed_form" model="ir.ui.view">
<field name="name">amazon.feed.form</field>
<field name="model">amazon.feed</field>
<field name="arch" type="xml">
<form string="Amazon Feed">
<header>
<button name="submit_feed" string="Submit" type="object" states="new" class="btn-primary"/>
<button name="submit_feed" string="Re-Submit" type="object" states="submitted,error_on_submit" />
<button name="check_feed" string="Check Feed" type="object" states="submitted" class="btn-primary"/>
<field name="state" widget="statusbar" statusbar_visible="new,submitted"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box"/>
<group>
<group>
<field name="external_id" readonly="1"/>
<field name="type" readonly="1"/>
<field name="content_type" readonly="1"/>
<field name="data" readonly="1"/>
</group>
<group>
<field name="create_date" readonly="1"/>
<field name="write_date" readonly="1"/>
<field name="backend_id" required="1"/>
<field name="response" readonly="1"/>
<field name="amazon_state" readonly="1"/>
<field name="amazon_stock_picking_id" attrs="{'invisible': [('amazon_stock_picking_id', '=', False)]}"/>
<field name="amazon_product_product_id" attrs="{'invisible': [('amazon_product_product_id', '=', False)]}"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_feed_tree" model="ir.ui.view">
<field name="name">amazon.feed.tree</field>
<field name="model">amazon.feed</field>
<field name="arch" type="xml">
<tree string="Amazon Feeds" decoration-muted="amazon_state=='DONE'">
<field name="create_date" readonly="1"/>
<field name="write_date" readonly="1"/>
<field name="external_id"/>
<field name="type"/>
<field name="backend_id"/>
<field name="state"/>
<field name="amazon_state"/>
</tree>
</field>
</record>
<record id="action_amazon_feed" model="ir.actions.act_window">
<field name="name">Amazon SP Feeds</field>
<field name="res_model">amazon.feed</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_amazon_feed"
name="Feeds"
action="action_amazon_feed"
parent="amazon_feed_menu"
sequence="2" />
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- Top menu item -->
<menuitem id="amazon_sale_menu_root"
name="Amazon SP"
web_icon="connector_amazon_sp,static/description/icon.png"
sequence="8"/>
<menuitem id="amazon_sale_order_menu"
name="Orders"
parent="amazon_sale_menu_root"
sequence="2"/>
<menuitem id="amazon_product_product_menu"
name="Listings"
parent="amazon_sale_menu_root"
sequence="20"/>
<menuitem id="amazon_feed_menu"
name="Feeds"
parent="amazon_sale_menu_root"
sequence="50"/>
<menuitem id="amazon_config_menu"
name="Configuration"
parent="amazon_sale_menu_root"
sequence="100"/>
</odoo>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_product_product_form" model="ir.ui.view">
<field name="name">amazon.product.product.form</field>
<field name="model">amazon.product.product</field>
<field name="arch" type="xml">
<form string="Amazon Product Listing">
<header>
<button name="button_submit_product" string="Submit Product" type="object" states="draft" class="btn-primary"/>
<button name="button_submit_product" string="Update Product" type="object" states="sent" />
<button name="button_update_inventory" string="Update Inventory" type="object" states="sent" class="btn-primary"/>
<button name="button_update_price" string="Update Price" type="object" states="sent" class="btn-primary"/>
<field name="state" widget="statusbar" statusbar_visible="draft,sent" clickable="True"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box"/>
<div class="oe_title">
<h1>
<field name="external_id" required="1" string="Amazon SKU"/>
</h1>
<h2>
<field name="asin" string="ASIN"/>
</h2>
</div>
<group>
<group>
<field name="odoo_id" required="1" string="Product"/>
<field name="default_code"/>
<field name="warehouse_id" domain="['|', ('id', 'in', backend_warehouse_ids), ('id', 'in', backend_fba_warehouse_ids)]"/>
<field name="buffer_qty"/>
</group>
<group>
<field name="backend_id" required="1"/>
<field name="backend_warehouse_ids" invisible="1"/>
<field name="backend_fba_warehouse_ids" invisible="1"/>
<field name="date_product_sent" readonly="1"/>
<field name="date_inventory_sent" readonly="1"/>
<field name="date_price_sent" readonly="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_amazon_product_tree" model="ir.ui.view">
<field name="name">amazon.product.product.tree</field>
<field name="model">amazon.product.product</field>
<field name="arch" type="xml">
<tree string="Amazon Listings">
<field name="external_id"/>
<field name="odoo_id"/>
<field name="asin"/>
<field name="backend_id"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="action_amazon_product_product" model="ir.actions.act_window">
<field name="name">Amazon SP Listings</field>
<field name="res_model">amazon.product.product</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_amazon_product_product"
name="Listings"
action="action_amazon_product_product"
parent="amazon_product_product_menu"
sequence="2" />
<!-- Additional Pricelist Menu -->
<menuitem id="menu_amazon_pricelist"
name="Pricelists"
parent="amazon_config_menu"
action="product.product_pricelist_action2" />
</odoo>

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_order_form" model="ir.ui.view">
<field name="name">sale.order.form.inherit.amazon_sp</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<field name="is_amazon_order" invisible="1"/>
<field name="amazon_bind_id" invisible="1"/>
<page string="Amazon SP Information" attrs="{'invisible': ['|', ('is_amazon_order', '=', True), ('amazon_bind_id', '=', False)]}">
<group>
<group>
<field name="fulfillment_channel"/>
<field name="total_amount" string="Amazon Total" widget="monetary"/>
<field name="ship_service_level"/>
<field name="ship_service_level_category"/>
<field name="marketplace"/>
<field name="order_type"/>
<field name="is_business_order"/>
<field name="is_global_express_enabled"/>
<field name="is_premium"/>
<field name="is_sold_by_ab"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<record id="view_amazon_order_tree" model="ir.ui.view">
<field name="name">amazon.sale.order.tree</field>
<field name="model">amazon.sale.order</field>
<field name="arch" type="xml">
<tree string="Amazon SP Sales Orders" decoration-danger="abs(total_amount-amount_total)>0.01" decoration-bf="message_needaction==True" decoration-muted="state=='cancel'">
<field name="message_needaction" invisible="1"/>
<field name="name" string="Order Number"/>
<field name="fulfillment_channel"/>
<field name="is_prime"/>
<field name="effective_date"/>
<field name="date_planned"/>
<field name="requested_date"/>
<field name="confirmation_date"/>
<field name="amount_total" sum="Total Tax Included" widget="monetary"/>
<field name="total_amount" sum="Amazon Total Tax Included" widget="monetary" string="Amazon Total"/>
<field name="currency_id" invisible="1"/>
<field name="invoice_status"/>
<field name="state" invisible="1"/>
</tree>
</field>
</record>
<record id="view_amazon_order_form" model="ir.ui.view">
<field name="name">amazon.sale.order.form</field>
<field name="model">amazon.sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//header" position="replace">
<header>
<button name="action_confirm" id="action_confirm" string="Confirm Sale" class="btn-primary" type="object" attrs="{'invisible': [('state', 'not in', ['draft', 'sent'])]}"/>
<button name="action_cancel" states="draft,sent,sale" type="object" string="Cancel"/>
<button name="action_draft" states="cancel" type="object" string="Set to Quotation"/>
<button name="action_unlock" type="object" string="Unlock" states="done" groups="sales_team.group_sale_manager"/>
<field name="state" widget="statusbar" statusbar_visible="draft,sent,sale"/>
</header>
</xpath>
<xpath expr="//sheet/group/group[1]" position="inside">
<field name="fulfillment_channel"/>
<field name="is_prime"/>
<field name="is_business_order"/>
<field name="is_global_express_enabled"/>
<field name="is_premium"/>
<field name="is_sold_by_ab"/>
</xpath>
<xpath expr="//sheet/group/group[2]" position="inside">
<field name="ship_service_level"/>
<field name="ship_service_level_category"/>
<field name="marketplace"/>
<field name="order_type"/>
</xpath>
<xpath expr="//field[@name='amount_total']" position="after">
<field name="total_amount" string="Amazon Total" widget="monetary"/>
</xpath>
</field>
</record>
<record id="action_amazon_orders" model="ir.actions.act_window">
<field name="name">Amazon SP Orders</field>
<field name="res_model">amazon.sale.order</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_amazon_order_tree"/>
</record>
<menuitem id="menu_amazon_sale_order"
name="Orders"
action="action_amazon_orders"
parent="amazon_sale_order_menu"
sequence="2" groups="sales_team.group_sale_salesman"/>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_delivery_carrier_form_with_provider_amazon_sp" model="ir.ui.view">
<field name="name">delivery.carrier.form.provider.amazon_sp</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='destination']" position='before'>
<page name="amazon_sp_general" string="Amazon" attrs="{'invisible': [('delivery_type', '=', 'amazon_sp_mfn')]}">
<group>
<group>
<field name="amazon_sp_carrier_code" string="CarrierCode" />
<field name="amazon_sp_carrier_name" string="CarrierName" />
<field name="amazon_sp_shipping_method" string="ShippingMethod" />
</group>
</group>
</page>
<page name="amazon_sp_mfn" string="Amazon SP MFN" attrs="{'invisible': [('delivery_type', '!=', 'amazon_sp_mfn')]}">
<group>
<p>This shipping method will pull details from a linked Sale Order.</p>
<group>
<field name="amazon_sp_mfn_allowed_services" attrs="{'required': [('delivery_type', '=', 'amazon_sp_mfn')]}"/>
</group>
<group>
<field name="amazon_sp_mfn_label_formats" attrs="{'required': [('delivery_type', '=', 'amazon_sp_mfn')]}"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<record id="product_packaging_delivery_form_amazon_sp" model="ir.ui.view">
<field name="name">product.packaging.form.delivery.amazon_sp</field>
<field name="model">product.packaging</field>
<field name="inherit_id" ref="delivery.product_packaging_delivery_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='package_carrier_type']" position='after'>
<field name="amazon_sp_mfn_allowed_services"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- This view's classes/structure do not make it easy to remove without direct class -->
<template id="report_picking_inherit" inherit_id="stock.report_picking">
<xpath expr="//div[@class='page']/div[@class='row']/div[2]" position="attributes">
<attribute name="t-if">not o.has_amazon_pii()</attribute>
</xpath>
</template>
<template id="report_delivery_document_inherit" inherit_id="stock.report_delivery_document">
<xpath expr="//div[@name='customer_address']" position="attributes">
<attribute name="t-if">not o.has_amazon_pii()</attribute>
</xpath>
</template>
</odoo>