mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
[REL] connector_amazon_sp: for 11.0
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user