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