[REL] connector_amazon_sp: for 11.0

This commit is contained in:
Jared Kipe
2022-02-04 13:25:45 -08:00
parent 8b2afa882d
commit bf7192f71a
48 changed files with 4264 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# © 2021 Hibou Corp.
from . import api
from . import amazon_backend
from . import amazon_binding
from . import amazon_feed
from . import delivery_carrier
# from . import partner
from . import product
from . import sale_order
from . import stock_picking

View File

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

View File

@@ -0,0 +1,208 @@
# © 2021 Hibou Corp.
from datetime import datetime, timedelta
from logging import getLogger
from contextlib import contextmanager
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from ...components.api.amazon import WrappedAPI
_logger = getLogger(__name__)
IMPORT_DELTA_BUFFER = 600 # seconds
class AmazonBackend(models.Model):
_name = 'amazon.backend'
_description = 'Amazon Backend'
_inherit = 'connector.backend'
name = fields.Char(string='Name')
active = fields.Boolean(default=True)
api_refresh_token = fields.Text(string='API Refresh Token', required=True)
api_lwa_client_id = fields.Char(string='API LWA Client ID', required=True)
api_lwa_client_secret = fields.Char(string='API LWA Client Secret', required=True)
api_aws_access_key = fields.Char(string='API AWS Access Key', required=True)
api_aws_secret_key = fields.Char(string='API AWS Secret Key', required=True)
api_role_arn = fields.Char(string='API AWS Role ARN', required=True)
merchant_id = fields.Char(string='Amazon Merchant Identifier', required=True)
warehouse_ids = fields.Many2many(
comodel_name='stock.warehouse',
string='Warehouses',
required=True,
help='Warehouses to use for delivery and stock.',
)
fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position',
string='Fiscal Position',
help='Fiscal position to use on orders.',
)
analytic_account_id = fields.Many2one(
comodel_name='account.analytic.account',
string='Analytic account',
help='If specified, this analytic account will be used to fill the '
'field on the sale order created by the connector.'
)
team_id = fields.Many2one('crm.team', string='Sales Team')
user_id = fields.Many2one('res.users', string='Salesperson',
help='Default Salesperson for newly imported orders.')
sale_prefix = fields.Char(
string='Sale Prefix',
help="A prefix put before the name of imported sales orders.\n"
"For instance, if the prefix is 'AMZ-', the sales "
"order 112-5571768504079 in Amazon, will be named 'AMZ-112-5571768504079' "
"in Odoo.",
)
payment_mode_id = fields.Many2one('account.payment.mode', string='Payment Mode')
carrier_id = fields.Many2one('delivery.carrier', string='Delivery Method')
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist')
buffer_qty = fields.Integer(string='Buffer Quantity',
help='Stock to hold back from Amazon for listings.',
default=0)
fba_warehouse_ids = fields.Many2many(
comodel_name='stock.warehouse',
relation='amazon_backend_fba_stock_warehouse_rel',
string='FBA Warehouses',
required=False,
help='Warehouses to use for FBA delivery and stock.',
)
fba_fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position',
string='FBA Fiscal Position',
help='Fiscal position to use on FBA orders.',
)
fba_analytic_account_id = fields.Many2one(
comodel_name='account.analytic.account',
string='FBA Analytic account',
help='If specified, this analytic account will be used to fill the '
'field on the sale order created by the connector.'
)
fba_team_id = fields.Many2one('crm.team', string='FBA Sales Team')
fba_user_id = fields.Many2one('res.users', string='FBA Salesperson',
help='Default Salesperson for newly imported FBA orders.')
fba_sale_prefix = fields.Char(
string='FBA Sale Prefix',
help="A prefix put before the name of imported sales orders.\n"
"For instance, if the prefix is 'FBA-', the sales "
"order 112-5571768504079 in Amazon, will be named 'FBA-112-5571768504079' "
"in Odoo.",
)
fba_payment_mode_id = fields.Many2one('account.payment.mode', string='FBA Payment Mode')
fba_carrier_id = fields.Many2one('delivery.carrier', string='FBA Delivery Method')
fba_pricelist_id = fields.Many2one('product.pricelist', string='FBA Pricelist')
fba_buffer_qty = fields.Integer(string='FBA Buffer Quantity',
help='Stock to hold back from Amazon for FBA listings.',
default=0)
# New Product fields.
product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category',
help='Default product category for newly created products.')
# Automation
scheduler_order_import_running = fields.Boolean(string='Automatic Sale Order Import is Running',
compute='_compute_scheduler_running',
compute_sudo=True)
scheduler_order_import = fields.Boolean(string='Automatic Sale Order Import')
scheduler_product_inventory_export_running = fields.Boolean(string='Automatic Product Inventory Export is Running',
compute='_compute_scheduler_running',
compute_sudo=True)
scheduler_product_inventory_export = fields.Boolean(string='Automatic Product Inventory Export')
scheduler_product_price_export_running = fields.Boolean(string='Automatic Product Price Export is Running',
compute='_compute_scheduler_running',
compute_sudo=True)
scheduler_product_price_export = fields.Boolean(string='Automatic Product Price Export')
import_orders_from_date = fields.Datetime(
string='Import sale orders from date',
)
@contextmanager
@api.multi
def work_on(self, model_name, **kwargs):
self.ensure_one()
amazon_api = self.get_wrapped_api()
with super().work_on(model_name, amazon_api=amazon_api, **kwargs) as work:
yield work
def button_test(self):
self.ensure_one()
amazon_api = self.get_wrapped_api()
Shipping = amazon_api.shipping()
raise UserError(str(Shipping.get_account()))
def get_wrapped_api(self):
self.ensure_one()
return WrappedAPI(self.env,
self.api_refresh_token,
self.api_lwa_client_id,
self.api_lwa_client_secret,
self.api_aws_access_key,
self.api_aws_secret_key,
self.api_role_arn)
def _compute_scheduler_running(self):
sched_action_so_imp = self.env.ref('connector_amazon_sp.ir_cron_import_sale_orders', raise_if_not_found=False)
sched_action_pi_exp = self.env.ref('connector_amazon_sp.ir_cron_export_product_inventory', raise_if_not_found=False)
sched_action_pp_exp = self.env.ref('connector_amazon_sp.ir_cron_export_product_price', raise_if_not_found=False)
for backend in self:
backend.scheduler_order_import_running = bool(sched_action_so_imp and sched_action_so_imp.active)
backend.scheduler_product_inventory_export_running = bool(sched_action_pi_exp and sched_action_pi_exp.active)
backend.scheduler_product_price_export_running = bool(sched_action_pp_exp and sched_action_pp_exp.active)
@api.model
def _scheduler_import_sale_orders(self):
# potential hook for customization (e.g. pad from date or provide its own)
backends = self.search([
('scheduler_order_import', '=', True),
])
return backends.import_sale_orders()
@api.model
def _scheduler_export_product_inventory(self):
backends = self.search([
('scheduler_product_inventory_export', '=', True),
])
for backend in backends:
self.env['amazon.product.product'].update_inventory(backend)
@api.model
def _scheduler_export_product_price(self):
backends = self.search([
('scheduler_product_price_export', '=', True),
])
for backend in backends:
self.env['amazon.product.product'].update_price(backend)
@api.multi
def import_sale_orders(self):
self._import_from_date('amazon.sale.order', 'import_orders_from_date')
return True
@api.multi
def _import_from_date(self, model_name, from_date_field):
import_start_time = datetime.now().replace(microsecond=0) - timedelta(seconds=IMPORT_DELTA_BUFFER)
for backend in self:
from_date = backend[from_date_field]
if from_date:
from_date = fields.Datetime.from_string(from_date)
else:
from_date = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER)
self.env[model_name].with_delay(priority=5).import_batch(
backend,
# TODO which filters can we use in Amazon?
filters={'CreatedAfter': from_date.isoformat(),
'CreatedBefore': import_start_time.isoformat()}
)
# We add a buffer, but won't import them twice.
# NOTE this is 2x the offset from now()
next_time = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER)
next_time = fields.Datetime.to_string(next_time)
backend.write({from_date_field: next_time})

View File

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

View File

@@ -0,0 +1,64 @@
# © 2021 Hibou Corp.
from odoo import api, models, fields
from odoo.addons.queue_job.job import job, related_action
class AmazonBinding(models.AbstractModel):
""" Abstract Model for the Bindings.
All of the models used as bindings between Amazon and Odoo
(``amazon.sale.order``) should ``_inherit`` from it.
"""
_name = 'amazon.binding'
_inherit = 'external.binding'
_description = 'Amazon Binding (abstract)'
backend_id = fields.Many2one(
comodel_name='amazon.backend',
string='Amazon Backend',
required=True,
ondelete='restrict',
)
external_id = fields.Char(string='ID in Amazon')
_sql_constraints = [
('Amazon_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Amazon ID.'),
]
@job(default_channel='root.amazon')
@api.model
def import_batch(self, backend, filters=None):
""" Prepare the import of records modified on Amazon """
if filters is None:
filters = {}
with backend.work_on(self._name) as work:
importer = work.component(usage='batch.importer')
return importer.run(filters=filters)
@job(default_channel='root.amazon')
@related_action(action='related_action_unwrap_binding')
@api.model
def import_record(self, backend, external_id, force=False):
""" Import a Amazon record """
with backend.work_on(self._name) as work:
importer = work.component(usage='record.importer')
return importer.run(external_id, force=force)
# @job(default_channel='root.amazon')
# @related_action(action='related_action_unwrap_binding')
# @api.multi
# def export_record(self, fields=None):
# """ Export a record on Amazon """
# self.ensure_one()
# with self.backend_id.work_on(self._name) as work:
# exporter = work.component(usage='record.exporter')
# return exporter.run(self, fields)
#
# @job(default_channel='root.amazon')
# @related_action(action='related_action_amazon_link')
# def export_delete_record(self, backend, external_id):
# """ Delete a record on Amazon """
# with backend.work_on(self._name) as work:
# deleter = work.component(usage='record.exporter.deleter')
# return deleter.run(external_id)

View File

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

View File

@@ -0,0 +1,112 @@
# © 2021 Hibou Corp.
from io import BytesIO
from base64 import b64encode, b64decode
from json import loads, dumps
from odoo import models, fields, api
from odoo.addons.queue_job.job import job
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import RetryableJobError
FEED_RETRY_PATTERN = {
1: 1 * 60,
5: 2 * 60,
10: 10 * 60,
}
class AmazonFeed(models.Model):
_name = 'amazon.feed'
_description = 'Amazon Feed'
_order = 'id desc'
_rec_name = 'external_id'
backend_id = fields.Many2one('amazon.backend', string='Backend')
external_id = fields.Char(string='Amazon Feed ID')
type = fields.Selection([
('POST_ORDER_FULFILLMENT_DATA', 'Order Fulfillment Data'),
('POST_PRODUCT_DATA', 'Product Data'),
('POST_INVENTORY_AVAILABILITY_DATA', 'Product Inventory'),
('POST_PRODUCT_PRICING_DATA', 'Product Pricing'),
], string='Feed Type')
content_type = fields.Selection([
('text/xml', 'XML'),
], string='Content Type')
data = fields.Binary(string='Data', attachment=True)
response = fields.Binary(string='Response', attachment=True)
state = fields.Selection([
('new', 'New'),
('submitted', 'Submitted'),
('error_on_submit', 'Submission Error'),
], string='State', default='new')
amazon_state = fields.Selection([
('not_sent', ''),
('invalid', 'Invalid'),
('UNCONFIRMED', 'Request Pending'),
('SUBMITTED', 'Submitted'),
('IN_SAFETY_NET', 'Safety Net'),
('IN_QUEUE', 'Queued'),
('IN_PROGRESS', 'Processing'),
('DONE', 'Done'),
('CANCELLED', 'Cancelled'),
('AWAITING_ASYNCHRONOUS_REPLY', 'Awaiting Asynchronous Reply'),
], default='not_sent')
amazon_stock_picking_id = fields.Many2one('amazon.stock.picking',
string='Shipment',
ondelete='set null')
amazon_product_product_id = fields.Many2one('amazon.product.product',
string='Listing',
ondelete='set null')
@api.multi
@job(default_channel='root.amazon')
def submit_feed(self):
for feed in self:
api_instance = feed.backend_id.get_wrapped_api()
feeds_api = api_instance.feeds()
feed_io = BytesIO(b64decode(feed.data))
res1, res2 = feeds_api.submit_feed(feed.type, feed_io, content_type=feed.content_type)
feed_id = res2.payload.get('feedId')
if not feed_id:
if res2.payload:
feed.response = b64encode(dumps(res2.payload))
feed.state = 'error_on_submit'
else:
feed.state = 'submitted'
feed.external_id = feed_id
# First attempt will be delayed 1 minute
# Next 5 retries will be delayed 10 min each
# The rest will be delayed 30 min each
feed.with_delay(priority=100).check_feed()
@api.multi
@job(default_channel='root.amazon', retry_pattern=FEED_RETRY_PATTERN)
def check_feed(self):
for feed in self.filtered('external_id'):
api_instance = feed.backend_id.get_wrapped_api()
feeds_api = api_instance.feeds()
res3 = feeds_api.get_feed(feed.external_id)
status = res3.payload['processingStatus']
try:
feed.amazon_state = status
except ValueError:
feed.amazon_state = 'invalid'
if status in ('IN_QUEUE', 'IN_PROGRESS'):
raise RetryableJobError('Check back later on: ' + str(status), ignore_retry=True)
if status in ('DONE', ):
feed_document_id = res3.payload['resultFeedDocumentId']
if feed_document_id:
response = feeds_api.get_feed_result_document(feed_document_id)
try:
feed.response = b64encode(response)
except TypeError:
feed.response = b64encode(response.encode())
# queue a job to process the response
feed.with_delay(priority=10).process_feed_result()
@job(default_channel='root.amazon')
def process_feed_result(self):
for feed in self:
pass

View File

@@ -0,0 +1,138 @@
# © 2021 Hibou Corp.
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from base64 import b64decode, b64encode
from odoo import api
from odoo.tools import pycompat
PREFIX = 'amz_pii:'
PREFIX_LEN = len(PREFIX)
BLOCK_SIZE = 32
AMZ_PII_DECRYPT_STARTED = 1
AMZ_PII_DECRYPT_FAIL = -1
def make_amz_pii_decrypt(cipher):
def amz_pii_decrypt(value):
if value and isinstance(value, pycompat.string_types) and value.startswith(PREFIX):
try:
to_decrypt = b64decode(value[PREFIX_LEN:])
# remove whitespace and `ack`
return cipher.decrypt(to_decrypt).decode().strip().strip('\x06')
except ValueError:
pass
except:
raise
return value
return amz_pii_decrypt
def make_amz_pii_encrypt(cipher):
def amz_pii_encrypt(value):
if value and isinstance(value, pycompat.string_types) and not value.startswith(PREFIX):
try:
to_encrypt = value.encode()
to_encrypt = pad(to_encrypt, BLOCK_SIZE)
# must be aligned, so pad with spaces (to remove in decrypter)
# need_padded = len(to_encrypt) % BLOCK_SIZE
# if need_padded:
# to_encrypt = to_encrypt + (b' ' * (BLOCK_SIZE - need_padded))
to_encode = cipher.encrypt(to_encrypt)
return PREFIX + b64encode(to_encode).decode()
except ValueError:
pass
except:
raise
return value
return amz_pii_encrypt
def make_amz_pii_cipher(env):
# TODO we should try to get this from environment variable
# we should check 1. env variable 2. odoo config 3. database.secret
get_param = env['ir.config_parameter'].sudo().get_param
# we could get the 'database.uuid'
database_secret = get_param('database.secret')
if len(database_secret) < BLOCK_SIZE:
database_secret = database_secret.ljust(BLOCK_SIZE).encode()
else:
database_secret = database_secret[:BLOCK_SIZE].encode()
try:
cipher = AES.new(database_secret, AES.MODE_ECB)
except ValueError:
cipher = None
return cipher
# No PII field has been observed in this method
# def set(self, record, field, value):
# """ Set the value of ``field`` for ``record``. """
# amz_pii_decrypt = getattr(self, 'amz_pii_decrypt', None)
# c = record.env.context.get('amz_pii_decrypt') or True
# _logger.warn('set amz_pii_decrypt ' + str(c))
# if not amz_pii_decrypt and c:
# # setup function to do the decryption
# get_param = record.env['ir.config_parameter'].sudo().get_param
# prefix = 'amz_pii:'
# prefix_len = len(prefix)
# block_size = 32
# # we could get the 'database.uuid'
# database_secret = get_param('database.secret')
# if len(database_secret) < block_size:
# database_secret = database_secret.ljust(block_size).encode()
# else:
# database_secret = database_secret[:block_size].encode()
# try:
# cipher = AES.new(database_secret, AES.MODE_ECB)
# except ValueError:
# _logger.error('Cannot create AES256 decryption environment.')
# cipher = None
# self.amz_pii_decrypt = AMZ_PII_DECRYPT_FAIL
#
# if cipher:
# _logger.warn('created cipher')
# def amz_pii_decrypt(value):
# _logger.warn(' amz_pii_decrypt(' + str(value) + ')')
# if value and isinstance(value, pycompat.string_types) and value.startswith(prefix):
# try:
# to_decrypt = b64decode(value[prefix_len:])
# v = cipher.decrypt(to_decrypt).decode().strip()
# _logger.warn(' decrypted to ' + str(v))
# return v
# except:
# raise
# return value
# self.amz_pii_decrypt = amz_pii_decrypt
# elif amz_pii_decrypt and not isinstance(amz_pii_decrypt, int):
# value = amz_pii_decrypt(value)
# key = record.env.cache_key(field)
# self._data[key][field][record._ids[0]] = value
def update(self, records, field, values):
amz_pii_decrypt = getattr(self, 'amz_pii_decrypt', None)
amz_pii_decrypt_enabled = records.env.context.get('amz_pii_decrypt')
if not amz_pii_decrypt and amz_pii_decrypt_enabled:
self._start_amz_pii_decrypt(records.env)
elif amz_pii_decrypt_enabled and amz_pii_decrypt and not isinstance(amz_pii_decrypt, int):
for i, value in enumerate(values):
values[i] = amz_pii_decrypt(value)
key = records.env.cache_key(field)
self._data[key][field].update(pycompat.izip(records._ids, values))
def _start_amz_pii_decrypt(self, env):
self.amz_pii_decrypt = AMZ_PII_DECRYPT_STARTED
cipher = make_amz_pii_cipher(env)
if cipher:
self.amz_pii_decrypt = make_amz_pii_decrypt(cipher)
else:
self.amz_pii_decrypt = AMZ_PII_DECRYPT_FAIL
# api.Cache.set = set
api.Cache.update = update
api.Cache._start_amz_pii_decrypt = _start_amz_pii_decrypt

View File

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

View File

@@ -0,0 +1,415 @@
# © 2021 Hibou Corp.
import zlib
from datetime import date, datetime
from base64 import b64decode
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
amazon_sp_mfn_allowed_services = fields.Text(
string='Amazon SP MFN Allowed Methods',
help='Comma separated list. e.g. "FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB"')
class ProviderAmazonSP(models.Model):
_inherit = 'delivery.carrier'
delivery_type = fields.Selection(selection_add=[
# ('amazon_sp', 'Amazon Selling Partner'), # TODO buy shipping for regular orders?
('amazon_sp_mfn', 'Amazon SP Merchant Fulfillment')
])
# Fields when uploading shipping to Amazon
amazon_sp_carrier_code = fields.Char(string='Amazon Carrier Code',
help='Specific carrier code, will default to "Other".')
amazon_sp_carrier_name = fields.Char(string='Amazon Carrier Name',
help='Specific carrier name, will default to regular name.')
amazon_sp_shipping_method = fields.Char(string='Amazon Shipping Method',
help='Specific shipping method, will default to "Standard"')
# Fields when purchasing shipping from Amazon
amazon_sp_mfn_allowed_services = fields.Text(
string='Allowed Methods',
help='Comma separated list. e.g. "FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB"',
default='FEDEX_PTP_HOME_DELIVERY,FEDEX_PTP_SECOND_DAY,USPS_PTP_PRI_LFRB')
amazon_sp_mfn_label_formats = fields.Text(
string='Allowed Label Formats',
help='Comma separated list. e.g. "ZPL203,PNG"',
default='ZPL203,PNG')
def send_shipping(self, pickings):
pickings = pickings.with_context(amz_pii_decrypt=1)
self = self.with_context(amz_pii_decrypt=1)
return super(ProviderAmazonSP, self).send_shipping(pickings)
def is_amazon(self, order=None, picking=None):
# Override from `delivery_hibou` to be used in stamps etc....
if picking and picking.sale_id:
so = picking.sale_id
if so.amazon_bind_ids:
return True
if order and order.amazon_bind_ids:
return True
return super().is_amazon(order=order, picking=picking)
def _amazon_sp_mfn_get_order_details(self, order):
company = self.get_shipper_company(order=order)
wh_partner = self.get_shipper_warehouse(order=order)
if not order.amazon_bind_ids:
raise ValidationError('Amazon shipping is not available for this order.')
amazon_order_id = order.amazon_bind_ids[0].external_id
from_ = dict(
Name=company.name,
AddressLine1=wh_partner.street,
AddressLine2=wh_partner.street2 or '',
City=wh_partner.city,
StateOrProvinceCode=wh_partner.state_id.code,
PostalCode=wh_partner.zip,
CountryCode=wh_partner.country_id.code,
Email=company.email or '',
Phone=company.phone or '',
)
return amazon_order_id, from_
def _amazon_sp_mfn_get_items_for_order(self, order):
items = order.order_line.filtered(lambda l: l.amazon_bind_ids)
return items.mapped(lambda l: (l.amazon_bind_ids[0].external_id, str(int(l.product_qty))))
def _amazon_sp_mfn_get_items_for_package(self, package, order):
items = []
if not package.quant_ids:
for move_line in package.current_picking_move_line_ids:
line = order.order_line.filtered(lambda l: l.product_id.id == move_line.product_id.id and l.amazon_bind_ids)
if line:
items.append((line[0].amazon_bind_ids[0].external_id, int(move_line.qty_done), {
'Unit': 'g',
'Value': line.product_id.weight * move_line.qty_done * 1000,
}, line.name))
else:
for quant in package.quant_ids:
line = order.order_line.filtered(lambda l: l.product_id.id == quant.product_id.id and l.amazon_bind_ids)
if line:
items.append((line[0].amazon_bind_ids[0].external_id, int(quant.quantity), {
'Unit': 'g',
'Value': line.product_id.weight * quant.quantity * 1000,
}, line.name))
return items
def _amazon_sp_mfn_convert_weight(self, weight):
return int(weight * 1000), 'g'
def _amazon_sp_mfn_pick_service(self, api_services, package=None):
allowed_services = self.amazon_sp_mfn_allowed_services.split(',')
if package and package.packaging_id.amazon_sp_mfn_allowed_services:
allowed_services = package.packaging_id.amazon_sp_mfn_allowed_services.split(',')
allowed_label_formats = self.amazon_sp_mfn_label_formats.split(',')
services = []
api_service_list = api_services['ShippingServiceList']
if not isinstance(api_service_list, list):
api_service_list = [api_service_list]
for s in api_service_list:
if s['ShippingServiceId'] in allowed_services:
s_available_formats = s['AvailableLabelFormats']
for l in allowed_label_formats:
if l in s_available_formats:
services.append({
'service_id': s['ShippingServiceId'],
'amount': float(s['Rate']['Amount']),
'label_format': l
})
break
if services:
return sorted(services, key=lambda s: s['amount'])[0]
error = 'Cannot find applicable service. API Services: ' + \
','.join([s['ShippingServiceId'] for s in api_services['ShippingServiceList']]) + \
' Allowed Services: ' + self.amazon_sp_mfn_allowed_services
raise ValidationError(error)
def amazon_sp_mfn_send_shipping(self, pickings):
res = []
date_planned = datetime.now().replace(microsecond=0).isoformat()
for picking in pickings:
shipments = []
picking_packages = picking.package_ids
package_carriers = picking_packages.mapped('carrier_id')
if package_carriers:
# only ship ours
picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref)
if not picking_packages:
continue
order = picking.sale_id.sudo() # for having access to amazon bindings and backend
# API comes from the binding backend
if order.amazon_bind_ids:
amazon_order = order.amazon_bind_ids[0]
api_wrapped = amazon_order.backend_id.get_wrapped_api()
# must_arrive_by_date not used, and `amazon_order.requested_date` can be False
# so if it is to be used, we must decide what to do if there is no date.
# must_arrive_by_date = fields.Datetime.from_string(amazon_order.requested_date).isoformat()
api = api_wrapped.merchant_fulfillment()
if not api:
raise UserError('%s cannot be shipped by Amazon SP without a Sale Order or backend to use.' % picking)
amazon_order_id, from_ = self._amazon_sp_mfn_get_order_details(order)
for package in picking_packages:
dimensions = {
'Length': package.packaging_id.length or 0.1,
'Width': package.packaging_id.width or 0.1,
'Height': package.packaging_id.height or 0.1,
'Unit': 'inches',
}
weight, weight_unit = self._amazon_sp_mfn_convert_weight(package.shipping_weight)
items = self._amazon_sp_mfn_get_items_for_package(package, order)
# Declared value
inventory_value = self.get_inventory_value(picking=picking, package=package)
sig_req = self.get_signature_required(picking=picking, package=package)
ShipmentRequestDetails = {
'AmazonOrderId': amazon_order_id,
'ShipFromAddress': from_,
'Weight': {'Unit': weight_unit, 'Value': weight},
'SellerOrderId': order.name,
# The format of these dates cannot be determined, attempts:
# 2021-04-27 08:00:00
# 2021-04-27T08:00:00
# 2021-04-27T08:00:00Z
# 2021-04-27T08:00:00+00:00
# 'ShipDate': date_planned,
# 'MustArriveByDate': must_arrive_by_date,
'ShippingServiceOptions': {
'DeliveryExperience': 'DeliveryConfirmationWithSignature' if sig_req else 'NoTracking',
# CarrierWillPickUp is required
'CarrierWillPickUp': False, # Note: Scheduled carrier pickup is available only using Dynamex (US), DPD (UK), and Royal Mail (UK).
'DeclaredValue': {
'Amount': inventory_value,
'CurrencyCode': 'USD'
},
# Conflicts at time of shipping for the above
# 'CarrierWillPickUpOption': 'NoPreference',
'LabelFormat': 'ZPL203'
},
'ItemList': [{
'OrderItemId': i[0],
'Quantity': i[1],
'ItemWeight': i[2],
'ItemDescription': i[3],
} for i in items],
'PackageDimensions': dimensions,
}
try:
# api_services = api.get_eligible_shipment_services(ShipmentRequestDetails, ShippingOfferingFilter={
# 'IncludePackingSlipWithLabel': False,
# 'IncludeComplexShippingOptions': False,
# 'CarrierWillPickUp': 'CarrierWillPickUp',
# 'DeliveryExperience': 'NoTracking',
# })
api_services = api.get_eligible_shipment_services(ShipmentRequestDetails)
except api_wrapped.SellingApiForbiddenException:
raise UserError('Your Amazon SP API access does not include MerchantFulfillment')
except api_wrapped.SellingApiException as e:
raise UserError('API Exception: ' + str(e.message))
api_services = api_services.payload
service = self._amazon_sp_mfn_pick_service(api_services, package=package)
try:
shipment = api.create_shipment(ShipmentRequestDetails, service['service_id']).payload
except api_wrapped.SellingApiForbiddenException:
raise UserError('Your Amazon SP API access does not include MerchantFulfillment')
except api_wrapped.SellingApiException as e:
raise UserError('API Exception: ' + str(e.message))
shipments.append((shipment, service))
carrier_price = 0.0
tracking_numbers =[]
for shipment, service in shipments:
tracking_number = shipment['TrackingId']
carrier_name = shipment['ShippingService']['CarrierName']
label_data = shipment['Label']['FileContents']['Contents']
# So far, this is b64encoded and gzipped
try:
label_decoded = b64decode(label_data)
try:
label_decoded = zlib.decompress(label_decoded)
except:
label_decoded = zlib.decompress(label_decoded, zlib.MAX_WBITS | 16)
label_data = label_decoded
except:
# Oh well...
pass
body = 'Shipment created into Amazon MFN<br/> <b>Tracking Number : <br/>' + tracking_number + '</b>'
picking.message_post(body=body, attachments=[('Label%s-%s.%s' % (carrier_name, tracking_number, service['label_format']), label_data)])
carrier_price += float(shipment['ShippingService']['Rate']['Amount'])
tracking_numbers.append(tracking_number)
shipping_data = {'exact_price': carrier_price, 'tracking_number': ','.join(tracking_numbers)}
res = res + [shipping_data]
return res
def amazon_sp_mfn_rate_shipment_multi(self, order=None, picking=None, packages=None):
if not packages:
return self._amazon_sp_mfn_rate_shipment_multi_package(order=order, picking=picking)
else:
rates = []
for package in packages:
rates += self._amazon_sp_mfn_rate_shipment_multi_package(order=order, picking=picking, package=package)
return rates
def _amazon_sp_mfn_rate_shipment_multi_package(self, order=None, picking=None, package=None):
res = []
self.ensure_one()
date_planned = fields.Datetime.now()
if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned')
if order or not picking:
raise UserError('Amazon SP MFN is intended to be used on imported orders.')
if package:
packages = package
else:
packages = picking.package_ids
if not packages:
raise UserError('Amazon SP MFN can only be used with packed items.')
# to use current inventory in package
packages = packages.with_context(picking_id=picking.id)
order = picking.sale_id.sudo()
api = None
if order.amazon_bind_ids:
amazon_order = order.amazon_bind_ids[0]
api_wrapped = amazon_order.backend_id.get_wrapped_api()
# must_arrive_by_date not used, and `amazon_order.requested_date` can be False
# so if it is to be used, we must decide what to do if there is no date.
# must_arrive_by_date = fields.Datetime.from_string(amazon_order.requested_date).isoformat()
api = api_wrapped.merchant_fulfillment()
if not api:
raise UserError('%s cannot be shipped by Amazon SP without a Sale Order or backend to use.' % picking)
amazon_order_id, from_ = self._amazon_sp_mfn_get_order_details(order)
for package in packages:
dimensions = {
'Length': package.packaging_id.length or 0.1,
'Width': package.packaging_id.width or 0.1,
'Height': package.packaging_id.height or 0.1,
'Unit': 'inches',
}
weight, weight_unit = self._amazon_sp_mfn_convert_weight(package.shipping_weight)
items = self._amazon_sp_mfn_get_items_for_package(package, order)
# Declared value
inventory_value = self.get_insurance_value(picking=picking, package=package)
sig_req = self.get_signature_required(picking=picking, package=packages)
ShipmentRequestDetails = {
'AmazonOrderId': amazon_order_id,
'ShipFromAddress': from_,
'Weight': {'Unit': weight_unit, 'Value': weight},
'SellerOrderId': order.name,
# The format of these dates cannot be determined, attempts:
# 2021-04-27 08:00:00
# 2021-04-27T08:00:00
# 2021-04-27T08:00:00Z
# 2021-04-27T08:00:00+00:00
# 'ShipDate': date_planned,
# 'MustArriveByDate': must_arrive_by_date,
'ShippingServiceOptions': {
'DeliveryExperience': 'DeliveryConfirmationWithSignature' if sig_req else 'NoTracking',
# CarrierWillPickUp is required
'CarrierWillPickUp': False,
# Note: Scheduled carrier pickup is available only using Dynamex (US), DPD (UK), and Royal Mail (UK).
'DeclaredValue': {
'Amount': inventory_value,
'CurrencyCode': 'USD'
},
# Conflicts at time of shipping for the above
# 'CarrierWillPickUpOption': 'NoPreference',
'LabelFormat': 'ZPL203'
},
'ItemList': [{
'OrderItemId': i[0],
'Quantity': i[1],
'ItemWeight': i[2],
'ItemDescription': i[3],
} for i in items],
'PackageDimensions': dimensions,
}
try:
# api_services = api.get_eligible_shipment_services(ShipmentRequestDetails, ShippingOfferingFilter={
# 'IncludePackingSlipWithLabel': False,
# 'IncludeComplexShippingOptions': False,
# 'CarrierWillPickUp': 'CarrierWillPickUp',
# 'DeliveryExperience': 'NoTracking',
# })
api_services = api.get_eligible_shipment_services(ShipmentRequestDetails)
except api_wrapped.SellingApiForbiddenException:
raise UserError('Your Amazon SP API access does not include MerchantFulfillment')
except api_wrapped.SellingApiException as e:
raise UserError('API Exception: ' + str(e.message))
api_services = api_services.payload
# project into distinct carrier
allowed_services = self.amazon_sp_mfn_allowed_services.split(',')
if package and package.packaging_id.amazon_sp_mfn_allowed_services:
allowed_services = package.packaging_id.amazon_sp_mfn_allowed_services.split(',')
api_service_list = api_services['ShippingServiceList']
if not isinstance(api_service_list, list):
api_service_list = [api_service_list]
for s in filter(lambda s: s['ShippingServiceId'] in allowed_services, api_service_list):
_logger.warning('ShippingService: ' + str(s))
service_code = s['ShippingServiceId']
carrier = self.amazon_sp_mfn_find_delivery_carrier_for_service(service_code)
if carrier:
res.append({
'carrier': carrier,
'package': package or self.env['stock.quant.package'].browse(),
'success': True,
'price': s['Rate']['Amount'],
'error_message': False,
'warning_message': False,
# 'transit_days': transit_days,
'date_delivered': s['LatestEstimatedDeliveryDate'] if s['LatestEstimatedDeliveryDate'] else s['EarliestEstimatedDeliveryDate'],
'date_planned': date_planned,
'service_code': service_code,
})
if not res:
res.append({
'success': False,
'price': 0.0,
'error_message': 'No valid rates returned from AmazonSP-MFN',
'warning_message': False
})
return res
def amazon_sp_mfn_find_delivery_carrier_for_service(self, service_code):
if self.amazon_sp_mfn_allowed_services == service_code:
return self
carrier = self.search([('amazon_sp_mfn_allowed_services', '=', service_code),
('delivery_type', '=', 'amazon_sp_mfn')
], limit=1)
return carrier

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

@@ -0,0 +1,293 @@
# © 2021 Hibou Corp.
from base64 import b64encode
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.addons.component.core import Component
PRODUCT_SKU_WITH_WAREHOUSE = '%s-%s'
class AmazonProductProduct(models.Model):
_name = 'amazon.product.product'
_inherit = 'amazon.binding'
_inherits = {'product.product': 'odoo_id'}
_description = 'Amazon Product Listing'
_rec_name = 'external_id'
odoo_id = fields.Many2one('product.product',
string='Product',
required=True,
ondelete='cascade')
asin = fields.Char(string='ASIN')
state = fields.Selection([
('draft', 'Draft'),
('sent', 'Submitted'),
], default='draft')
warehouse_id = fields.Many2one('stock.warehouse',
string='Warehouse',
ondelete='set null')
backend_warehouse_ids = fields.Many2many(related='backend_id.warehouse_ids')
backend_fba_warehouse_ids = fields.Many2many(related='backend_id.fba_warehouse_ids')
date_product_sent = fields.Datetime(string='Last Product Update')
date_price_sent = fields.Datetime(string='Last Price Update')
date_inventory_sent = fields.Datetime(string='Last Inventory Update')
buffer_qty = fields.Integer(string='Buffer Quantity',
help='Stock to hold back from Amazon for listings. (-1 means use the backend default)',
default=-1)
@api.onchange('odoo_id', 'warehouse_id', 'default_code')
def _onchange_suggest_external_id(self):
with_code_and_warehouse = self.filtered(lambda p: p.default_code and p.warehouse_id)
with_code = (self - with_code_and_warehouse).filtered('default_code')
other = (self - with_code_and_warehouse - with_code)
for product in with_code_and_warehouse:
product.external_id = PRODUCT_SKU_WITH_WAREHOUSE % (product.default_code, product.warehouse_id.code)
for product in with_code:
product.external_id = product.default_code
for product in other:
product.external_id = product.external_id
@api.multi
def button_submit_product(self):
backends = self.mapped('backend_id')
for backend in backends:
products = self.filtered(lambda p: p.backend_id == backend)
products._submit_product()
return 1
@api.multi
def button_update_inventory(self):
backends = self.mapped('backend_id')
for backend in backends:
products = self.filtered(lambda p: p.backend_id == backend)
products._update_inventory()
return 1
@api.multi
def button_update_price(self):
backends = self.mapped('backend_id')
for backend in backends:
products = self.filtered(lambda p: p.backend_id == backend)
products._update_price()
return 1
def _submit_product(self):
# this should be called on a product set that has the same backend
backend = self[0].backend_id
with backend.work_on(self._name) as work:
exporter = work.component(usage='amazon.product.product.exporter')
exporter.run(self)
self.write({'date_product_sent': fields.Datetime.now(), 'state': 'sent'})
def _update_inventory(self):
# this should be called on a product set that has the same backend
backend = self[0].backend_id
with backend.work_on(self._name) as work:
exporter = work.component(usage='amazon.product.product.exporter')
exporter.run_inventory(self)
self.write({'date_inventory_sent': fields.Datetime.now()})
def _update_price(self):
# this should be called on a product set that has the same backend
backend = self[0].backend_id
with backend.work_on(self._name) as work:
exporter = work.component(usage='amazon.product.product.exporter')
exporter.run_price(self)
self.write({'date_price_sent': fields.Datetime.now()})
def _update_for_backend_products(self, backend):
return self.search([
('backend_id', '=', backend.id),
('state', '=', 'sent'),
])
def update_inventory(self, backend):
products = self._update_for_backend_products(backend)
if products:
products._update_inventory()
def update_price(self, backend):
products = self._update_for_backend_products(backend)
if products:
products._update_price()
class ProductProduct(models.Model):
_inherit = 'product.product'
amazon_bind_ids = fields.One2many('amazon.product.product', 'odoo_id', string='Amazon Listings')
class ProductAdapter(Component):
_name = 'amazon.product.product.adapter'
_inherit = 'amazon.adapter'
_apply_on = 'amazon.product.product'
def _api(self):
return self.api_instance.feeds()
def _submit_feed(self, bindings, type, content_type, data):
feed_values = {
'backend_id': bindings[0].backend_id.id,
'type': type,
'content_type': content_type,
'data': b64encode(data),
}
if len(bindings) == 1:
feed_values['amazon_product_product_id'] = bindings.id
feed = self.env['amazon.feed'].create(feed_values)
feed.with_delay(priority=19).submit_feed() # slightly higher than regular submit_feed calls
return feed
def create(self, bindings):
feed_root, _message = self._product_data_feed(bindings)
feed_data = self._feed_string(feed_root)
self._submit_feed(bindings, 'POST_PRODUCT_DATA', 'text/xml', feed_data)
def create_inventory(self, bindings):
feed_root, _message = self._product_inventory_feed(bindings)
feed_data = self._feed_string(feed_root)
self._submit_feed(bindings, 'POST_INVENTORY_AVAILABILITY_DATA', 'text/xml', feed_data)
def create_price(self, bindings):
feed_root, _message = self._product_price_feed(bindings)
feed_data = self._feed_string(feed_root)
self._submit_feed(bindings, 'POST_PRODUCT_PRICING_DATA', 'text/xml', feed_data)
def _process_product_data(self, bindings):
res = []
for amazon_product in bindings:
# why iterate? because we probably need more data eventually...
if not amazon_product.external_id:
raise UserError('Amazon Product Listing (%s) must have an Amazon SKU filled.' % (amazon_product.id, ))
res.append({
'SKU': amazon_product.external_id,
})
return res
def _product_data_feed(self, bindings):
product_datas = self._process_product_data(bindings)
root, message = self._feed('Product', bindings[0].backend_id)
root.remove(message)
self.ElementTree.SubElement(root, 'PurgeAndReplace').text = 'false'
for i, product_data in enumerate(product_datas, 1):
message = self.ElementTree.SubElement(root, 'Message')
self.ElementTree.SubElement(message, 'MessageID').text = str(i)
# ElementTree.SubElement(message, 'OperationType').text = 'Update'
self.ElementTree.SubElement(message, 'OperationType').text = 'PartialUpdate'
product = self.ElementTree.SubElement(message, 'Product')
self.ElementTree.SubElement(product, 'SKU').text = product_data['SKU']
# standard_product_id = ElementTree.SubElement(product, 'StandardProductID')
# ElementTree.SubElement(standard_product_id, 'Type').text = product_data['StandardProductID.Type']
# ElementTree.SubElement(standard_product_id, 'Value').text = product_data['StandardProductID.Value']
# description_data = ElementTree.SubElement(product, 'DescriptionData')
# ElementTree.SubElement(description_data, 'Title').text = product_data['Title']
# ElementTree.SubElement(description_data, 'Brand').text = product_data['Brand']
# ElementTree.SubElement(description_data, 'Description').text = product_data['Description']
# for bullet in product_data['BulletPoints']:
# ElementTree.SubElement(description_data, 'BulletPoint').text = bullet
# ElementTree.SubElement(description_data, 'Manufacturer').text = product_data['Manufacturer']
# ElementTree.SubElement(description_data, 'ItemType').text = product_data['ItemType']
return root, message
def _process_product_inventory(self, bindings):
def _qty(binding, buffer_qty):
# qty available is all up inventory, less outgoing inventory gives qty to send
qty = binding.qty_available - binding.outgoing_qty
if binding.buffer_qty >= 0.0:
return max((0.0, qty - binding.buffer_qty))
return max((0.0, qty - buffer_qty))
res = []
backend = bindings[0].backend_id
backend_warehouses = backend.warehouse_ids
backend_fba_warehouses = backend.fba_warehouse_ids
warehouses = bindings.mapped('warehouse_id')
for warehouse in warehouses:
wh_bindings = bindings.filtered(lambda p: p.warehouse_id == warehouse).with_context(warehouse=warehouse.id)
buffer_qty = backend.fba_buffer_qty if warehouse in backend_fba_warehouses else backend.buffer_qty
for binding in wh_bindings:
res.append((binding.external_id, _qty(binding, buffer_qty)))
buffer_qty = backend.buffer_qty
for binding in bindings.filtered(lambda p: not p.warehouse_id).with_context(warehouse=backend_warehouses.ids):
res.append((binding.external_id, _qty(binding, buffer_qty)))
return res
def _product_inventory_feed(self, bindings):
product_datas = self._process_product_inventory(bindings)
root, message = self._feed('Inventory', bindings[0].backend_id)
root.remove(message)
for i, product_data in enumerate(product_datas, 1):
sku, qty = product_data
message = self.ElementTree.SubElement(root, 'Message')
self.ElementTree.SubElement(message, 'MessageID').text = str(i)
# ElementTree.SubElement(message, 'OperationType').text = 'Update'
self.ElementTree.SubElement(message, 'OperationType').text = 'Update'
inventory = self.ElementTree.SubElement(message, 'Inventory')
self.ElementTree.SubElement(inventory, 'SKU').text = sku
self.ElementTree.SubElement(inventory, 'Quantity').text = str(int(qty))
return root, message
def _process_product_price(self, bindings):
def _process_product_price_internal(env, binding, pricelist, res):
price = binding.lst_price
sale_price = None
date_start = None
date_end = None
if pricelist:
rule = None
sale_price, rule_id = pricelist.get_product_price_rule(binding.odoo_id, 1.0, None)
if rule_id:
rule = env['product.pricelist.item'].browse(rule_id).exists()
if rule and (rule.date_start or rule.date_end):
date_start = rule.date_start
date_end = rule.date_end
res.append((binding.external_id, price, sale_price, date_start, date_end))
res = []
backend = bindings[0].backend_id
pricelist = backend.pricelist_id
fba_pricelist = backend.fba_pricelist_id
backend_fba_warehouses = backend.fba_warehouse_ids
fba_bindings = bindings.filtered(lambda b: b.warehouse_id and b.warehouse_id in backend_fba_warehouses)
for binding in fba_bindings:
_process_product_price_internal(self.env, binding, fba_pricelist, res)
for binding in (bindings - fba_bindings):
_process_product_price_internal(self.env, binding, pricelist, res)
return res
def _product_price_feed(self, bindings):
backend = bindings[0].backend_id
product_datas = self._process_product_price(bindings)
root, message = self._feed('Price', backend)
root.remove(message)
now = fields.Datetime.now()
tomorrow = str(fields.Datetime.from_string(now) + timedelta(days=1))
for i, product_data in enumerate(product_datas, 1):
sku, _price, _sale_price, date_start, date_end = product_data
message = self.ElementTree.SubElement(root, 'Message')
self.ElementTree.SubElement(message, 'MessageID').text = str(i)
# ElementTree.SubElement(message, 'OperationType').text = 'Update'
# self.ElementTree.SubElement(message, 'OperationType').text = 'Update'
price = self.ElementTree.SubElement(message, 'Price')
self.ElementTree.SubElement(price, 'SKU').text = sku
standard_price = self.ElementTree.SubElement(price, 'StandardPrice')
standard_price.text = '%0.2f' % (_price, )
standard_price.attrib['currency'] = 'USD' # TODO gather currency
if _sale_price and abs(_price - _sale_price) > 0.01:
sale = self.ElementTree.SubElement(price, 'Sale')
if not date_start:
date_start = now
self.ElementTree.SubElement(sale, 'StartDate').text = fields.Datetime.from_string(date_start).isoformat()
if not date_end:
date_end = tomorrow
self.ElementTree.SubElement(sale, 'EndDate').text = fields.Datetime.from_string(date_end).isoformat()
sale_price = self.ElementTree.SubElement(sale, 'SalePrice')
sale_price.text = '%0.2f' % (_sale_price, )
sale_price.attrib['currency'] = 'USD' # TODO gather currency
return root, message

View File

@@ -0,0 +1,22 @@
# © 2021 Hibou Corp.
from odoo.addons.component.core import Component
class AmazonProductProductExporter(Component):
_name = 'amazon.product.product.exporter'
_inherit = 'amazon.exporter'
_apply_on = ['amazon.product.product']
_usage = 'amazon.product.product.exporter'
def run(self, bindings):
# TODO should exporter prepare feed data?
self.backend_adapter.create(bindings)
def run_inventory(self, bindings):
# TODO should exporter prepare feed data?
self.backend_adapter.create_inventory(bindings)
def run_price(self, bindings):
# TODO should exporter prepare feed data?
self.backend_adapter.create_price(bindings)

View File

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

View File

@@ -0,0 +1,283 @@
# © 2021 Hibou Corp.
import logging
from time import sleep
import odoo.addons.decimal_precision as dp
from odoo import models, fields, api
from odoo.exceptions import ValidationError
from odoo.addons.queue_job.job import job, related_action
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import RetryableJobError
from ...components.api.amazon import RequestRateError
SO_REQUEST_SLEEP_SECONDS = 30
_logger = logging.getLogger(__name__)
SO_IMPORT_RETRY_PATTERN = {
1: 10 * 60,
2: 30 * 60,
}
class AmazonSaleOrder(models.Model):
_name = 'amazon.sale.order'
_inherit = 'amazon.binding'
_description = 'Amazon Sale Order'
_inherits = {'sale.order': 'odoo_id'}
_order = 'date_order desc, id desc'
odoo_id = fields.Many2one(comodel_name='sale.order',
string='Sale Order',
required=True,
ondelete='cascade')
amazon_order_line_ids = fields.One2many(
comodel_name='amazon.sale.order.line',
inverse_name='amazon_order_id',
string='Amazon Order Lines'
)
total_amount = fields.Float(
string='Total amount',
digits=dp.get_precision('Account')
)
# total_amount_tax = fields.Float(
# string='Total amount w. tax',
# digits=dp.get_precision('Account')
# )
# Ideally would be a selection, but there are/will be more codes we might
# not be able to predict like 'Second US D2D Dom'
# Standard, Expedited, Second US D2D Dom,
fulfillment_channel = fields.Selection([
('AFN', 'Amazon'),
('MFN', 'Merchant'),
], string='Fulfillment Channel')
ship_service_level = fields.Char(string='Shipping Service Level')
ship_service_level_category = fields.Char(string='Shipping Service Level Category')
marketplace = fields.Char(string='Marketplace')
order_type = fields.Char(string='Order Type')
is_business_order = fields.Boolean(string='Is Business Order')
is_prime = fields.Boolean(string='Is Prime')
is_global_express_enabled = fields.Boolean(string='Is Global Express')
is_premium = fields.Boolean(string='Is Premium')
is_sold_by_ab = fields.Boolean(string='Is Sold By AB')
is_amazon_order = fields.Boolean('Is Amazon Order', compute='_compute_is_amazon_order')
def is_fba(self):
return self.fulfillment_channel == 'AFN'
def _compute_is_amazon_order(self):
for so in self:
so.is_amazon_order = True
@job(default_channel='root.amazon')
@api.model
def import_batch(self, backend, filters=None):
""" Prepare the import of Sales Orders from Amazon """
return super(AmazonSaleOrder, self).import_batch(backend, filters=filters)
@job(default_channel='root.amazon', retry_pattern=SO_IMPORT_RETRY_PATTERN)
@related_action(action='related_action_unwrap_binding')
@api.model
def import_record(self, backend, external_id, force=False):
return super().import_record(backend, external_id, force=force)
@api.multi
def action_confirm(self):
res = self.odoo_id.action_confirm()
if res and hasattr(res, '__getitem__'): # Button returned an action: we need to set active_id to the amazon sale order
res.update({
'context': {
'active_id': self.ids[0],
'active_ids': self.ids
}
})
return res
@api.multi
def action_cancel(self):
return self.odoo_id.action_cancel()
@api.multi
def action_draft(self):
return self.odoo_id.action_draft()
@api.multi
def action_view_delivery(self):
res = self.odoo_id.action_view_delivery()
res.update({
'context': {
'active_id': self.ids[0],
'active_ids': self.ids
}
})
return res
# @job(default_channel='root.amazon')
# @api.model
# def acknowledge_order(self, backend, external_id):
# with backend.work_on(self._name) as work:
# adapter = work.component(usage='backend.adapter')
# return adapter.acknowledge_order(external_id)
class SaleOrder(models.Model):
_inherit = 'sale.order'
amazon_bind_ids = fields.One2many(
comodel_name='amazon.sale.order',
inverse_name='odoo_id',
string='Amazon Bindings',
)
amazon_bind_id = fields.Many2one('amazon.sale.order', 'Amazon Binding', compute='_compute_amazon_bind_id')
is_amazon_order = fields.Boolean('Is Amazon Order', compute='_compute_is_amazon_order')
total_amount = fields.Float(
string='Total amount',
digits=dp.get_precision('Account'),
related='amazon_bind_id.total_amount'
)
fulfillment_channel = fields.Selection(related='amazon_bind_id.fulfillment_channel')
ship_service_level = fields.Char(string='Shipping Service Level', related='amazon_bind_id.ship_service_level')
ship_service_level_category = fields.Char(string='Shipping Service Level Category', related='amazon_bind_id.ship_service_level_category')
marketplace = fields.Char(string='Marketplace', related='amazon_bind_id.marketplace')
order_type = fields.Char(string='Order Type', related='amazon_bind_id.order_type')
is_business_order = fields.Boolean(string='Is Business Order', related='amazon_bind_id.is_business_order')
is_prime = fields.Boolean(string='Is Prime', related='amazon_bind_id.is_prime')
is_global_express_enabled = fields.Boolean(string='Is Global Express', related='amazon_bind_id.is_global_express_enabled')
is_premium = fields.Boolean(string='Is Premium', related='amazon_bind_id.is_premium')
is_sold_by_ab = fields.Boolean(string='Is Sold By AB', related='amazon_bind_id.is_sold_by_ab')
@api.depends('amazon_bind_ids')
def _compute_amazon_bind_id(self):
for so in self:
so.amazon_bind_id = so.amazon_bind_ids[:1].id
def _compute_is_amazon_order(self):
for so in self:
so.is_amazon_order = False
# @api.multi
# def action_confirm(self):
# res = super(SaleOrder, self).action_confirm()
# self.amazon_bind_ids.action_confirm()
# return res
class AmazonSaleOrderLine(models.Model):
_name = 'amazon.sale.order.line'
_inherit = 'amazon.binding'
_description = 'Amazon Sale Order Line'
_inherits = {'sale.order.line': 'odoo_id'}
amazon_order_id = fields.Many2one(comodel_name='amazon.sale.order',
string='Amazon Sale Order',
required=True,
ondelete='cascade',
index=True)
odoo_id = fields.Many2one(comodel_name='sale.order.line',
string='Sale Order Line',
required=True,
ondelete='cascade')
backend_id = fields.Many2one(
related='amazon_order_id.backend_id',
string='Amazon Backend',
readonly=True,
store=True,
# override 'Amazon.binding', can't be INSERTed if True:
required=False,
)
@api.model
def create(self, vals):
amazon_order_id = vals['amazon_order_id']
binding = self.env['amazon.sale.order'].browse(amazon_order_id)
vals['order_id'] = binding.odoo_id.id
binding = super(AmazonSaleOrderLine, self).create(vals)
return binding
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
amazon_bind_ids = fields.One2many(
comodel_name='amazon.sale.order.line',
inverse_name='odoo_id',
string="Amazon Bindings",
)
class SaleOrderAdapter(Component):
_name = 'amazon.sale.order.adapter'
_inherit = 'amazon.adapter'
_apply_on = 'amazon.sale.order'
def _api(self):
return self.api_instance.orders()
def search(self, filters):
try:
res = self._api().get_orders(**filters)
if res.errors:
_logger.error('Error in Order search: ' + str(res.errors))
except self.api_instance.SellingApiException as e:
raise ValidationError('SellingApiException: ' + str(e.message))
return res.payload
# Note that order_items_buyer_info has always returned only the order items numbers.
def read(self, order_id,
include_order_items=False,
include_order_address=False,
include_order_buyer_info=False,
include_order_items_buyer_info=False,
):
try:
api = self._api()
order_res = api.get_order(order_id)
if order_res.errors:
_logger.error('Error in Order read: ' + str(order_res.errors))
res = order_res.payload
if include_order_items:
order_items_res = api.get_order_items(order_id)
if order_items_res.errors:
_logger.error('Error in Order Items read: ' + str(order_items_res.errors))
# Note that this isn't the same as the ones below to simplify later code
# by being able to get an iterable at the top level for mapping purposes
res['OrderItems'] = order_items_res.payload.get('OrderItems', [])
if include_order_address:
order_address_res = api.get_order_address(order_id)
if order_address_res.errors:
_logger.error('Error in Order Address read: ' + str(order_address_res.errors))
res['OrderAddress'] = order_address_res.payload
if include_order_buyer_info:
order_buyer_info_res = api.get_order_buyer_info(order_id)
if order_buyer_info_res.errors:
_logger.error('Error in Order Buyer Info read: ' + str(order_buyer_info_res.errors))
res['OrderBuyerInfo'] = order_buyer_info_res.payload
if include_order_items_buyer_info:
order_items_buyer_info_res = api.get_order_items_buyer_info(order_id)
if order_items_buyer_info_res.errors:
_logger.error('Error in Order Items Buyer Info read: ' + str(order_items_buyer_info_res.errors))
res['OrderItemsBuyerInfo'] = order_items_buyer_info_res.payload
except self.api_instance.SellingApiException as e:
if e.message.find('You exceeded your quota for the requested resource.') >= 0:
self._sleep_rety()
raise ValidationError('SellingApiException: ' + str(e.message))
except RequestRateError as e:
self._sleep_rety()
return res
def _sleep_rety(self):
# we CANNOT control when the next job of this type will be scheduled (by def, the queue may even be running
# the same jobs at the same time)
# we CAN control how long we wait before we free up the current queue worker though...
# Note that we can make it so that this job doesn't re-queue right away via RetryableJobError mechanisms,
# but that is no better than the more general case of us just sleeping this long now.
_logger.warn(' !!!!!!!!!!!!! _sleep_rety !!!!!!!!!!!!')
sleep(SO_REQUEST_SLEEP_SECONDS)
raise RetryableJobError('We are being throttled and will retry later.')

View File

@@ -0,0 +1,417 @@
# © 2021 Hibou Corp.
import logging
from json import dumps
from odoo import _
from odoo.addons.component.core import Component
from odoo.addons.connector.components.mapper import mapping
from odoo.addons.queue_job.exception import RetryableJobError, NothingToDoJob
from ...components.mapper import normalize_datetime
from ..api import make_amz_pii_cipher, make_amz_pii_encrypt
_logger = logging.getLogger(__name__)
class SaleOrderBatchImporter(Component):
_name = 'amazon.sale.order.batch.importer'
_inherit = 'amazon.delayed.batch.importer'
_apply_on = 'amazon.sale.order'
def _import_record(self, external_id, job_options=None, **kwargs):
if not job_options:
job_options = {
'max_retries': 0,
'priority': 30,
}
return super(SaleOrderBatchImporter, self)._import_record(
external_id, job_options=job_options)
def run(self, filters=None):
""" Run the synchronization """
if filters is None:
filters = {}
res = self.backend_adapter.search(filters)
orders = res.get('Orders', [])
for order in orders:
self._import_record(order['AmazonOrderId'])
class SaleOrderImportMapper(Component):
_name = 'amazon.sale.order.mapper'
_inherit = 'amazon.import.mapper'
_apply_on = 'amazon.sale.order'
direct = [
('AmazonOrderId', 'external_id'),
(normalize_datetime('PurchaseDate'), 'effective_date'),
(normalize_datetime('LatestShipDate'), 'date_planned'),
(normalize_datetime('LatestDeliveryDate'), 'requested_date'),
('ShipServiceLevel', 'ship_service_level'),
('ShipmentServiceLevelCategory', 'ship_service_level_category'),
('MarketplaceId', 'marketplace'),
('OrderType', 'order_type'),
('IsBusinessOrder', 'is_business_order'),
('IsPrime', 'is_prime'),
('IsGlobalExpressEnabled', 'is_global_express_enabled'),
('IsPremiumOrder', 'is_premium'),
('IsSoldByAB', 'is_sold_by_ab'),
('FulfillmentChannel', 'fulfillment_channel'),
]
children = [
('OrderItems', 'amazon_order_line_ids', 'amazon.sale.order.line'),
]
def _add_shipping_line(self, map_record, values):
# Any reason it wouldn't always be free?
# We need a delivery line to prevent shipping from invoicing cost of shipping.
record = map_record.source
line_builder = self.component(usage='order.line.builder.shipping')
line_builder.price_unit = 0.0
if values.get('carrier_id'):
carrier = self.env['delivery.carrier'].browse(values['carrier_id'])
line_builder.product = carrier.product_id
line = (0, 0, line_builder.get_line())
values['order_line'].append(line)
return values
def finalize(self, map_record, values):
values.setdefault('order_line', [])
self._add_shipping_line(map_record, values)
values.update({
'partner_id': self.options.partner_id,
'partner_invoice_id': self.options.partner_invoice_id,
'partner_shipping_id': self.options.partner_shipping_id,
})
onchange = self.component(
usage='ecommerce.onchange.manager.sale.order'
)
return onchange.play(values, values['amazon_order_line_ids'])
def is_fba(self, record):
return record.get('FulfillmentChannel') == 'AFN'
@mapping
def name(self, record):
name = record['AmazonOrderId']
prefix = self.backend_record.fba_sale_prefix if self.is_fba(record) else self.backend_record.sale_prefix
if prefix:
name = prefix + name
return {'name': name}
@mapping
def total_amount(self, record):
return {'total_amount': float(record.get('OrderTotal', {}).get('Amount', '0.0'))}
@mapping
def currency_id(self, record):
currency_code = record.get('OrderTotal', {}).get('CurrencyCode')
if not currency_code:
# TODO default to company currency if not specified
return {}
currency = self.env['res.currency'].search([('name', '=', currency_code)], limit=1)
return {'currency_id': currency.id}
@mapping
def warehouse_id(self, record):
warehouses = self.backend_record.warehouse_ids + self.backend_record.fba_warehouse_ids
postal_code = record.get('DefaultShipFromLocationAddress', {}).get('PostalCode')
if not warehouses or not postal_code:
# use default
warehouses = self.backend_record.fba_warehouse_ids if self.is_fba(record) else self.backend_record.warehouse_ids
for warehouse in warehouses:
# essentially the first of either regular or FBA warehouses
return {'warehouse_id': warehouse.id, 'company_id': warehouse.company_id.id}
return {}
warehouses = warehouses.filtered(lambda w: w.partner_id.zip == postal_code)
for warehouse in warehouses:
return {'warehouse_id': warehouse.id, 'company_id': warehouse.company_id.id}
return {}
@mapping
def backend_id(self, record):
return {'backend_id': self.backend_record.id}
@mapping
def fiscal_position_id(self, record):
fiscal_position = self.backend_record.fba_fiscal_position_id if self.is_fba(record) else self.backend_record.fiscal_position_id
if fiscal_position:
return {'fiscal_position_id': fiscal_position.id}
@mapping
def team_id(self, record):
team = self.backend_record.fba_team_id if self.is_fba(record) else self.backend_record.team_id
if team:
return {'team_id': team.id}
@mapping
def user_id(self, record):
user = self.backend_record.fba_user_id if self.is_fba(record) else self.backend_record.user_id
if user:
return {'user_id': user.id}
@mapping
def payment_mode_id(self, record):
payment_mode = self.backend_record.fba_payment_mode_id if self.is_fba(record) else self.backend_record.payment_mode_id
assert payment_mode, ("Payment mode must be specified on the Amazon Backend.")
return {'payment_mode_id': payment_mode.id}
@mapping
def analytic_account_id(self, record):
analytic_account = self.backend_record.fba_analytic_account_id if self.is_fba(record) else self.backend_record.analytic_account_id
if analytic_account:
return {'analytic_account_id': analytic_account.id}
@mapping
def carrier_id(self, record):
carrier = self.backend_record.fba_carrier_id if self.is_fba(record) else self.backend_record.carrier_id
if carrier:
return {'carrier_id': carrier.id}
class SaleOrderImporter(Component):
_name = 'amazon.sale.order.importer'
_inherit = 'amazon.importer'
_apply_on = 'amazon.sale.order'
def _get_amazon_data(self):
""" Return the raw Amazon data for ``self.external_id`` """
return self.backend_adapter.read(self.external_id,
include_order_items=True,
include_order_address=True,
include_order_buyer_info=True,
include_order_items_buyer_info=False, # this call doesn't add anything useful
)
def _must_skip(self):
if self.binder.to_internal(self.external_id):
return _('Already imported')
def _before_import(self):
status = self.amazon_record.get('OrderStatus')
if status == 'Pending':
raise RetryableJobError('Order is Pending')
if status == 'Canceled':
raise NothingToDoJob('Order is Cancelled')
def _create_partner(self, values):
return self.env['res.partner'].create(values)
def _get_partner_values(self):
cipher = make_amz_pii_cipher(self.env)
if cipher:
amz_pii_encrypt = make_amz_pii_encrypt(cipher)
else:
def amz_pii_encrypt(value):
return value
record = self.amazon_record
# find or make partner with these details.
if 'OrderAddress' not in record or 'ShippingAddress' not in record['OrderAddress']:
raise ValueError('Order does not have OrderAddress.ShippingAddress in : ' + str(record))
ship_info = record['OrderAddress']['ShippingAddress']
email = record.get('OrderBuyerInfo', {}).get('BuyerEmail', '')
phone = ship_info.get('Phone') or ''
if phone:
phone = amz_pii_encrypt(phone)
name = ship_info.get('Name')
if name:
name = amz_pii_encrypt(name)
else:
name = record['AmazonOrderId'] # customer will be named after order....
street = ship_info.get('AddressLine1') or ''
if street:
street = amz_pii_encrypt(street)
street2 = ship_info.get('AddressLine2') or ''
if street2:
street2 = amz_pii_encrypt(street2)
city = ship_info.get('City') or ''
country_code = ship_info.get('CountryCode') or ''
country_id = False
if country_code:
country_id = self.env['res.country'].search([('code', '=ilike', country_code)], limit=1).id
state_id = False
state_code = ship_info.get('StateOrRegion') or ''
if state_code:
state_domain = [('code', '=ilike', state_code)]
if country_id:
state_domain.append(('country_id', '=', country_id))
state_id = self.env['res.country.state'].search(state_domain, limit=1).id
if not state_id and state_code:
# Amazon can send some strange stuff like 'TEXAS'
state_domain[0] = ('name', '=ilike', state_code)
state_id = self.env['res.country.state'].search(state_domain, limit=1).id
zip_ = ship_info.get('PostalCode') or ''
res = {
'email': email,
'name': name,
'phone': phone,
'street': street,
'street2': street2,
'zip': zip_,
'city': city,
'state_id': state_id,
'country_id': country_id,
'type': 'contact',
}
_logger.warn('partner values: ' + str(res))
return res
def _import_addresses(self):
partner_values = self._get_partner_values()
# Find or create a 'parent' partner for the address.
if partner_values['email']:
partner = self.env['res.partner'].search([
('email', '=', partner_values['email']),
('parent_id', '=', False)
], limit=1)
else:
partner = self.env['res.partner'].search([
('name', '=', partner_values['name']),
('parent_id', '=', False)
], limit=1)
if not partner:
# create partner.
partner = self._create_partner({'name': partner_values['name'], 'email': partner_values['email']})
partner_values['parent_id'] = partner.id
partner_values['type'] = 'other'
shipping_partner = self._create_partner(partner_values)
self.partner = partner
self.shipping_partner = shipping_partner
def _check_special_fields(self):
assert self.partner, (
"self.partner should have been defined "
"in SaleOrderImporter._import_addresses")
assert self.shipping_partner, (
"self.shipping_partner should have been defined "
"in SaleOrderImporter._import_addresses")
def _create_data(self, map_record, **kwargs):
# non dependencies
self._check_special_fields()
return super(SaleOrderImporter, self)._create_data(
map_record,
partner_id=self.partner.id,
partner_invoice_id=self.shipping_partner.id,
partner_shipping_id=self.shipping_partner.id,
**kwargs
)
def _create_plan(self, binding):
plan = None
if not binding.is_fba():
# I really do not like that we need to use planner here.
# it adds to the setup and relies on the planner being setup with the appropriate warehouses.
# Why Amazon, can you not just tell me which warehouse?
options = self.env['sale.order.make.plan'].generate_order_options(binding.odoo_id, plan_shipping=False)
if options:
plan = options[0]
sub_options = plan.get('sub_options')
# serialize lines
if sub_options:
plan['sub_options'] = dumps(sub_options)
if plan:
option = self.env['sale.order.planning.option'].create(plan)
self.env['sale.order.make.plan'].plan_order_option(binding.odoo_id, option)
def _create(self, data):
binding = super(SaleOrderImporter, self)._create(data)
self._create_plan(binding)
# Without this, it won't map taxes with the fiscal position.
if binding.fiscal_position_id:
binding.odoo_id._compute_tax_id()
return binding
def _import_dependencies(self):
record = self.amazon_record
self._import_addresses()
class SaleOrderLineImportMapper(Component):
_name = 'amazon.sale.order.line.mapper'
_inherit = 'amazon.import.mapper'
_apply_on = 'amazon.sale.order.line'
direct = [
('OrderItemId', 'external_id'),
('Title', 'name'),
('QuantityOrdered', 'product_uom_qty'),
]
def _finalize_product_values(self, record, values):
# This would be a good place to create a vendor or add a route...
return values
def _product_sku(self, record):
# This would be a good place to modify or map the SellerSKU
return record['SellerSKU']
def _product_values(self, record):
sku = self._product_sku(record)
name = record['Title']
list_price = float(record.get('ItemPrice', {}).get('Amount', '0.0'))
values = {
'default_code': sku,
'name': name or sku,
'type': 'product',
'list_price': list_price,
'categ_id': self.backend_record.product_categ_id.id,
}
return self._finalize_product_values(record, values)
@mapping
def product_id(self, record):
asin = record['ASIN']
sku = self._product_sku(record)
binder = self.binder_for('amazon.product.product')
product = None
amazon_product = binder.to_internal(sku)
if amazon_product:
# keep the asin up to date (or set for the first time!)
if amazon_product.asin != asin:
amazon_product.asin = asin
product = amazon_product.odoo_id # unwrap
if not product:
product = self.env['product.product'].search([
('default_code', '=', sku)
], limit=1)
if not product:
# we could use a record like (0, 0, values)
product = self.env['product.product'].create(self._product_values(record))
amazon_product = self.env['amazon.product.product'].create({
'external_id': sku,
'odoo_id': product.id,
'backend_id': self.backend_record.id,
'asin': asin,
'state': 'sent', # Already exists in Amazon
})
return {'product_id': product.id}
@mapping
def price_unit(self, record):
# Apparently these are all up, not per-qty
qty = float(record.get('QuantityOrdered', '1.0')) or 1.0
price_unit = float(record.get('ItemPrice', {}).get('Amount', '0.0'))
discount = float(record.get('PromotionDiscount', {}).get('Amount', '0.0'))
# discount amount needs to be a percent...
discount = (discount / (price_unit or 1.0)) * 100.0
return {'price_unit': price_unit / qty, 'discount': discount / qty}
@mapping
def backend_id(self, record):
return {'backend_id': self.backend_record.id}

View File

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

View File

@@ -0,0 +1,147 @@
# © 2021 Hibou Corp.
from base64 import b64encode
from odoo import api, models, fields, _
from odoo.addons.queue_job.job import job, related_action
from odoo.addons.component.core import Component
import logging
_logger = logging.getLogger(__name__)
class AmazonStockPicking(models.Model):
_name = 'amazon.stock.picking'
_inherit = 'amazon.binding'
_inherits = {'stock.picking': 'odoo_id'}
_description = 'Amazon Delivery Order'
odoo_id = fields.Many2one(comodel_name='stock.picking',
string='Stock Picking',
required=True,
ondelete='cascade')
amazon_order_id = fields.Many2one(comodel_name='amazon.sale.order',
string='Amazon Sale Order',
ondelete='set null')
@job(default_channel='root.amazon')
@related_action(action='related_action_unwrap_binding')
@api.multi
def export_picking_done(self):
""" Export a complete or partial delivery order. """
self.ensure_one()
self = self.sudo()
with self.backend_id.work_on(self._name) as work:
exporter = work.component(usage='record.exporter')
return exporter.run(self)
class StockPicking(models.Model):
_inherit = 'stock.picking'
amazon_bind_ids = fields.One2many(
comodel_name='amazon.stock.picking',
inverse_name='odoo_id',
string="Amazon Bindings",
)
def has_amazon_pii(self):
self.ensure_one()
partner = self.partner_id
if not partner or not partner.email:
return False
return partner.email.find('@marketplace.amazon.com') >= 0
class StockPickingAdapter(Component):
_name = 'amazon.stock.picking.adapter'
_inherit = 'amazon.adapter'
_apply_on = 'amazon.stock.picking'
def _api(self):
return self.api_instance.feeds()
def create(self, amazon_picking, carrier_code, carrier_name, shipping_method, tracking):
amazon_order = amazon_picking.amazon_order_id
# api_instance = self.api_instance
# feeds_api = self._api()
order_line_qty = self._process_picking_items(amazon_picking)
feed_root, _message = self._order_fulfillment_feed(amazon_picking, amazon_order, order_line_qty, carrier_code, carrier_name, shipping_method, tracking)
feed_data = self._feed_string(feed_root)
feed = self.env['amazon.feed'].create({
'backend_id': amazon_order.backend_id.id,
'type': 'POST_ORDER_FULFILLMENT_DATA',
'content_type': 'text/xml',
'data': b64encode(feed_data),
'amazon_stock_picking_id': amazon_picking.id,
})
feed.with_delay(priority=20).submit_feed()
_logger.info('Feed for Amazon Order %s for tracking number %s created.' % (amazon_order.external_id, tracking))
return True
def _process_picking_items(self, amazon_picking):
amazon_order_line_to_qty = {}
amazon_so_lines = amazon_picking.move_lines.mapped('sale_line_id.amazon_bind_ids')
for so_line in amazon_so_lines:
stock_moves = amazon_picking.move_lines.filtered(lambda sm: sm.sale_line_id.amazon_bind_ids in so_line and sm.quantity_done)
if stock_moves:
amazon_order_line_to_qty[so_line.external_id] = sum(stock_moves.mapped('quantity_done'))
return amazon_order_line_to_qty
def _order_fulfillment_feed(self, amazon_picking, amazon_order, order_line_qty, carrier_code, carrier_name, shipping_method, tracking):
root, message = self._feed('OrderFulfillment', amazon_order.backend_id)
order_fulfillment = self.ElementTree.SubElement(message, 'OrderFulfillment')
self.ElementTree.SubElement(order_fulfillment, 'AmazonOrderID').text = amazon_order.external_id
self.ElementTree.SubElement(order_fulfillment, 'FulfillmentDate').text = fields.Datetime.from_string(amazon_picking.create_date).isoformat()
fulfillment_data = self.ElementTree.SubElement(order_fulfillment, 'FulfillmentData')
self.ElementTree.SubElement(fulfillment_data, 'CarrierCode').text = carrier_code
self.ElementTree.SubElement(fulfillment_data, 'CarrierName').text = carrier_name
self.ElementTree.SubElement(fulfillment_data, 'ShippingMethod').text = shipping_method
self.ElementTree.SubElement(fulfillment_data, 'ShipperTrackingNumber').text = tracking
for num, qty in order_line_qty.items():
item = self.ElementTree.SubElement(order_fulfillment, 'Item')
self.ElementTree.SubElement(item, 'AmazonOrderItemCode').text = num
self.ElementTree.SubElement(item, 'Quantity').text = str(int(qty)) # always whole
return root, message
class AmazonBindingStockPickingListener(Component):
_name = 'amazon.binding.stock.picking.listener'
_inherit = 'base.event.listener'
_apply_on = ['amazon.stock.picking']
def on_record_create(self, record, fields=None):
record.with_delay(priority=10).export_picking_done()
class AmazonStockPickingListener(Component):
_name = 'amazon.stock.picking.listener'
_inherit = 'base.event.listener'
_apply_on = ['stock.picking']
def on_picking_dropship_done(self, record, picking_method):
return self.on_picking_out_done(record, picking_method)
def on_picking_out_done(self, record, picking_method):
"""
Create a ``amazon.stock.picking`` record. This record will then
be exported to Amazon.
:param picking_method: picking_method, can be 'complete' or 'partial'
:type picking_method: str
"""
sale = record.sale_id
if not sale:
return
if record.carrier_id.delivery_type == 'amazon_sp_mfn':
# buying postage through Amazon already marks it shipped.
return
for amazon_sale in sale.amazon_bind_ids:
self.env['amazon.stock.picking'].sudo().create({
'backend_id': amazon_sale.backend_id.id,
'odoo_id': record.id,
'amazon_order_id': amazon_sale.id,
})

View File

@@ -0,0 +1,41 @@
# © 2021 Hibou Corp.
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import NothingToDoJob
class AmazonPickingExporter(Component):
_name = 'amazon.stock.picking.exporter'
_inherit = 'amazon.exporter'
_apply_on = ['amazon.stock.picking']
def _get_tracking(self, binding):
return binding.carrier_tracking_ref or ''
def _get_carrier_code(self, binding):
return binding.carrier_id.amazon_sp_carrier_code or 'Other'
def _get_carrier_name(self, binding):
return binding.carrier_id.amazon_sp_carrier_name or binding.carrier_id.name or 'Other'
def _get_shipping_method(self, binding):
return binding.carrier_id.amazon_sp_shipping_method or 'Standard'
def run(self, binding):
"""
Export the picking to Amazon
:param binding: amazon.stock.picking
:return:
"""
if binding.external_id:
return 'Already exported'
tracking = self._get_tracking(binding)
if not tracking:
raise NothingToDoJob('Cancelled: the delivery order does not contain tracking.')
carrier_code = self._get_carrier_code(binding)
carrier_name = self._get_carrier_name(binding)
shipping_method = self._get_shipping_method(binding)
_res = self.backend_adapter.create(binding, carrier_code, carrier_name, shipping_method, tracking)
# Note we essentially bind to our own ID because we just need to notify Amazon
self.binder.bind(str(binding.odoo_id), binding)