diff --git a/connector_opencart/models/product/common.py b/connector_opencart/models/product/common.py index 7b66edb5..cd220ab6 100644 --- a/connector_opencart/models/product/common.py +++ b/connector_opencart/models/product/common.py @@ -1,4 +1,5 @@ from odoo import api, fields, models +from odoo.exceptions import UserError from odoo.addons.queue_job.exception import NothingToDoJob, RetryableJobError from odoo.addons.component.core import Component @@ -17,7 +18,57 @@ class OpencartProductTemplate(models.Model): 'opencart_product_tmpl_id', string='Opencart Product Attribute Values') + def opencart_sale_line_custom_value_commands(self, options): + """Receives 'custom options' and returns commands for SO lines to link to. + This method will setup the product template to support the supplied commands.""" + commands = [] + for option in options: + c_attr_name = option.get('name') + c_attr_value = option.get('value') + if not all((c_attr_name, c_attr_value)): + raise UserError('Mapping sale order custom values cannot happen if the option is missing name or value. Original option payload: ' + str(option)) + # note this is a weak binding because the name could change, even due to translation + attr_line = self.odoo_id.attribute_line_ids.filtered(lambda l: l.attribute_id.name == c_attr_name) + if not attr_line: + attribute = self.env['product.attribute'].search([('name', '=', c_attr_name)], limit=1) + if not attribute: + # we will have to assume some things about the attribute + attribute = self.env['product.attribute'].create({ + 'name': c_attr_name, + 'create_variant': 'no_variant', + # 'visibility': 'hidden', # TODO who adds this field + 'value_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'name': 'opencart-custom', # people can rename it. + 'is_custom': True, + })], + }) + value = attribute.value_ids.filtered('is_custom') + if len(value) > 1: + value = value[0] + # while we may not have a value here, the exception should tell us as much as us raising one ourself + # now we have an attribute, we can make an attribute value line with one custom va + self.odoo_id.write({ + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(4, value.id)] + })] + }) + attr_line = self.odoo_id.attribute_line_ids.filtered(lambda l: l.attribute_id == attribute) + # now we have a product template attribute line, it should have a custom value + attr_line_value = attr_line.product_template_value_ids.filtered(lambda v: v.is_custom) + if len(attr_line_value) > 1: + attr_line_value = attr_line_value[0] + # again we may not have a value, but the exception will be on the SOL side + commands.append((0, 0, { + 'custom_product_template_attribute_value_id': attr_line_value.id, + 'custom_value': c_attr_value, + })) + return commands + def opencart_sale_get_combination(self, options, reentry=False): + # note we EXPECT every option passed in here to have a 'product_option_value_id' + # filtering them out at this step is not desirable because of the recursive entry with options if not options: return self.odoo_id.product_variant_id selected_attribute_values = self.env['product.template.attribute.value'] @@ -37,7 +88,12 @@ class OpencartProductTemplate(models.Model): raise RetryableJobError('Product imported, but selected option is not available.') if not opencart_attribute_value.odoo_id: raise RetryableJobError('Order Product (%s) has option (%s) "%s" that is not mapped to an Odoo Attribute Value.' % (self, opencart_attribute_value.external_id, opencart_attribute_value.opencart_name)) - selected_attribute_values += opencart_attribute_value.odoo_id + selected_attribute_values |= opencart_attribute_value.odoo_id + # we always need to 'select' template attr values for 'no variant' options + # this is only need if it creates the variant because this value cannot be skipped otherwise it is an invalid variant + for line in self.odoo_id.attribute_line_ids.filtered(lambda pal: pal.attribute_id.create_variant == 'no_variant'): + # and there must always bee at least one + selected_attribute_values |= line.product_template_value_ids[0] # Now that we know what options are selected, we can load a variant with those options product = self.odoo_id._create_product_variant(selected_attribute_values, log_warning=True) if not product: diff --git a/connector_opencart/models/product/importer.py b/connector_opencart/models/product/importer.py index b5043567..6157bd81 100644 --- a/connector_opencart/models/product/importer.py +++ b/connector_opencart/models/product/importer.py @@ -79,6 +79,14 @@ class ProductImporter(Component): binding = super(ProductImporter, self)._create(data) self.backend_record.add_checkpoint(binding, summary=checkpoint_summary) return binding + + def _update(self, binding, data): + checkpoint_summary = data.get('checkpoint_summary', '') + if 'checkpoint_summary' in data: + del data['checkpoint_summary'] + if checkpoint_summary: + self.backend_record.add_checkpoint(binding, summary=checkpoint_summary) + return super()._update(binding, data) def _after_import(self, binding): self._sync_options(binding) diff --git a/connector_opencart/models/sale_order/importer.py b/connector_opencart/models/sale_order/importer.py index bd721993..68d44222 100644 --- a/connector_opencart/models/sale_order/importer.py +++ b/connector_opencart/models/sale_order/importer.py @@ -2,13 +2,14 @@ from copy import copy from html import unescape +from datetime import datetime, timedelta import logging from odoo import fields, _ from odoo.addons.component.core import Component from odoo.addons.connector.components.mapper import mapping from odoo.exceptions import ValidationError -from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.exception import RetryableJobError, NothingToDoJob, FailedJobError _logger = logging.getLogger(__name__) @@ -131,7 +132,6 @@ class SaleOrderImportMapper(Component): onchange = self.component( usage='ecommerce.onchange.manager.sale.order' ) - # will I need more?! return onchange.play(values, values['opencart_order_line_ids']) @mapping @@ -228,8 +228,8 @@ class SaleOrderImporter(Component): return _('Already imported') def _before_import(self): - # Check if status is ok, etc. on self.opencart_record - pass + rules = self.component(usage='sale.import.rule') + rules.check(self.opencart_record) def _create_partner(self, values): return self.env['res.partner'].create(values) @@ -422,6 +422,85 @@ class SaleOrderImporter(Component): raise RetryableJobError('Products need setup. OpenCart Product IDs:' + str(products_need_setup), seconds=3600) +class SaleImportRule(Component): + _name = 'opencart.sale.import.rule' + _inherit = 'base.opencart.connector' + _apply_on = 'opencart.sale.order' + _usage = 'sale.import.rule' + + _status_no_import = [ + 'Canceled', + 'Canceled Reversal', + 'Chargeback', + 'Denied', + 'Expired', + 'Failed', + 'Refunded', + 'Reversed', + 'Voided', + ] + + _status_import_later = [ + 'Pending', + 'Processing', + ] + + def _rule_always(self, record, method): + """ Always import the order """ + return True + + def _rule_check_status(self, record, method): + if record['order_status'] in self._status_import_later: + raise RetryableJobError('Order %s is in %s and will be re-tried later.' % (order_id, order_status)) + return True + + def _rule_never(self, record, method): + """ Never import the order """ + raise NothingToDoJob('Orders with payment method %s are never imported.' % method.name) + + # currently, no good way of knowing if an order is paid or authorized + # we use these both to indicate you only want to import it if it makes it + # past a pending/processing state (the order itself) + _rules = {'always': _rule_always, + 'paid': _rule_check_status, + 'authorized': _rule_check_status, + 'never': _rule_never, + } + + def _rule_global(self, record, method): + """ Rule always executed, whichever is the selected rule. + Discards orders based on it being in a canceled state or status. + Discards orders based on order date being outside of import window.""" + order_id = record['order_id'] + order_status = record['order_status'] + if order_status in self._status_no_import: + raise NothingToDoJob('Order %s not imported for status %s' % (order_id, order_status)) + max_days = method.days_before_cancel + if max_days: + order_date = self.backend_record.date_to_odoo(record['date_added']) + if order_date + timedelta(days=max_days) < datetime.now(): + raise NothingToDoJob('Import of the order %s canceled ' + 'because it has not been paid since %d ' + 'days' % (order_id, max_days)) + + def check(self, record): + """ Check whether the current sale order should be imported + or not. It will actually use the payment method configuration + and see if the choosed rule is fullfilled. + :returns: True if the sale order should be imported + :rtype: boolean + """ + record_method = record['payment_method'] + method = self.env['account.payment.mode'].search( + [('name', '=', record_method)], + limit=1, + ) + if not method: + raise FailedJobError('Payment Mode named "%s", cannot be found.' % (record_method, )) + self._rule_global(record, method) + self._rules[method.import_rule](self, record, method) + + class SaleOrderLineImportMapper(Component): _name = 'opencart.sale.order.line.mapper' @@ -433,10 +512,8 @@ class SaleOrderLineImportMapper(Component): ('order_product_id', 'external_id'), ] - @mapping - def name(self, record): - return {'name': unescape(record['name'])} - + # Note mapping for name is removed due to desire to get + # custom attr values to display via computed sol description @mapping def product_id(self, record): product_id = record['product_id'] @@ -445,5 +522,14 @@ class SaleOrderLineImportMapper(Component): # connector bindings are found with `active_test=False` but that also means computed fields # like `product.template.product_variant_id` could find different products because of archived variants opencart_product_template = binder.to_internal(product_id, unwrap=False).with_context(active_test=True) - product = opencart_product_template.opencart_sale_get_combination(record.get('option')) - return {'product_id': product.id, 'product_uom': product.uom_id.id} + line_options = record.get('option') or [] + options_for_product = list(filter(lambda o: o.get('product_option_value_id'), line_options)) + options_for_line = list(filter(lambda o: not o.get('product_option_value_id'), line_options)) + product = opencart_product_template.opencart_sale_get_combination(options_for_product) + + custom_option_commands = opencart_product_template.opencart_sale_line_custom_value_commands(options_for_line) + return { + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_custom_attribute_value_ids': custom_option_commands, + } diff --git a/delivery_purolator/__init__.py b/delivery_purolator/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/delivery_purolator/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_purolator/__manifest__.py b/delivery_purolator/__manifest__.py new file mode 100644 index 00000000..cc305e42 --- /dev/null +++ b/delivery_purolator/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'Purolator Shipping', + 'summary': 'Send your shippings through Purolator and track them online.', + 'version': '15.0.1.0.1', + 'author': "Hibou Corp.", + 'category': 'Warehouse', + 'license': 'OPL-1', + 'images': [], + 'website': "https://hibou.io", + 'description': """ +Purolator Shipping +================== + +* Provides estimates on shipping costs. +* Send your shippings and track packages. +""", + 'depends': [ + 'delivery_hibou', + ], + 'demo': [ + 'data/delivery_purolator_demo.xml', + ], + 'data': [ + 'data/delivery_purolator_data.xml', + 'views/delivery_purolator_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/delivery_purolator/data/delivery_purolator_data.xml b/delivery_purolator/data/delivery_purolator_data.xml new file mode 100644 index 00000000..527dd1c0 --- /dev/null +++ b/delivery_purolator/data/delivery_purolator_data.xml @@ -0,0 +1,24 @@ + + + + + + Purolator Default + + purolator + + + + Purolator LargePackage + LargePackage + purolator + + + + Purolator FlatPackage + FlatPackage + purolator + + + + diff --git a/delivery_purolator/data/delivery_purolator_demo.xml b/delivery_purolator/data/delivery_purolator_demo.xml new file mode 100644 index 00000000..ede6dd63 --- /dev/null +++ b/delivery_purolator/data/delivery_purolator_demo.xml @@ -0,0 +1,46 @@ + + + + + + + + Purolator Express + Delivery_PurolatorExpress + service + + + + 0.0 + order + + + Purolator Express Test + + purolator + PurolatorExpress + + + + + + Purolator Ground + Delivery_PurolatorGround + service + + + + 0.0 + order + + + Purolator Ground Test + + purolator + PurolatorGround + + + + + + diff --git a/delivery_purolator/models/__init__.py b/delivery_purolator/models/__init__.py new file mode 100644 index 00000000..8a30199b --- /dev/null +++ b/delivery_purolator/models/__init__.py @@ -0,0 +1,2 @@ +from . import delivery_purolator +from . import stock_package_type diff --git a/delivery_purolator/models/delivery_purolator.py b/delivery_purolator/models/delivery_purolator.py new file mode 100644 index 00000000..fe9de227 --- /dev/null +++ b/delivery_purolator/models/delivery_purolator.py @@ -0,0 +1,385 @@ +from base64 import b64decode +from odoo import fields, models, _ +from odoo.exceptions import UserError +from .purolator_services import PurolatorClient +import logging + + +_logger = logging.getLogger(__name__) + +# 2022-09-21 - US Methods are known to rate, but cannot ship without additional customs/documents +PUROLATOR_SERVICES = [ + ('PurolatorExpress9AM', 'Purolator Express 9AM'), + ('PurolatorExpress10:30AM', 'Purolator Express 10:30AM'), + ('PurolatorExpress12PM', 'Purolator Express 12PM'), + ('PurolatorExpress', 'Purolator Express'), + ('PurolatorExpressEvening', 'Purolator Express Evening'), + ('PurolatorExpressEnvelope9AM', 'Purolator Express Envelope 9AM'), + ('PurolatorExpressEnvelope10:30AM', 'Purolator Express Envelope 10:30AM'), + ('PurolatorExpressEnvelope12PM', 'Purolator Express Envelope 12PM'), + ('PurolatorExpressEnvelope', 'Purolator Express Envelope'), + ('PurolatorExpressEnvelopeEvening', 'Purolator Express Envelope Evening'), + ('PurolatorExpressPack9AM', 'Purolator Express Pack 9AM'), + ('PurolatorExpressPack10:30AM', 'Purolator Express Pack 10:30AM'), + ('PurolatorExpressPack12PM', 'Purolator Express Pack 12PM'), + ('PurolatorExpressPack', 'Purolator Express Pack'), + ('PurolatorExpressPackEvening', 'Purolator Express Pack Evening'), + ('PurolatorExpressBox9AM', 'Purolator Express Box 9AM'), + ('PurolatorExpressBox10:30AM', 'Purolator Express Box 10:30AM'), + ('PurolatorExpressBox12PM', 'Purolator Express Box 12PM'), + ('PurolatorExpressBox', 'Purolator Express Box'), + ('PurolatorExpressBoxEvening', 'Purolator Express Box Evening'), + ('PurolatorGround', 'Purolator Ground'), + ('PurolatorGround9AM', 'Purolator Ground 9AM'), + ('PurolatorGround10:30AM', 'Purolator Ground 10:30AM'), + ('PurolatorGroundEvening', 'Purolator Ground Evening'), + ('PurolatorQuickShip', 'Purolator Quick Ship'), + ('PurolatorQuickShipEnvelope', 'Purolator Quick Ship Envelope'), + ('PurolatorQuickShipPack', 'Purolator Quick Ship Pack'), + ('PurolatorQuickShipBox', 'Purolator Quick Ship Box'), + # 2022-09-21 - US Methods are known to rate, but cannot ship without additional customs/documents + # ('PurolatorExpressU.S.', 'Purolator Express U.S.'), + # ('PurolatorExpressU.S.9AM', 'Purolator Express U.S. 9AM'), + # ('PurolatorExpressU.S.10:30AM', 'Purolator Express U.S. 10:30AM'), + # ('PurolatorExpressU.S.12:00', 'Purolator Express U.S. 12:00'), + # ('PurolatorExpressEnvelopeU.S.', 'Purolator Express Envelope U.S.'), + # ('PurolatorExpressU.S.Envelope9AM', 'Purolator Express U.S. Envelope 9AM'), + # ('PurolatorExpressU.S.Envelope10:30AM', 'Purolator Express U.S. Envelope 10:30AM'), + # ('PurolatorExpressU.S.Envelope12:00', 'Purolator Express U.S. Envelope 12:00'), + # ('PurolatorExpressPackU.S.', 'Purolator Express Pack U.S.'), + # ('PurolatorExpressU.S.Pack9AM', 'Purolator Express U.S. Pack 9AM'), + # ('PurolatorExpressU.S.Pack10:30AM', 'Purolator Express U.S. Pack 10:30AM'), + # ('PurolatorExpressU.S.Pack12:00', 'Purolator Express U.S. Pack 12:00'), + # ('PurolatorExpressBoxU.S.', 'Purolator Express Box U.S.'), + # ('PurolatorExpressU.S.Box9AM', 'Purolator Express U.S. Box 9AM'), + # ('PurolatorExpressU.S.Box10:30AM', 'Purolator Express U.S. Box 10:30AM'), + # ('PurolatorExpressU.S.Box12:00', 'Purolator Express U.S. Box 12:00'), + # ('PurolatorGroundU.S.', 'Purolator Ground U.S.'), + # 2022-09-21 - International Methods are known to rate + # ('PurolatorExpressInternational', 'Purolator Express International'), + # ('PurolatorExpressInternational9AM', 'Purolator Express International 9AM'), + # ('PurolatorExpressInternational10:30AM', 'Purolator Express International 10:30AM'), + # ('PurolatorExpressInternational12:00', 'Purolator Express International 12:00'), + # ('PurolatorExpressEnvelopeInternational', 'Purolator Express Envelope International'), + # ('PurolatorExpressInternationalEnvelope9AM', 'Purolator Express International Envelope 9AM'), + # ('PurolatorExpressInternationalEnvelope10:30AM', 'Purolator Express International Envelope 10:30AM'), + # ('PurolatorExpressInternationalEnvelope12:00', 'Purolator Express International Envelope 12:00'), + # ('PurolatorExpressPackInternational', 'Purolator Express Pack International'), + # ('PurolatorExpressInternationalPack9AM', 'Purolator Express International Pack 9AM'), + # ('PurolatorExpressInternationalPack10:30AM', 'Purolator Express International Pack 10:30AM'), + # ('PurolatorExpressInternationalPack12:00', 'Purolator Express International Pack 12:00'), + # ('PurolatorExpressBoxInternational', 'Purolator Express Box International'), + # ('PurolatorExpressInternationalBox9AM', 'Purolator Express International Box 9AM'), + # ('PurolatorExpressInternationalBox10:30AM', 'Purolator Express International Box 10:30AM'), + # ('PurolatorExpressInternationalBox12:00', 'Purolator Express International Box 12:00'), +] + + +class ProviderPurolator(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection(selection_add=[('purolator', 'Purolator')], + ondelete={'purolator': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})}) + purolator_api_key = fields.Char(string='Purolator API Key', groups='base.group_system') + purolator_password = fields.Char(string='Purolator Password', groups='base.group_system') + purolator_activation_key = fields.Char(string='Purolator Activation Key', groups='base.group_system') + purolator_account_number = fields.Char(string='Purolator Account Number', groups='base.group_system') + purolator_service_type = fields.Selection(selection=PUROLATOR_SERVICES, + default='PurolatorGround') + purolator_default_package_type_id = fields.Many2one('stock.package.type', string="Purolator Package Type") + purolator_label_file_type = fields.Selection([ + ('PDF', 'PDF'), + ('ZPL', 'ZPL'), + ], default='ZPL', string="Purolator Label File Type") + + def purolator_convert_weight(self, weight): + weight_uom_id = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter() + return weight_uom_id._compute_quantity(weight, self.env.ref('uom.product_uom_lb'), round=False) + + def purolator_convert_length(self, length): + raise Exception('Not implemented. Need to do math on UOM to convert less dimensions') + volume_uom_id = self.env['product.template']._get_volume_uom_id_from_ir_config_parameter() + return volume_uom_id._compute_quantity(weight, self.env.ref('uom.product_uom_lb'), round=False) + + def purolator_rate_shipment(self, order, downgrade_response=True): + multi_res = self._purolator_rate_shipment_multi_package(order=order) + for res in multi_res: + if res.get('carrier') == self: + if downgrade_response: + return { + 'success': res.get('success', True), + 'price': res.get('price', 0.0), + 'error_message': res.get('error_message', False), + 'warning_message': res.get('warning_message', False), + } + return res + return { + 'success': False, + 'price': 0.0, + 'error_message': _('No rate found matching service: %s') % self.purolator_service_type, + 'warning_message': False, + } + + def purolator_rate_shipment_multi(self, order=None, picking=None, packages=None): + if not packages: + return self._purolator_rate_shipment_multi_package(order=order, picking=picking) + else: + rates = [] + for package in packages: + rates += self._purolator_rate_shipment_multi_package(order=order, picking=picking, package=package) + return rates + + def _purolator_format_errors(self, response_body, raise_class=None): + errors = response_body.ResponseInformation.Errors + if errors: + errors = errors.Error # unpack container node + puro_errors = ['%s - %s - %s' % (e.Code, e.AdditionalInformation, e.Description) for e in errors] + if raise_class: + raise raise_class(_('Error(s) during Purolator Request:\n%s') % ('\n\n'.join(puro_errors), )) + return puro_errors + + def _purolator_shipment_fill_payor(self, request, picking=None, order=None): + request.PaymentInformation.PaymentType = 'Sender' + request.PaymentInformation.RegisteredAccountNumber = self.purolator_account_number + request.PaymentInformation.BillingAccountNumber = self.purolator_account_number + third_party_account = self.purolator_third_party(picking=picking, order=order) + # when would it be 'Receiver' ? + if third_party_account: + request.PaymentInformation.PaymentType = 'ThirdParty' + request.PaymentInformation.BillingAccountNumber = third_party_account + + def _purolator_shipment_fill_options(self, request, picking=None, order=None, packages=None): + # Signature can come from any package/packages + require_signature = False + if packages: + # if ANY package has it + require_signature = any(packages.mapped('require_signature')) + else: + require_signature = self.get_signature_required(order=order, picking=picking) + # when we support international, there is also ResidentialSignatureIntl (and AdultSignatureRequired) + request.ResidentialSignatureDomestic = 'true' if require_signature else 'false' + + declared_value = 0.0 + if packages: + declared_value = sum(s or 0.0 for s in packages.mapped('declared_value')) + else: + declared_value = self.get_insurance_value(picking=picking, order=order) + if declared_value: + request.DeclaredValue = str(round(declared_value, 2)) + + request.DeclaredValue = str(self.get_insurance_value()) + # _logger.info(' _purolator_shipment_fill_options set sig.req. %s set declared val. %s' % (require_signature, declared_value)) + + def _purolator_rate_shipment_multi_package(self, order=None, picking=None, package=None): + service = self._purolator_service() + third_party = self.purolator_third_party(order=order, picking=picking) + sender = self.get_shipper_warehouse(order=order, picking=picking) + receiver = self.get_recipient(order=order, picking=picking) + + date_planned = fields.Datetime.now() + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + + # create SOAP request to fill in + shipment = service.estimate_shipment_request() + # request getting more than one service back + shipment.ShowAlternativeServicesIndicator = "true" + # indicate when we will ship this for time in transit + shipment.ShipmentDate = str(date_planned) + if hasattr(date_planned, 'date'): + shipment.ShipmentDate = str(date_planned.date()) + + # populate origin information + self._purolator_fill_address(shipment.SenderInformation.Address, sender) + # populate destination + self._purolator_fill_address(shipment.ReceiverInformation.Address, receiver) + + if order: + service.estimate_shipment_add_sale_order_packages(shipment, self, order) + else: + service.estimate_shipment_add_picking_packages(shipment, self, picking, package) + + self._purolator_shipment_fill_payor(shipment, order=order, picking=picking) + self._purolator_shipment_fill_options(shipment, order=order, picking=picking, packages=package) + + shipment_res = service.get_full_estimate(shipment) + + # _logger.info('_purolator_rate_shipment_multi_package called with shipment %s result %s' % (shipment, shipment_res)) + + errors = self._purolator_format_errors(shipment_res) + if errors: + return [{'carrier': self, + 'success': False, + 'price': 0.0, + 'error_message': '\n'.join(errors), + 'warning_message': False, + }] + rates = [] + for shipment in shipment_res.ShipmentEstimates.ShipmentEstimate: + carrier = self.purolator_find_delivery_carrier_for_service(shipment['ServiceID']) + if carrier: + price = shipment['TotalPrice'] + rates.append({ + 'carrier': carrier, + 'package': package or self.env['stock.quant.package'].browse(), + 'success': True, + 'price': price if not third_party else 0.0, + 'error_message': False, + 'warning_message': _('TotalCharge not found.') if price == 0.0 else False, + 'date_planned': date_planned, + 'date_delivered': fields.Datetime.to_datetime(shipment['ExpectedDeliveryDate']), + 'transit_days': shipment['EstimatedTransitDays'], + 'service_code': shipment['ServiceID'], + }) + + return rates + + def purolator_find_delivery_carrier_for_service(self, service_code): + if self.purolator_service_type == service_code: + return self + carrier = self.search([('delivery_type', '=', 'purolator'), + ('purolator_service_type', '=', service_code), + ('purolator_account_number', '=', self.purolator_account_number), + ], limit=1) + return carrier + + def purolator_third_party(self, order=None, picking=None): + third_party_account = self.get_third_party_account(order=order, picking=picking) + if third_party_account: + if not third_party_account.delivery_type == 'purolator': + raise ValidationError('Non-Purolator Shipping Account indicated during Purolator shipment.') + return third_party_account.name + return False + + def _purolator_service(self): + return PurolatorClient( + self.purolator_api_key, + self.purolator_password, + self.purolator_activation_key, + self.purolator_account_number, + self.prod_environment, + ) + + def _purolator_address_street(self, partner): + # assume we don't have base_address_extended + street = partner.street or '' + street_pieces = [t.strip() for t in street.split(' ')] + len_street_pieces = len(street_pieces) + if len_street_pieces >= 3: + street_num = street_pieces[0] + street_type = street_pieces[2] + # TODO santize the types? I see an example for "Douglas Road" that sends "Street" + return street_num, ' '.join(street_pieces[1:]), 'Street' + elif len_street_pieces == 2: + return street_pieces[0], street_pieces[1], 'Street' + return '', street, 'Street' + + def _purolator_address_phonenumber(self, partner): + # TODO parse out of partner.phone or one of the many other phone numbers + return '1', '905', '5555555' + + + def _purolator_fill_address(self, addr, partner): + # known to not work without a name + addr.Name = partner.name + addr.Company = partner.name if partner.is_company else (partner.company_name or '') + addr.Department = '' + addr.StreetNumber, addr.StreetName, addr.StreetType = self._purolator_address_street(partner) + # addr.City = partner.city.upper() if partner.city else '' + addr.City = partner.city or '' + addr.Province = partner.state_id.code + addr.Country = partner.country_id.code + addr.PostalCode = partner.zip + addr.PhoneNumber.CountryCode, addr.PhoneNumber.AreaCode, addr.PhoneNumber.Phone = self._purolator_address_phonenumber(partner) + + def _purolator_extract_doc_blobs(self, documents_result): + res = [] + for d in getattr(documents_result.Documents, 'Document', []): + for d2 in getattr(d.DocumentDetails, 'DocumentDetail', []): + res.append(d2.Data) + return res + + # Picking Shipping + def purolator_send_shipping(self, pickings): + res = [] + service = self._purolator_service() + + for picking in pickings: + picking_packages = self.get_to_ship_picking_packages(picking) + if picking_packages is None: + continue + + shipment = service.shipment_request() + + # populate origin information + sender = self.get_shipper_warehouse(picking=picking) + self._purolator_fill_address(shipment.SenderInformation.Address, sender) + + receiver = self.get_recipient(picking=picking) + self._purolator_fill_address(shipment.ReceiverInformation.Address, receiver) + + service.shipment_add_picking_packages(shipment, self, picking, picking_packages) + + self._purolator_shipment_fill_payor(shipment, picking=picking) + self._purolator_shipment_fill_options(shipment, picking=picking, packages=picking_packages) + + shipment_res = service.shipment_create(shipment, + printer_type=('Regular' if self.purolator_label_file_type == 'PDF' else 'Thermal')) + # _logger.info('purolator service.shipment_create for shipment %s resulted in %s' % (shipment, shipment_res)) + + # this will raise an error alerting the user if there is an error, and no more + self._purolator_format_errors(shipment_res, raise_class=UserError) + + document_blobs = [] + shipment_pin = shipment_res.ShipmentPIN.Value + if picking_packages and getattr(shipment_res, 'PiecePINs', None): + piece_pins = shipment_res.PiecePINs.PIN + for p, pin in zip(picking_packages, piece_pins): + pin = pin.Value + p.carrier_tracking_ref = pin + doc_res = service.document_by_pin(pin, output_type=self.purolator_label_file_type) + for n, blob in enumerate(self._purolator_extract_doc_blobs(doc_res), 1): + document_blobs.append(('PuroPackage-%s-%s.%s' % (pin, n, self.purolator_label_file_type), b64decode(blob))) + else: + # retrieve shipment_pin document(s) + doc_res = service.document_by_pin(shipment_pin, output_type=self.purolator_label_file_type) + # _logger.info('purolator service.document_by_pin for pin %s resulted in %s' % (shipment_pin, doc_res)) + for n, blob in enumerate(self._purolator_extract_doc_blobs(doc_res), 1): + document_blobs.append(('PuroShipment-%s-%s.%s' % (shipment_pin, n, self.purolator_label_file_type), b64decode(blob))) + + if document_blobs: + logmessage = _("Shipment created in Purolator
Tracking Number/PIN : %s") % (shipment_pin) + picking.message_post(body=logmessage, attachments=document_blobs) + + picking.carrier_tracking_ref = shipment_pin + shipping_data = { + 'exact_price': picking.carrier_price, # price is set during planning + 'tracking_number': shipment_pin, + } + res.append(shipping_data) + + return res + + def purolator_get_tracking_link(self, pickings): + res = [] + for picking in pickings: + ref = picking.carrier_tracking_ref + res = res + ['https://www.purolator.com/en/shipping/tracker?pins=%s' % ref] + return res + + def purolator_cancel_shipment(self, picking, packages=None): + service = self._purolator_service() + if packages: + for package in packages: + tracking_pin = package.carrier_tracking_ref + void_res = service.shipment_void(tracking_pin) + self._purolator_format_errors(void_res, raise_class=UserError) + package.write({'carrier_tracking_ref': ''}) + picking.message_post(body=_('Package N° %s has been cancelled' % tracking_pin)) + else: + tracking_pin = picking.carrier_tracking_ref + void_res = service.shipment_void(tracking_pin) + self._purolator_format_errors(void_res, raise_class=UserError) + picking.message_post(body=_('Shipment N° %s has been cancelled' % tracking_pin)) + picking.write({'carrier_tracking_ref': '', + 'carrier_price': 0.0}) diff --git a/delivery_purolator/models/purolator_services.py b/delivery_purolator/models/purolator_services.py new file mode 100644 index 00000000..9e276c73 --- /dev/null +++ b/delivery_purolator/models/purolator_services.py @@ -0,0 +1,323 @@ +from math import ceil +from requests import Session +from requests.auth import HTTPBasicAuth +from zeep import Client +from zeep.cache import SqliteCache +from zeep.transports import Transport +from odoo.exceptions import UserError + + +PUROLATOR_PIECE_SPECIAL_HANDLING_TYPE = [ + # 'AdditionalHandling', # unknown if this is "SpecialHandling" + 'FlatPackage', + 'LargePackage', + # 'Oversized', # unknown if this is "SpecialHandling" + # 'ResidentialAreaHeavyweight', # unknown if this is "SpecialHandling" +] + + +class PurolatorClient(object): + + # clients and factories + _estimating_client = None + @property + def estimating_client(self): + if not self._estimating_client: + self._estimating_client = self._get_client('/EWS/V2/Estimating/EstimatingService.asmx?wsdl', + request_reference='Rating') + return self._estimating_client + + _estimating_factory = None + @property + def estimating_factory(self): + if not self._estimating_factory: + self._estimating_factory = self.estimating_client.type_factory('ns1') + return self._estimating_factory + + _shipping_client = None + @property + def shipping_client(self): + if not self._shipping_client: + self._shipping_client = self._get_client('/EWS/V2/Shipping/ShippingService.asmx?wsdl', + request_reference='Shipping') + return self._shipping_client + + _shipping_factory = None + @property + def shipping_factory(self): + if not self._shipping_factory: + self._shipping_factory = self.shipping_client.type_factory('ns1') + return self._shipping_factory + + _shipping_documents_client = None + @property + def shipping_documents_client(self): + if not self._shipping_documents_client: + self._shipping_documents_client = self._get_client('/PWS/V1/ShippingDocuments/ShippingDocumentsService.asmx?wsdl', + version='1.3', + request_reference='ShippingDocuments') + return self._shipping_documents_client + + _shipping_documents_factory = None + @property + def shipping_documents_factory(self): + if not self._shipping_documents_factory: + self._shipping_documents_factory = self.shipping_documents_client.type_factory('ns1') + return self._shipping_documents_factory + + def __init__(self, api_key, password, activation_key, account_number, is_prod): + self.api_key = api_key + self.password = password + self.activation_key = activation_key + self.account_number = account_number + self._wsdl_base = "https://devwebservices.purolator.com" + if is_prod: + self._wsdl_base = "https://webservices.purolator.com" + + session = Session() + session.auth = HTTPBasicAuth(self.api_key, self.password) + self.transport = Transport(cache=SqliteCache(), session=session) + + def _get_client(self, wsdl_path, version='2.0', request_reference='RatingExample'): + # version added because shipping documents needs a different one + client = Client(self._wsdl_base + wsdl_path, + transport=self.transport) + request_context = client.get_element('ns1:RequestContext') + header_value = request_context( + Version=version, + Language='en', + GroupID='xxx', # TODO should we have a GroupID? + RequestReference=request_reference, + UserToken=self.activation_key, + ) + client.set_default_soapheaders([header_value]) + return client + + def get_full_estimate(self, shipment, show_alternative_services='true'): + response = self.estimating_client.service.GetFullEstimate( + Shipment=shipment, + ShowAlternativeServicesIndicator=show_alternative_services, + ) + return response.body + + def get_quick_estimate(self, sender_postal_code, receiver_address, package_type, total_weight): + """ Call GetQuickEstimate + + :param sender_postal_code: string + :param receiver_address: dict {'City': string, + 'Province': string, + 'Country': string, + 'PostalCode': string} + :param package_type: string + :param total_weight: float (in pounds) + :returns: dict {'shipments': list, 'error': string or False} + """ + response = self.estimating_client.service.GetQuickEstimate( + BillingAccountNumber=self.account_number, + SenderPostalCode=sender_postal_code, + ReceiverAddress=receiver_address, + PackageType=package_type, + TotalWeight={ + 'Value': total_weight, + 'WeightUnit': 'lb', + }, + ) + errors = response['body']['ResponseInformation']['Errors'] + if errors: + return { + 'shipments': False, + 'error': '\n'.join(['%s: %s' % (error['Code'], error['Description']) for error in errors['Error']]), + } + shipments = response['body']['ShipmentEstimates']['ShipmentEstimate'] + if shipments: + return { + 'shipments': shipments, + 'error': False, + } + return { + 'shipments': False, + 'error': 'Purolator service did not return any matching rates.', + } + + def shipment_request(self): + return self._shipment_request(self.shipping_factory) + + # just like above, but using estimate api + def estimate_shipment_request(self): + return self._shipment_request(self.estimating_factory) + + def _shipment_request(self, factory): + shipment = factory.Shipment() + shipment.SenderInformation = factory.SenderInformation() + shipment.SenderInformation.Address = factory.Address() + shipment.SenderInformation.Address.PhoneNumber = factory.PhoneNumber() + shipment.ReceiverInformation = factory.ReceiverInformation() + shipment.ReceiverInformation.Address = factory.Address() + shipment.ReceiverInformation.Address.PhoneNumber = factory.PhoneNumber() + shipment.PackageInformation = factory.PackageInformation() + shipment.PackageInformation.TotalWeight = factory.TotalWeight() + shipment.PackageInformation.PiecesInformation = factory.ArrayOfPiece() + shipment.PaymentInformation = factory.PaymentInformation() + return shipment + + def _add_piece_code(self, factory, piece, code): + # note that we ONLY support special handling type + if not piece.Options: + piece.Options = factory.ArrayOfOptionIDValuePair() + piece.Options.OptionIDValuePair.append(factory.OptionIDValuePair( + ID='SpecialHandling', + Value='true', + )) + piece.Options.OptionIDValuePair.append(factory.OptionIDValuePair( + ID='SpecialHandlingType', + Value=code, + )) + + def estimate_shipment_add_sale_order_packages(self, shipment, carrier, order): + # this could be a non-purolator package type as returned by the search functions + package_type = carrier.get_package_type_for_order(order) + total_pieces = carrier.get_package_count_for_order(order, package_type) + + package_type_codes = [t.strip() for t in (package_type.shipper_package_code or '').split(',') if t.strip() in PUROLATOR_PIECE_SPECIAL_HANDLING_TYPE] + shipment.PackageInformation.ServiceID = carrier.purolator_service_type + total_weight_value = carrier.purolator_convert_weight(order._get_estimated_weight()) + package_weight = total_weight_value / total_pieces + if total_weight_value < 1.0: + total_weight_value = 1.0 + if package_weight < 1.0: + package_weight = 1.0 + + for _i in range(total_pieces): + p = self.estimating_factory.Piece( + Weight={ + 'Value': str(package_weight), + 'WeightUnit': 'lb', + }, + Length={ + 'Value': str(package_type.packaging_length), # TODO need conversion + 'DimensionUnit': 'in', + }, + Width={ + 'Value': str(package_type.width), # TODO need conversion + 'DimensionUnit': 'in', + }, + Height={ + 'Value': str(package_type.height), # TODO need conversion + 'DimensionUnit': 'in', + }, + ) + for package_code in package_type_codes: + self._add_piece_code(self.estimating_factory, p, package_code) + + shipment.PackageInformation.PiecesInformation.Piece.append(p) + shipment.PackageInformation.TotalWeight.Value = str(total_weight_value) + shipment.PackageInformation.TotalWeight.WeightUnit = 'lb' + shipment.PackageInformation.TotalPieces = str(total_pieces) + + def estimate_shipment_add_picking_packages(self, shipment, carrier, picking, packages): + return self._shipment_add_picking_packages(self.estimating_factory, shipment, carrier, picking, packages) + + def shipment_add_picking_packages(self, shipment, carrier, picking, packages): + return self._shipment_add_picking_packages(self.shipping_factory, shipment, carrier, picking, packages) + + def _shipment_add_picking_packages(self, factory, shipment, carrier, picking, packages): + # note that no package can be less than 1lb, so we fix that here... + # for the package to be allowed, it must be the same service + shipment.PackageInformation.ServiceID = carrier.purolator_service_type + + total_weight_value = 0.0 + total_pieces = len(packages or []) or 1 + if not packages: + # setup default package + package_weight = carrier.purolator_convert_weight(picking.shipping_weight) + if package_weight < 1.0: + package_weight = 1.0 + total_weight_value += package_weight + package_type = carrier.purolator_default_package_type_id + package_type_codes = [t.strip() for t in (package_type.shipper_package_code or '').split(',') if t.strip() in PUROLATOR_PIECE_SPECIAL_HANDLING_TYPE] + p = factory.Piece( + Weight={ + 'Value': str(package_weight), + 'WeightUnit': 'lb', + }, + Length={ + 'Value': str(package_type.packaging_length), # TODO need conversion + 'DimensionUnit': 'in', + }, + Width={ + 'Value': str(package_type.width), # TODO need conversion + 'DimensionUnit': 'in', + }, + Height={ + 'Value': str(package_type.height), # TODO need conversion + 'DimensionUnit': 'in', + }, + ) + for package_code in package_type_codes: + self._add_piece_code(factory, p, package_code) + + shipment.PackageInformation.PiecesInformation.Piece.append(p) + else: + for package in packages: + package_weight = carrier.purolator_convert_weight(package.shipping_weight) + if package_weight < 1.0: + package_weight = 1.0 + package_type = package.package_type_id + package_type_code = package_type.shipper_package_code or '' + if package_type.package_carrier_type != 'purolator': + package_type_code = carrier.purolator_default_package_type_id.shipper_package_code or '' + package_type_codes = [t.strip() for t in package_type_code.split(',') if t.strip() in PUROLATOR_PIECE_SPECIAL_HANDLING_TYPE] + + total_weight_value += package_weight + p = factory.Piece( + Weight={ + 'Value': str(package_weight), + 'WeightUnit': 'lb', + }, + Length={ + 'Value': str(package_type.packaging_length), # TODO need conversion + 'DimensionUnit': 'in', + }, + Width={ + 'Value': str(package_type.width), # TODO need conversion + 'DimensionUnit': 'in', + }, + Height={ + 'Value': str(package_type.height), # TODO need conversion + 'DimensionUnit': 'in', + }, + ) + for package_code in package_type_codes: + self._add_piece_code(factory, p, package_code) + + shipment.PackageInformation.PiecesInformation.Piece.append(p) + + shipment.PackageInformation.TotalWeight.Value = str(total_weight_value) + shipment.PackageInformation.TotalWeight.WeightUnit = 'lb' + shipment.PackageInformation.TotalPieces = str(total_pieces) + + def shipment_create(self, shipment, printer_type='Thermal'): + response = self.shipping_client.service.CreateShipment( + Shipment=shipment, + PrinterType=printer_type, + ) + return response.body + + def shipment_void(self, pin): + response = self.shipping_client.service.VoidShipment( + PIN={'Value': pin} + ) + return response.body + + def document_by_pin(self, pin, document_type='', output_type='ZPL'): + # TODO document_type? + document_criterium = self.shipping_documents_factory.ArrayOfDocumentCriteria() + document_criterium.DocumentCriteria.append(self.shipping_documents_factory.DocumentCriteria( + PIN=pin, + )) + response = self.shipping_documents_client.service.GetDocuments( + DocumentCriterium=document_criterium, + OutputType=output_type, + Synchronous=True, + ) + return response.body diff --git a/delivery_purolator/models/stock_package_type.py b/delivery_purolator/models/stock_package_type.py new file mode 100644 index 00000000..868e16fc --- /dev/null +++ b/delivery_purolator/models/stock_package_type.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class PackageType(models.Model): + _inherit = 'stock.package.type' + + package_carrier_type = fields.Selection(selection_add=[('purolator', 'Purolator')]) diff --git a/delivery_purolator/tests/__init__.py b/delivery_purolator/tests/__init__.py new file mode 100644 index 00000000..e35bb8ef --- /dev/null +++ b/delivery_purolator/tests/__init__.py @@ -0,0 +1 @@ +from . import test_purolator diff --git a/delivery_purolator/tests/test_purolator.py b/delivery_purolator/tests/test_purolator.py new file mode 100644 index 00000000..f70295fa --- /dev/null +++ b/delivery_purolator/tests/test_purolator.py @@ -0,0 +1,147 @@ + +from odoo.tests.common import Form, TransactionCase +from odoo.exceptions import UserError + + +class TestPurolator(TransactionCase): + def setUp(self): + super().setUp() + self.carrier = self.env.ref('delivery_purolator.purolator_ground', raise_if_not_found=False) + if not self.carrier or not self.carrier.purolator_api_key: + self.skipTest('Purolator Shipping not configured, skipping tests.') + if self.carrier.prod_environment: + self.skipTest('Purolator Shipping configured to use production credentials, skipping tests.') + + # the setup for these addresses is important as there is + # error handling on purolator's side + self.state_ca_ontario = self.env.ref('base.state_ca_on') + self.country_ca = self.state_ca_ontario.country_id + + self.shipper_partner = self.env['res.partner'].create({ + 'name': 'The Great North Ltd.', + 'zip': 'L4W5M8', + 'street': '1234 Test St.', + 'state_id': self.state_ca_ontario.id, + 'country_id': self.country_ca.id, + 'city': 'Mississauga', # note other city will return error for this field+zip + }) + self.shipper_warehouse = self.env['stock.warehouse'].create({ + 'partner_id': self.shipper_partner.id, + 'name': 'Canadian Warehouse', + 'code': 'CWH', + }) + self.receiver_partner = self.env['res.partner'].create({ + 'name': 'Receiver Address', + 'city': 'Burnaby', + 'street': '1234 Test Rd.', + 'state_id': self.ref('base.state_ca_bc'), + 'country_id': self.ref('base.ca'), + 'zip': 'V5C5A9', + }) + self.storage_box = self.env.ref('product.product_product_6') + self.storage_box.weight = 1.5 # Something more reasonable + # Make some available + self.env['stock.quant']._update_available_quantity(self.storage_box, self.shipper_warehouse.lot_stock_id, 100) + self.sale_order = self.env['sale.order'].create({ + 'partner_id': self.receiver_partner.id, + 'warehouse_id': self.shipper_warehouse.id, + 'order_line': [(0, 0, { + 'name': self.storage_box.name, + 'product_id': self.storage_box.id, + 'product_uom_qty': 3.0, + 'product_uom': self.storage_box.uom_id.id, + 'price_unit': self.storage_box.lst_price, + })], + }) + + # reconfigure this method so that we can set its default package to one that needs a service code + self.delivery_carrier_ground = self.env.ref('delivery_purolator.purolator_ground') + self.delivery_carrier_ground.purolator_default_package_type_id = self.env.ref('delivery_purolator.purolator_packaging_large_package') + # set a VERY low requirement for signature + self.delivery_carrier_ground.automatic_insurance_value = 0.1 + self.delivery_carrier_ground.automatic_sig_req_value = 0.1 + + def _so_pick_shipping(self): + # Regular Update Shipping functionality + delivery_wizard = Form(self.env['choose.delivery.carrier'].with_context({ + 'default_order_id': self.sale_order.id, + 'default_carrier_id': self.ref('delivery_purolator.purolator_ground'), + })) + choose_delivery_carrier = delivery_wizard.save() + choose_delivery_carrier.update_price() + self.assertGreater(choose_delivery_carrier.delivery_price, 0.0, "Purolator delivery cost for this SO has not been correctly estimated.") + choose_delivery_carrier.button_confirm() + self.assertEqual(self.sale_order.carrier_id, self.carrier) + + def test_00_rate_order(self): + self._so_pick_shipping() + + # Multi-rating with sale order + rates = self.carrier.rate_shipment_multi(order=self.sale_order) + carrier_express = self.env.ref('delivery_purolator.purolator_express') + rate_express = list(filter(lambda r: r['carrier'] == carrier_express, rates)) + rate_express = rate_express and rate_express[0] + self.assertFalse(rate_express['error_message']) + self.assertGreater(rate_express['price'], 0.0) + self.assertGreater(rate_express['transit_days'], 0) + self.assertEqual(rate_express['package'], self.env['stock.quant.package'].browse()) + + # Multi-rating with picking + self.sale_order.action_confirm() + picking = self.sale_order.picking_ids + self.assertEqual(len(picking), 1) + rates = self.carrier.rate_shipment_multi(picking=picking) + rate_express = list(filter(lambda r: r['carrier'] == carrier_express, rates)) + rate_express = rate_express and rate_express[0] + self.assertFalse(rate_express['error_message']) + self.assertGreater(rate_express['price'], 0.0) + self.assertGreater(rate_express['transit_days'], 0) + self.assertEqual(rate_express['package'], self.env['stock.quant.package'].browse()) + + # Multi-rate package + self.assertEqual(picking.move_lines.reserved_availability, 3.0) + picking.move_line_ids.qty_done = 1.0 + context = dict( + current_package_carrier_type=picking.carrier_id.delivery_type, + default_picking_id=picking.id + ) + choose_package_wizard = self.env['choose.delivery.package'].with_context(context).create({}) + self.assertEqual(choose_package_wizard.shipping_weight, 1.5) + choose_package_wizard.action_put_in_pack() + package = picking.move_line_ids.mapped('result_package_id') + self.assertEqual(len(package), 1) + + rates = self.carrier.rate_shipment_multi(picking=picking, packages=package) + rate_express = list(filter(lambda r: r['carrier'] == carrier_express, rates)) + rate_express = rate_express and rate_express[0] + self.assertFalse(rate_express['error_message']) + self.assertGreater(rate_express['price'], 0.0) + self.assertGreater(rate_express['transit_days'], 0) + self.assertEqual(rate_express['package'], package) + + def test_20_shipping(self): + self._so_pick_shipping() + self.sale_order.action_confirm() + picking = self.sale_order.picking_ids + self.assertEqual(picking.carrier_id, self.carrier) + self.assertEqual(picking.message_attachment_count, 0) + + # Test Error handling: + # Not having a city will result in an error + original_shipper_partner_city = self.shipper_partner.city + self.shipper_partner.city = '' + with self.assertRaises(UserError): + picking.send_to_shipper() + self.shipper_partner.city = original_shipper_partner_city + + # Basic case: no qty done or packages or anything at all really + # it makes sense to be able to do 'something' in this case + picking.carrier_price = 50.0 + picking.send_to_shipper() + self.assertTrue(picking.carrier_tracking_ref) + self.assertEqual(picking.message_attachment_count, 1) # has tracking label now + self.assertEqual(picking.carrier_price, 50.0) # price is set during planning and should remain unchanged + + # Void + picking.cancel_shipment() + self.assertFalse(picking.carrier_tracking_ref) diff --git a/delivery_purolator/views/delivery_purolator_views.xml b/delivery_purolator/views/delivery_purolator_views.xml new file mode 100644 index 00000000..413823e0 --- /dev/null +++ b/delivery_purolator/views/delivery_purolator_views.xml @@ -0,0 +1,28 @@ + + + + + delivery.carrier.form.provider.purolator + delivery.carrier + + + + + + + + + + + + + + + + + + + + + + diff --git a/delivery_purolator_planner_price/__init__.py b/delivery_purolator_planner_price/__init__.py new file mode 100644 index 00000000..455a4c33 --- /dev/null +++ b/delivery_purolator_planner_price/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import wizard diff --git a/delivery_purolator_planner_price/__manifest__.py b/delivery_purolator_planner_price/__manifest__.py new file mode 100644 index 00000000..28cd1a2b --- /dev/null +++ b/delivery_purolator_planner_price/__manifest__.py @@ -0,0 +1,28 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Purolator Planner Price', + 'summary': 'Use estimated delivery cost when label is created.', + 'version': '15.0.1.0.1', + 'author': "Hibou Corp.", + 'category': 'Warehouse', + 'license': 'OPL-1', + 'images': [], + 'website': "https://hibou.io", + 'description': """ +Purolator Planner Price +======================= + +* Uses estimated delivery cost when label is created. +""", + 'depends': [ + 'delivery_purolator', + 'stock_delivery_planner', + ], + 'demo': [ + ], + 'data': [ + ], + 'auto_install': True, + 'installable': True, +} diff --git a/delivery_purolator_planner_price/tests/__init__.py b/delivery_purolator_planner_price/tests/__init__.py new file mode 100644 index 00000000..04c314a3 --- /dev/null +++ b/delivery_purolator_planner_price/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import test_delivery_purolator_planner_price diff --git a/delivery_purolator_planner_price/tests/test_delivery_purolator_planner_price.py b/delivery_purolator_planner_price/tests/test_delivery_purolator_planner_price.py new file mode 100644 index 00000000..ea098d72 --- /dev/null +++ b/delivery_purolator_planner_price/tests/test_delivery_purolator_planner_price.py @@ -0,0 +1,109 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.tests.common import Form, TransactionCase + + +class TestDeliveryPurolatorPlannerPrice(TransactionCase): + def setUp(self): + super().setUp() + self.carrier = self.env.ref('delivery_purolator.purolator_ground', raise_if_not_found=False) + if not self.carrier or not self.carrier.purolator_api_key: + self.skipTest('Purolator Shipping not configured, skipping tests.') + if self.carrier.prod_environment: + self.skipTest('Purolator Shipping configured to use production credentials, skipping tests.') + + # Order planner setup + self.env['ir.config_parameter'].sudo().set_param('sale.planner.carrier_ids.%s' % (self.env.company.id, ), + "%d" % self.carrier.id) + self.env['ir.config_parameter'].sudo().set_param('stock.delivery.planner.carrier_ids.%s' % (self.env.company.id, ), + "%d" % self.carrier.id) + delivery_calendar = self.env['resource.calendar'].create({ + 'name': 'Test Delivery Calendar', + 'tz': 'US/Central', + 'attendance_ids': [ + (0, 0, {'name': 'Monday', 'dayofweek': '0', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday', 'dayofweek': '1', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday', 'dayofweek': '2', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday', 'dayofweek': '3', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday', 'dayofweek': '4', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + ], + }) + self.carrier.delivery_calendar_id = delivery_calendar + # self.fedex_2_day.delivery_calendar_id = delivery_calendar + # self.env['stock.warehouse'].search([]).write({'shipping_calendar_id': delivery_calendar.id}) + + # the setup for these addresses is important as there is + # error handling on purolator's side + self.state_ca_ontario = self.env.ref('base.state_ca_on') + self.country_ca = self.state_ca_ontario.country_id + + self.shipper_partner = self.env['res.partner'].create({ + 'name': 'The Great North Ltd.', + 'zip': 'L4W5M8', + 'street': '1234 Test St.', + 'state_id': self.state_ca_ontario.id, + 'country_id': self.country_ca.id, + 'city': 'Mississauga', # note other city will return error for this field+zip + }) + self.shipper_warehouse = self.env['stock.warehouse'].create({ + 'partner_id': self.shipper_partner.id, + 'name': 'Canadian Warehouse', + 'code': 'CWH', + 'shipping_calendar_id': delivery_calendar.id, + }) + self.env['ir.config_parameter'].sudo().set_param('sale.planner.warehouse_ids.%s' % (self.env.company.id, ), + "%d" % self.shipper_warehouse.id) + self.receiver_partner = self.env['res.partner'].create({ + 'name': 'Receiver Address', + 'city': 'Burnaby', + 'street': '1234 Test Rd.', + 'state_id': self.ref('base.state_ca_bc'), + 'country_id': self.ref('base.ca'), + 'zip': 'V5C5A9', + }) + self.storage_box = self.env.ref('product.product_product_6') + self.storage_box.weight = 1.0 # Something more reasonable + # Make some available + self.env['stock.quant']._update_available_quantity(self.storage_box, self.shipper_warehouse.lot_stock_id, 100) + self.sale_order = self.env['sale.order'].create({ + 'partner_id': self.receiver_partner.id, + 'warehouse_id': self.shipper_warehouse.id, + 'order_line': [(0, 0, { + 'name': self.storage_box.name, + 'product_id': self.storage_box.id, + 'product_uom_qty': 3.0, + 'product_uom': self.storage_box.uom_id.id, + 'price_unit': self.storage_box.lst_price, + })], + }) + order_plan_action = self.sale_order.action_planorder() + order_plan = self.env[order_plan_action['res_model']].browse(order_plan_action['res_id']) + order_plan.planning_option_ids.filtered(lambda o: o.carrier_id == self.carrier).select_plan() + + self.sale_order.action_confirm() + self.picking = self.sale_order.picking_ids + + def test_00_estimate_shipping_cost(self): + self.assertEqual(self.picking.carrier_id, self.carrier, 'Carrier did not carry over to Delivery Order') + + self.picking.move_line_ids.filtered(lambda ml: ml.product_id == self.storage_box).qty_done = 3.0 + packing_action = self.picking.action_put_in_pack() + packing_wizard = Form(self.env[packing_action['res_model']].with_context(packing_action['context'])) + choose_delivery_package = packing_wizard.save() + choose_delivery_package.action_put_in_pack() + self.assertEqual(self.picking.shipping_weight, 3.0) + + action = self.picking.action_plan_delivery() + planner = self.env[action['res_model']].browse(action['res_id']) + + self.assertEqual(planner.picking_id, self.picking) + self.assertGreater(len(planner.plan_option_ids), 1) + + plan_option = planner.plan_option_ids.filtered(lambda o: o.carrier_id == self.carrier) + self.assertEqual(len(plan_option), 1) + self.assertGreater(plan_option.price, 0.0) + + plan_option.select_plan() + planner.action_plan() + self.assertEqual(self.picking.carrier_id, self.carrier) + self.assertEqual(plan_option.price, self.picking.carrier_price) diff --git a/delivery_purolator_planner_price/wizard/__init__.py b/delivery_purolator_planner_price/wizard/__init__.py new file mode 100644 index 00000000..09cfe093 --- /dev/null +++ b/delivery_purolator_planner_price/wizard/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import stock_delivery_planner diff --git a/delivery_purolator_planner_price/wizard/stock_delivery_planner.py b/delivery_purolator_planner_price/wizard/stock_delivery_planner.py new file mode 100644 index 00000000..43af07f7 --- /dev/null +++ b/delivery_purolator_planner_price/wizard/stock_delivery_planner.py @@ -0,0 +1,18 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import models + + +class StockDeliveryPlanner(models.TransientModel): + _inherit = 'stock.delivery.planner' + + def action_plan(self): + res = super().action_plan() + puro_package_options = self.plan_option_ids.filtered( + lambda o: (o.package_id + and o.selection == 'selected' + and o.carrier_id.delivery_type == 'purolator' + )) + if puro_package_options: + self.picking_id.carrier_price = sum(puro_package_options.mapped('price')) + return res