mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch 'new/15.0/delivery_purolator_planner_price' into '15.0'
WIP: new/15.0/delivery_purolator_planner_price into 15.0 See merge request hibou-io/hibou-odoo/suite!1509
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
from math import ceil
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.tools.float_utils import float_compare
|
from odoo.tools.float_utils import float_compare
|
||||||
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
|
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DeliveryCarrier(models.Model):
|
class DeliveryCarrier(models.Model):
|
||||||
_inherit = 'delivery.carrier'
|
_inherit = 'delivery.carrier'
|
||||||
|
|
||||||
@@ -17,8 +22,85 @@ class DeliveryCarrier(models.Model):
|
|||||||
string='Procurement Priority',
|
string='Procurement Priority',
|
||||||
help='Priority for this carrier. Will affect pickings '
|
help='Priority for this carrier. Will affect pickings '
|
||||||
'and procurements related to this carrier.')
|
'and procurements related to this carrier.')
|
||||||
|
package_by_field = fields.Selection([
|
||||||
|
('', 'Use Default Package Type'),
|
||||||
|
('weight', 'Weight'),
|
||||||
|
('volume', 'Volume'),
|
||||||
|
], string='Packaging by Product Field')
|
||||||
|
|
||||||
|
# Package selection
|
||||||
|
def get_package_type_for_order(self, order):
|
||||||
|
if self.package_by_field == 'weight':
|
||||||
|
res = self._get_package_type_for_order(order, 'max_weight', 'weight')
|
||||||
|
_logger.info(' get_package_type_for_order package by weight (%s) %s' % (res.id, res.name))
|
||||||
|
return res
|
||||||
|
elif self.package_by_field == 'volume':
|
||||||
|
res = self._get_package_type_for_order(order, 'package_volume', 'volume')
|
||||||
|
_logger.info(' get_package_type_for_order package by volume (%s) %s' % (res.id, res.name))
|
||||||
|
return res
|
||||||
|
attr = getattr(self, '%s_default_packaging_id' % (self.delivery_type, ), None)
|
||||||
|
if attr:
|
||||||
|
_logger.info(' get_package_type_for_order package by default_packaging_id (%s) %s' % (attr.id, attr.name))
|
||||||
|
return attr
|
||||||
|
attr = getattr(self, '%s_default_package_type_id' % (self.delivery_type, ), None)
|
||||||
|
if attr:
|
||||||
|
_logger.info(' get_package_type_for_order package by default_package_type_id (%s) %s' % (attr.id, attr.name))
|
||||||
|
return attr
|
||||||
|
_logger.info(' package by NULL')
|
||||||
|
return self.env['stock.package.type']
|
||||||
|
|
||||||
|
def get_package_count_for_order(self, order, package_type=None):
|
||||||
|
if package_type is None:
|
||||||
|
package_type = self.get_package_type_for_order(order)
|
||||||
|
|
||||||
|
if self.package_by_field == 'volume':
|
||||||
|
return self._get_package_count_for_order(order, package_type, 'package_volume', 'volume')
|
||||||
|
return self._get_package_count_for_order(order, package_type, 'max_weight', 'weight')
|
||||||
|
|
||||||
|
def _get_package_type_for_order(self, order, package_type_field, product_field):
|
||||||
|
# NOTE do not optimize this into non-loop.
|
||||||
|
# this may be an orderfake
|
||||||
|
order_total = 0.0
|
||||||
|
for ol in order.order_line.filtered(lambda ol: ol.product_id.type in ('product', 'consu')):
|
||||||
|
order_total += ol.product_id[product_field] * ol.product_uom_qty
|
||||||
|
_logger.info(' _get_package_type_for_order order_total ' + str(order_total))
|
||||||
|
if order_total:
|
||||||
|
package_types = self.env['stock.package.type'].search([
|
||||||
|
('package_carrier_type', 'in', ('none', False, self.delivery_type)),
|
||||||
|
('use_in_package_selection', '=', True),
|
||||||
|
], order=package_type_field)
|
||||||
|
package_type = None
|
||||||
|
for package_type in package_types:
|
||||||
|
if package_type[package_type_field] >= order_total:
|
||||||
|
return package_type
|
||||||
|
return package_types if not package_type else package_type
|
||||||
|
return self.env['stock.package.type']
|
||||||
|
|
||||||
|
def _get_package_count_for_order(self, order, package_type, package_type_field, product_field):
|
||||||
|
# NOTE do not optimize this into non-loop.
|
||||||
|
# this may be an orderfake
|
||||||
|
order_total = 0.0
|
||||||
|
for ol in order.order_line.filtered(lambda ol: ol.product_id.type in ('product', 'consu')):
|
||||||
|
order_total += ol.product_id[product_field] * ol.product_uom_qty
|
||||||
|
package_type_field_value = package_type[package_type_field]
|
||||||
|
if not package_type_field_value or package_type_field_value >= order_total:
|
||||||
|
return 1
|
||||||
|
return ceil(order_total / package_type_field_value)
|
||||||
|
|
||||||
# Utility
|
# Utility
|
||||||
|
def get_to_ship_picking_packages(self, picking):
|
||||||
|
# Will return a stock.quant.package record set if the picking has packages
|
||||||
|
# in the case of multi-packing and none applicable, will return None
|
||||||
|
# Additionally, will not return packages that have a tracking number (because they have shipped)
|
||||||
|
picking_packages = picking.package_ids
|
||||||
|
package_carriers = picking_packages.mapped('carrier_id')
|
||||||
|
if package_carriers:
|
||||||
|
# only ship ours
|
||||||
|
picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref)
|
||||||
|
|
||||||
|
if package_carriers and not picking_packages:
|
||||||
|
return None
|
||||||
|
return picking_packages
|
||||||
|
|
||||||
def get_insurance_value(self, order=None, picking=None, package=None):
|
def get_insurance_value(self, order=None, picking=None, package=None):
|
||||||
value = 0.0
|
value = 0.0
|
||||||
@@ -142,7 +224,7 @@ class DeliveryCarrier(models.Model):
|
|||||||
|
|
||||||
def _get_shipper_warehouse_dropship_in(self, picking):
|
def _get_shipper_warehouse_dropship_in(self, picking):
|
||||||
if picking.sale_id:
|
if picking.sale_id:
|
||||||
picking.sale_id.partner_shipping_id
|
return picking.sale_id.partner_shipping_id
|
||||||
return self._get_shipper_warehouse_dropship_in_no_sale(picking)
|
return self._get_shipper_warehouse_dropship_in_no_sale(picking)
|
||||||
|
|
||||||
def _get_shipper_warehouse_dropship_in_no_sale(self, picking):
|
def _get_shipper_warehouse_dropship_in_no_sale(self, picking):
|
||||||
@@ -223,7 +305,7 @@ class DeliveryCarrier(models.Model):
|
|||||||
else:
|
else:
|
||||||
if packages:
|
if packages:
|
||||||
raise UserError(_('Cannot rate package without picking.'))
|
raise UserError(_('Cannot rate package without picking.'))
|
||||||
self = self.with_context(date_planned=(order.date_planned or fields.Datetime.now()))
|
self = self.with_context(date_planned=('date_planned' in self.env['sale.order']._fields and order.date_planned or fields.Datetime.now()))
|
||||||
|
|
||||||
res = []
|
res = []
|
||||||
for carrier in self:
|
for carrier in self:
|
||||||
@@ -232,16 +314,16 @@ class DeliveryCarrier(models.Model):
|
|||||||
p.package_type_id.package_carrier_type in (False, '', 'none', carrier.delivery_type))
|
p.package_type_id.package_carrier_type in (False, '', 'none', carrier.delivery_type))
|
||||||
if packages and not carrier_packages:
|
if packages and not carrier_packages:
|
||||||
continue
|
continue
|
||||||
if hasattr(carrier, '%s_rate_shipment_multi' % self.delivery_type):
|
attr = getattr(carrier, '%s_rate_shipment_multi' % self.delivery_type, None)
|
||||||
|
if attr:
|
||||||
try:
|
try:
|
||||||
res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order,
|
res += attr(order=order, picking=picking, packages=carrier_packages)
|
||||||
picking=picking,
|
|
||||||
packages=carrier_packages)
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# TODO remove catch if after Odoo 14
|
# TODO remove catch if after Odoo 14
|
||||||
# This is intended to find ones that don't support packages= kwarg
|
# This is intended to find ones that don't support packages= kwarg
|
||||||
res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order,
|
res2 = attr(order=order, picking=picking)
|
||||||
picking=picking)
|
if res2:
|
||||||
|
res += res2
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@@ -252,11 +334,12 @@ class DeliveryCarrier(models.Model):
|
|||||||
:param packages: Optional recordset of packages (should be for this carrier)
|
:param packages: Optional recordset of packages (should be for this carrier)
|
||||||
'''
|
'''
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if hasattr(self, '%s_cancel_shipment' % self.delivery_type):
|
attr = getattr(self, '%s_cancel_shipment' % self.delivery_type, None)
|
||||||
|
if attr:
|
||||||
# No good way to tell if this method takes the kwarg for packages
|
# No good way to tell if this method takes the kwarg for packages
|
||||||
if packages:
|
if packages:
|
||||||
try:
|
try:
|
||||||
return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings, packages=packages)
|
return attr(pickings, packages=packages)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# we won't be able to cancel the packages properly
|
# we won't be able to cancel the packages properly
|
||||||
# here we will TRY to make a good call here where we put the package references into the picking
|
# here we will TRY to make a good call here where we put the package references into the picking
|
||||||
@@ -267,7 +350,7 @@ class DeliveryCarrier(models.Model):
|
|||||||
'carrier_tracking_ref': tracking_ref,
|
'carrier_tracking_ref': tracking_ref,
|
||||||
})
|
})
|
||||||
|
|
||||||
return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings)
|
return attr(pickings)
|
||||||
|
|
||||||
|
|
||||||
class ChooseDeliveryPackage(models.TransientModel):
|
class ChooseDeliveryPackage(models.TransientModel):
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ from odoo import api, fields, models, _
|
|||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class StockPackageType(models.Model):
|
||||||
|
_inherit = 'stock.package.type'
|
||||||
|
|
||||||
|
use_in_package_selection = fields.Boolean()
|
||||||
|
package_volume = fields.Float(compute='_compute_package_volume', store=True)
|
||||||
|
|
||||||
|
@api.depends('packaging_length', 'width', 'height')
|
||||||
|
def _compute_package_volume(self):
|
||||||
|
for pt in self:
|
||||||
|
pt.package_volume = pt.packaging_length * pt.width * pt.height
|
||||||
|
|
||||||
|
|
||||||
class StockQuantPackage(models.Model):
|
class StockQuantPackage(models.Model):
|
||||||
_inherit = 'stock.quant.package'
|
_inherit = 'stock.quant.package'
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ class TestDeliveryHibou(common.TransactionCase):
|
|||||||
super(TestDeliveryHibou, self).setUp()
|
super(TestDeliveryHibou, self).setUp()
|
||||||
self.partner = self.env.ref('base.res_partner_address_13')
|
self.partner = self.env.ref('base.res_partner_address_13')
|
||||||
self.product = self.env.ref('product.product_product_7')
|
self.product = self.env.ref('product.product_product_7')
|
||||||
|
self.product.write({
|
||||||
|
'type': 'product',
|
||||||
|
'weight': 1.0,
|
||||||
|
'volume': 15.0,
|
||||||
|
})
|
||||||
# Create Shipping Account
|
# Create Shipping Account
|
||||||
self.shipping_account = self.env['partner.shipping.account'].create({
|
self.shipping_account = self.env['partner.shipping.account'].create({
|
||||||
'name': '123123',
|
'name': '123123',
|
||||||
@@ -20,6 +25,26 @@ class TestDeliveryHibou(common.TransactionCase):
|
|||||||
self.carrier = self.env['delivery.carrier'].create({
|
self.carrier = self.env['delivery.carrier'].create({
|
||||||
'name': 'Test Carrier1',
|
'name': 'Test Carrier1',
|
||||||
'product_id': self.delivery_product.id,
|
'product_id': self.delivery_product.id,
|
||||||
|
'delivery_type': 'fixed',
|
||||||
|
})
|
||||||
|
# update all other package types to have
|
||||||
|
self.package_type_large = self.env['stock.package.type'].create({
|
||||||
|
'name': 'Large 15x15x15',
|
||||||
|
'packaging_length': 15.0,
|
||||||
|
'height': 15.0,
|
||||||
|
'width': 15.0,
|
||||||
|
'max_weight': 50.0,
|
||||||
|
'package_carrier_type': 'none',
|
||||||
|
'use_in_package_selection': True,
|
||||||
|
})
|
||||||
|
self.package_type_small = self.env['stock.package.type'].create({
|
||||||
|
'name': 'Small 2x2x4',
|
||||||
|
'packaging_length': 4.0,
|
||||||
|
'height': 2.0,
|
||||||
|
'width': 2.0,
|
||||||
|
'max_weight': 1.0,
|
||||||
|
'package_carrier_type': 'none',
|
||||||
|
'use_in_package_selection': True,
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_delivery_hibou(self):
|
def test_delivery_hibou(self):
|
||||||
@@ -40,6 +65,7 @@ class TestDeliveryHibou(common.TransactionCase):
|
|||||||
'shipping_account_id': self.shipping_account.id,
|
'shipping_account_id': self.shipping_account.id,
|
||||||
'order_line': [(0, 0, {
|
'order_line': [(0, 0, {
|
||||||
'product_id': self.product.id,
|
'product_id': self.product.id,
|
||||||
|
'product_uom_qty': 2.0,
|
||||||
})]
|
})]
|
||||||
})
|
})
|
||||||
self.assertFalse(sale_order.carrier_id)
|
self.assertFalse(sale_order.carrier_id)
|
||||||
@@ -62,6 +88,35 @@ class TestDeliveryHibou(common.TransactionCase):
|
|||||||
self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account)
|
self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account)
|
||||||
self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account)
|
self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account)
|
||||||
|
|
||||||
|
# Test Package selection
|
||||||
|
default_package_type = sale_order.carrier_id.get_package_type_for_order(sale_order)
|
||||||
|
self.assertFalse(default_package_type, 'Fixed should not have a default packaging type.')
|
||||||
|
|
||||||
|
# by product weight
|
||||||
|
sale_order.carrier_id.package_by_field = 'weight'
|
||||||
|
|
||||||
|
default_package_type = sale_order.carrier_id.get_package_type_for_order(sale_order)
|
||||||
|
self.assertTrue(default_package_type)
|
||||||
|
self.assertEqual(default_package_type, self.package_type_large)
|
||||||
|
|
||||||
|
# change qty ordered to try to get the small package type
|
||||||
|
sale_order.order_line.write({
|
||||||
|
'product_uom_qty': 1.0,
|
||||||
|
})
|
||||||
|
default_package_type = sale_order.carrier_id.get_package_type_for_order(sale_order)
|
||||||
|
self.assertEqual(default_package_type, self.package_type_small)
|
||||||
|
|
||||||
|
# by product volume
|
||||||
|
sale_order.carrier_id.package_by_field = 'volume'
|
||||||
|
default_package_type = sale_order.carrier_id.get_package_type_for_order(sale_order)
|
||||||
|
self.assertEqual(default_package_type, self.package_type_small)
|
||||||
|
|
||||||
|
sale_order.order_line.write({
|
||||||
|
'product_uom_qty': 2.0,
|
||||||
|
})
|
||||||
|
default_package_type = sale_order.carrier_id.get_package_type_for_order(sale_order)
|
||||||
|
self.assertEqual(default_package_type, self.package_type_large)
|
||||||
|
|
||||||
# Test attn
|
# Test attn
|
||||||
test_ref = 'TEST100'
|
test_ref = 'TEST100'
|
||||||
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False)
|
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False)
|
||||||
|
|||||||
@@ -52,4 +52,17 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="stock_package_type_form" model="ir.ui.view">
|
||||||
|
<field name="name">hibou.stock.package.type.form</field>
|
||||||
|
<field name="model">stock.package.type</field>
|
||||||
|
<field name="inherit_id" ref="stock.stock_package_type_form" />
|
||||||
|
<field name="priority" eval="20" />
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='barcode']" position="after">
|
||||||
|
<field name="package_volume" />
|
||||||
|
<field name="use_in_package_selection" />
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
22
delivery_partner_purolator/README.rst
Normal file
22
delivery_partner_purolator/README.rst
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
*******************************************
|
||||||
|
Hibou - Purolator Partner Shipping Accounts
|
||||||
|
*******************************************
|
||||||
|
|
||||||
|
Adds Purolator shipping accounts.
|
||||||
|
|
||||||
|
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||||
|
|
||||||
|
|
||||||
|
=============
|
||||||
|
Main Features
|
||||||
|
=============
|
||||||
|
|
||||||
|
* Adds Purolator to the delivery type selection field.
|
||||||
|
|
||||||
|
=======
|
||||||
|
License
|
||||||
|
=======
|
||||||
|
|
||||||
|
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/15.0/LICENSE>`_.
|
||||||
|
|
||||||
|
Copyright Hibou Corp. 2022
|
||||||
1
delivery_partner_purolator/__init__.py
Normal file
1
delivery_partner_purolator/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
17
delivery_partner_purolator/__manifest__.py
Executable file
17
delivery_partner_purolator/__manifest__.py
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
'name': 'Purolator Partner Shipping Accounts',
|
||||||
|
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||||
|
'version': '15.0.1.0.0',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'category': 'Stock',
|
||||||
|
'sequence': 95,
|
||||||
|
'summary': 'Purolator Partner Shipping Accounts',
|
||||||
|
'website': 'https://hibou.io/',
|
||||||
|
'depends': [
|
||||||
|
'delivery_partner',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
}
|
||||||
1
delivery_partner_purolator/models/__init__.py
Normal file
1
delivery_partner_purolator/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import delivery
|
||||||
7
delivery_partner_purolator/models/delivery.py
Normal file
7
delivery_partner_purolator/models/delivery.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerShippingAccount(models.Model):
|
||||||
|
_inherit = 'partner.shipping.account'
|
||||||
|
|
||||||
|
delivery_type = fields.Selection(selection_add=[('purolator', 'Purolator')], ondelete={'purolator': 'set default'})
|
||||||
1
delivery_partner_purolator/tests/__init__.py
Normal file
1
delivery_partner_purolator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_purolator_account
|
||||||
20
delivery_partner_purolator/tests/test_purolator_account.py
Normal file
20
delivery_partner_purolator/tests/test_purolator_account.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccount(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestAccount, self).setUp()
|
||||||
|
self.PartnerShippingAccount = self.env['partner.shipping.account']
|
||||||
|
self.partner = self.env.ref('base.res_partner_12')
|
||||||
|
|
||||||
|
def test_purolator_account_information(self):
|
||||||
|
# Create object and confirm that validation error raises if fedex account is blank or not 8 digits
|
||||||
|
_ = self.PartnerShippingAccount.create({
|
||||||
|
'name': '123456789',
|
||||||
|
'description': 'Test Account',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'delivery_type': 'purolator',
|
||||||
|
'note': 'This is a note'
|
||||||
|
})
|
||||||
1
delivery_purolator/__init__.py
Normal file
1
delivery_purolator/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
29
delivery_purolator/__manifest__.py
Normal file
29
delivery_purolator/__manifest__.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
24
delivery_purolator/data/delivery_purolator_data.xml
Normal file
24
delivery_purolator/data/delivery_purolator_data.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="purolator_packaging_default" model="stock.package.type">
|
||||||
|
<field name="name">Purolator Default</field>
|
||||||
|
<field name="shipper_package_code" eval=""/>
|
||||||
|
<field name="package_carrier_type">purolator</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purolator_packaging_large_package" model="stock.package.type">
|
||||||
|
<field name="name">Purolator LargePackage</field>
|
||||||
|
<field name="shipper_package_code">LargePackage</field>
|
||||||
|
<field name="package_carrier_type">purolator</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purolator_packaging_flat_package" model="stock.package.type">
|
||||||
|
<field name="name">Purolator FlatPackage</field>
|
||||||
|
<field name="shipper_package_code">FlatPackage</field>
|
||||||
|
<field name="package_carrier_type">purolator</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
46
delivery_purolator/data/delivery_purolator_demo.xml
Normal file
46
delivery_purolator/data/delivery_purolator_demo.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Purolator Delivery Carriers -->
|
||||||
|
<!-- Requires working api key for tests to function, not provided -->
|
||||||
|
<record id="product_product_purolator_express" model="product.product">
|
||||||
|
<field name="name">Purolator Express</field>
|
||||||
|
<field name="default_code">Delivery_PurolatorExpress</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="categ_id" ref="delivery.product_category_deliveries"/>
|
||||||
|
<field name="sale_ok" eval="False"/>
|
||||||
|
<field name="purchase_ok" eval="False"/>
|
||||||
|
<field name="list_price">0.0</field>
|
||||||
|
<field name="invoice_policy">order</field>
|
||||||
|
</record>
|
||||||
|
<record id="purolator_express" model="delivery.carrier">
|
||||||
|
<field name="name">Purolator Express Test</field>
|
||||||
|
<field name="product_id" ref="delivery_purolator.product_product_purolator_express"/>
|
||||||
|
<field name="delivery_type">purolator</field>
|
||||||
|
<field name="purolator_service_type">PurolatorExpress</field>
|
||||||
|
<field name="prod_environment" eval="False"/>
|
||||||
|
<field name="purolator_default_package_type_id" ref="delivery_purolator.purolator_packaging_default"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_product_purolator_ground" model="product.product">
|
||||||
|
<field name="name">Purolator Ground</field>
|
||||||
|
<field name="default_code">Delivery_PurolatorGround</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="categ_id" ref="delivery.product_category_deliveries"/>
|
||||||
|
<field name="sale_ok" eval="False"/>
|
||||||
|
<field name="purchase_ok" eval="False"/>
|
||||||
|
<field name="list_price">0.0</field>
|
||||||
|
<field name="invoice_policy">order</field>
|
||||||
|
</record>
|
||||||
|
<record id="purolator_ground" model="delivery.carrier">
|
||||||
|
<field name="name">Purolator Ground Test</field>
|
||||||
|
<field name="product_id" ref="delivery_purolator.product_product_purolator_ground"/>
|
||||||
|
<field name="delivery_type">purolator</field>
|
||||||
|
<field name="purolator_service_type">PurolatorGround</field>
|
||||||
|
<field name="prod_environment" eval="False"/>
|
||||||
|
<field name="purolator_default_package_type_id" ref="delivery_purolator.purolator_packaging_default"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
2
delivery_purolator/models/__init__.py
Normal file
2
delivery_purolator/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import delivery_purolator
|
||||||
|
from . import stock_package_type
|
||||||
385
delivery_purolator/models/delivery_purolator.py
Normal file
385
delivery_purolator/models/delivery_purolator.py
Normal file
@@ -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 <br/> <b>Tracking Number/PIN : </b>%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})
|
||||||
323
delivery_purolator/models/purolator_services.py
Normal file
323
delivery_purolator/models/purolator_services.py
Normal file
@@ -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
|
||||||
7
delivery_purolator/models/stock_package_type.py
Normal file
7
delivery_purolator/models/stock_package_type.py
Normal file
@@ -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')])
|
||||||
1
delivery_purolator/tests/__init__.py
Normal file
1
delivery_purolator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_purolator
|
||||||
147
delivery_purolator/tests/test_purolator.py
Normal file
147
delivery_purolator/tests/test_purolator.py
Normal file
@@ -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)
|
||||||
28
delivery_purolator/views/delivery_purolator_views.xml
Normal file
28
delivery_purolator/views/delivery_purolator_views.xml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_delivery_carrier_form_with_provider_purolator" model="ir.ui.view">
|
||||||
|
<field name="name">delivery.carrier.form.provider.purolator</field>
|
||||||
|
<field name="model">delivery.carrier</field>
|
||||||
|
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@name='destination']" position='before'>
|
||||||
|
<page string="Purolator Configuration" name="purolator_configuration" attrs="{'invisible': [('delivery_type', '!=', 'purolator')]}">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="purolator_api_key" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
<field name="purolator_password" attrs="{'required': [('delivery_type', '=', 'purolator')]}" password="True"/>
|
||||||
|
<field name="purolator_activation_key" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
<field name="purolator_account_number" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
<field name="purolator_service_type" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
<field name="purolator_label_file_type" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
<field name="package_by_field" />
|
||||||
|
<field name="purolator_default_package_type_id" attrs="{'required': [('delivery_type', '=', 'purolator')]}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
3
delivery_purolator_planner_price/__init__.py
Normal file
3
delivery_purolator_planner_price/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import wizard
|
||||||
28
delivery_purolator_planner_price/__manifest__.py
Normal file
28
delivery_purolator_planner_price/__manifest__.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
3
delivery_purolator_planner_price/tests/__init__.py
Normal file
3
delivery_purolator_planner_price/tests/__init__.py
Normal file
@@ -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
|
||||||
@@ -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)
|
||||||
3
delivery_purolator_planner_price/wizard/__init__.py
Normal file
3
delivery_purolator_planner_price/wizard/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import stock_delivery_planner
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user