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