mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch 'new/11.0/connector_amazon_sp' into '11.0'
WIP: new/11.0/connector_amazon_sp into 11.0 See merge request hibou-io/hibou-odoo/suite!1350
This commit is contained in:
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -44,3 +44,6 @@
|
||||
[submodule "external/hibou-oca/pos"]
|
||||
path = external/hibou-oca/pos
|
||||
url = https://github.com/hibou-io/oca-pos.git
|
||||
[submodule "external/python-amazon-sp-api"]
|
||||
path = external/python-amazon-sp-api
|
||||
url = https://github.com/hibou-io/python-amazon-sp-api.git
|
||||
|
||||
@@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \
|
||||
&& cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \
|
||||
;
|
||||
|
||||
USER 0
|
||||
RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \
|
||||
&& pip install .
|
||||
USER 104
|
||||
|
||||
EXPOSE 3000
|
||||
ENV SHELL=/bin/bash \
|
||||
THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins
|
||||
|
||||
@@ -9,6 +9,11 @@ RUN rm /etc/odoo/odoo.conf \
|
||||
&& cp /opt/odoo/hibou-suite/debian/odoo.conf /etc/odoo/odoo.conf \
|
||||
;
|
||||
|
||||
USER 0
|
||||
RUN cd /opt/odoo/hibou-suite/external/python-amazon-sp-api \
|
||||
&& pip install .
|
||||
USER 104
|
||||
|
||||
EXPOSE 3000
|
||||
ENV SHELL=/bin/bash \
|
||||
THEIA_DEFAULT_PLUGINS=local-dir:/opt/athene/plugins
|
||||
|
||||
1
connector_amazon_sp/__init__.py
Normal file
1
connector_amazon_sp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
45
connector_amazon_sp/__manifest__.py
Executable file
45
connector_amazon_sp/__manifest__.py
Executable 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",
|
||||
],
|
||||
},
|
||||
}
|
||||
8
connector_amazon_sp/components/__init__.py
Normal file
8
connector_amazon_sp/components/__init__.py
Normal 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
|
||||
1
connector_amazon_sp/components/api/__init__.py
Normal file
1
connector_amazon_sp/components/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import amazon
|
||||
182
connector_amazon_sp/components/api/amazon.py
Normal file
182
connector_amazon_sp/components/api/amazon.py
Normal 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
|
||||
79
connector_amazon_sp/components/backend_adapter.py
Normal file
79
connector_amazon_sp/components/backend_adapter.py
Normal 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
|
||||
22
connector_amazon_sp/components/binder.py
Normal file
22
connector_amazon_sp/components/binder.py
Normal 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',
|
||||
]
|
||||
310
connector_amazon_sp/components/exporter.py
Normal file
310
connector_amazon_sp/components/exporter.py
Normal 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
|
||||
323
connector_amazon_sp/components/importer.py
Normal file
323
connector_amazon_sp/components/importer.py
Normal 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)
|
||||
23
connector_amazon_sp/components/mapper.py
Normal file
23
connector_amazon_sp/components/mapper.py
Normal 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
|
||||
123
connector_amazon_sp/data/connector_amazon_sp_data.xml
Normal file
123
connector_amazon_sp/data/connector_amazon_sp_data.xml
Normal 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', '<', 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>
|
||||
11
connector_amazon_sp/models/__init__.py
Normal file
11
connector_amazon_sp/models/__init__.py
Normal 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
|
||||
3
connector_amazon_sp/models/amazon_backend/__init__.py
Normal file
3
connector_amazon_sp/models/amazon_backend/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
208
connector_amazon_sp/models/amazon_backend/common.py
Normal file
208
connector_amazon_sp/models/amazon_backend/common.py
Normal 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})
|
||||
3
connector_amazon_sp/models/amazon_binding/__init__.py
Normal file
3
connector_amazon_sp/models/amazon_binding/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
64
connector_amazon_sp/models/amazon_binding/common.py
Normal file
64
connector_amazon_sp/models/amazon_binding/common.py
Normal 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)
|
||||
3
connector_amazon_sp/models/amazon_feed/__init__.py
Normal file
3
connector_amazon_sp/models/amazon_feed/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
112
connector_amazon_sp/models/amazon_feed/common.py
Normal file
112
connector_amazon_sp/models/amazon_feed/common.py
Normal 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
|
||||
138
connector_amazon_sp/models/api.py
Normal file
138
connector_amazon_sp/models/api.py
Normal 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
|
||||
3
connector_amazon_sp/models/delivery_carrier/__init__.py
Normal file
3
connector_amazon_sp/models/delivery_carrier/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
415
connector_amazon_sp/models/delivery_carrier/common.py
Normal file
415
connector_amazon_sp/models/delivery_carrier/common.py
Normal 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
|
||||
3
connector_amazon_sp/models/partner/__init__.py
Normal file
3
connector_amazon_sp/models/partner/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
1
connector_amazon_sp/models/partner/common.py
Normal file
1
connector_amazon_sp/models/partner/common.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
4
connector_amazon_sp/models/product/__init__.py
Normal file
4
connector_amazon_sp/models/product/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
from . import exporter
|
||||
293
connector_amazon_sp/models/product/common.py
Normal file
293
connector_amazon_sp/models/product/common.py
Normal 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
|
||||
22
connector_amazon_sp/models/product/exporter.py
Normal file
22
connector_amazon_sp/models/product/exporter.py
Normal 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)
|
||||
4
connector_amazon_sp/models/sale_order/__init__.py
Normal file
4
connector_amazon_sp/models/sale_order/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
from . import importer
|
||||
283
connector_amazon_sp/models/sale_order/common.py
Normal file
283
connector_amazon_sp/models/sale_order/common.py
Normal 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.')
|
||||
417
connector_amazon_sp/models/sale_order/importer.py
Normal file
417
connector_amazon_sp/models/sale_order/importer.py
Normal 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}
|
||||
4
connector_amazon_sp/models/stock_picking/__init__.py
Normal file
4
connector_amazon_sp/models/stock_picking/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import common
|
||||
from . import exporter
|
||||
147
connector_amazon_sp/models/stock_picking/common.py
Normal file
147
connector_amazon_sp/models/stock_picking/common.py
Normal 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,
|
||||
})
|
||||
41
connector_amazon_sp/models/stock_picking/exporter.py
Normal file
41
connector_amazon_sp/models/stock_picking/exporter.py
Normal 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)
|
||||
11
connector_amazon_sp/security/ir.model.access.csv
Normal file
11
connector_amazon_sp/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
connector_amazon_sp/static/description/icon.png
Normal file
BIN
connector_amazon_sp/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
4
connector_amazon_sp/tests/__init__.py
Normal file
4
connector_amazon_sp/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# © 2021 Hibou Corp.
|
||||
|
||||
from . import test_orders
|
||||
from . import test_product_listing
|
||||
0
connector_amazon_sp/tests/api/__init__.py
Normal file
0
connector_amazon_sp/tests/api/__init__.py
Normal file
88
connector_amazon_sp/tests/api/feeds.py
Normal file
88
connector_amazon_sp/tests/api/feeds.py
Normal 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
|
||||
74
connector_amazon_sp/tests/api/orders.py
Normal file
74
connector_amazon_sp/tests/api/orders.py
Normal 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
|
||||
39
connector_amazon_sp/tests/common.py
Normal file
39
connector_amazon_sp/tests/common.py
Normal 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
|
||||
67
connector_amazon_sp/tests/test_orders.py
Normal file
67
connector_amazon_sp/tests/test_orders.py
Normal 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')
|
||||
179
connector_amazon_sp/tests/test_product_listing.py
Normal file
179
connector_amazon_sp/tests/test_product_listing.py
Normal 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())
|
||||
163
connector_amazon_sp/views/amazon_backend_views.xml
Normal file
163
connector_amazon_sp/views/amazon_backend_views.xml
Normal 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>
|
||||
68
connector_amazon_sp/views/amazon_feed_views.xml
Normal file
68
connector_amazon_sp/views/amazon_feed_views.xml
Normal 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>
|
||||
30
connector_amazon_sp/views/amazon_menus.xml
Normal file
30
connector_amazon_sp/views/amazon_menus.xml
Normal 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>
|
||||
80
connector_amazon_sp/views/amazon_product_views.xml
Normal file
80
connector_amazon_sp/views/amazon_product_views.xml
Normal 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>
|
||||
103
connector_amazon_sp/views/amazon_sale_views.xml
Normal file
103
connector_amazon_sp/views/amazon_sale_views.xml
Normal 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>
|
||||
45
connector_amazon_sp/views/delivery_carrier_views.xml
Normal file
45
connector_amazon_sp/views/delivery_carrier_views.xml
Normal 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>
|
||||
17
connector_amazon_sp/views/stock_views.xml
Normal file
17
connector_amazon_sp/views/stock_views.xml
Normal 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>
|
||||
1
external/python-amazon-sp-api
vendored
Submodule
1
external/python-amazon-sp-api
vendored
Submodule
Submodule external/python-amazon-sp-api added at 6322ec978d
Reference in New Issue
Block a user