mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
[IMP] delivery_purolator: implement picking shipping, with tests
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
from odoo import fields, models, _
|
from odoo import fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
from .purolator_services import PurolatorClient
|
from .purolator_services import PurolatorClient
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PUROLATOR_SERVICES = [
|
PUROLATOR_SERVICES = [
|
||||||
('PurolatorExpress9AM', 'Purolator Express 9AM'),
|
('PurolatorExpress9AM', 'Purolator Express 9AM'),
|
||||||
('PurolatorExpress10:30AM', 'Purolator Express 10:30AM'),
|
('PurolatorExpress10:30AM', 'Purolator Express 10:30AM'),
|
||||||
@@ -211,26 +214,124 @@ class ProviderPurolator(models.Model):
|
|||||||
('purolator_account_number', '=', self.purolator_account_number),
|
('purolator_account_number', '=', self.purolator_account_number),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
return carrier
|
return carrier
|
||||||
|
|
||||||
|
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):
|
||||||
|
addr.Name = partner.name if not partner.is_company else ''
|
||||||
|
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
|
# Picking Shipping
|
||||||
def purolator_send_shipping(self, pickings):
|
def purolator_send_shipping(self, pickings):
|
||||||
res = []
|
res = []
|
||||||
# service = self._get_purolator_service()
|
service = self._purolator_service()
|
||||||
# had_customs = False
|
|
||||||
|
|
||||||
for picking in pickings:
|
for picking in pickings:
|
||||||
picking_packages = self.get_to_ship_picking_packages(picking)
|
picking_packages = self.get_to_ship_picking_packages(picking)
|
||||||
if picking_packages is None:
|
if picking_packages is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# do the shipment!
|
shipment = service.shipment_request()
|
||||||
package_labels = []
|
|
||||||
for x in []:
|
# populate origin information
|
||||||
res = res + [shipping_data] # bug! fill in with appropriate data
|
sender = self.get_shipper_warehouse(picking=picking)
|
||||||
picking.carrier_tracking_ref = ','.join(package_labels)
|
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)
|
||||||
|
|
||||||
|
# TODO package level signature and insurance....
|
||||||
|
# IF we cannot do this at the package level, then we must implement it here.
|
||||||
|
# We may need to warn that all packages will follow the same rule.
|
||||||
|
# //Define OptionsInformation
|
||||||
|
# //ResidentialSignatureDomestic
|
||||||
|
# $request->Shipment->PackageInformation->OptionsInformation->Options->OptionIDValuePair->ID = "ResidentialSignatureDomestic";
|
||||||
|
# $request->Shipment->PackageInformation->OptionsInformation->Options->OptionIDValuePair->Value = "true";
|
||||||
|
|
||||||
|
shipment.PaymentInformation.PaymentType = 'Sender'
|
||||||
|
shipment.PaymentInformation.RegisteredAccountNumber = self.purolator_account_number
|
||||||
|
shipment.PaymentInformation.BillingAccountNumber = self.purolator_account_number
|
||||||
|
# TODO switch to 'Receiver' or 'ThirdParty' if needed
|
||||||
|
|
||||||
|
shipment_res = service.shipment_create(shipment)
|
||||||
|
_logger.info('purolator service.shipment_create for shipment %s resulted in %s' % (shipment, shipment_res))
|
||||||
|
|
||||||
|
errors = shipment_res.ResponseInformation.Errors
|
||||||
|
if errors:
|
||||||
|
errors = errors.Error # unpack container node
|
||||||
|
puro_errors = '\n\n'.join(['%s - %s - %s' % (e.Code, e.AdditionalInformation, e.Description) for e in errors])
|
||||||
|
raise UserError(_('Error(s) during Purolator Shipment Request:\n%s') % (puro_errors, ))
|
||||||
|
|
||||||
|
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)
|
||||||
|
for n, blob in enumerate(self._purolator_extract_doc_blobs(doc_res), 1):
|
||||||
|
document_blobs.append(('PuroPackage-%s-%s.%s' % (pin, n, 'ZPL'), blob))
|
||||||
|
else:
|
||||||
|
# retrieve shipment_pin document(s)
|
||||||
|
doc_res = service.document_by_pin(shipment_pin)
|
||||||
|
_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, 'ZPL'), 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': 0.0, # TODO How can we know?!
|
||||||
|
'tracking_number': shipment_pin,
|
||||||
|
}
|
||||||
|
res.append(shipping_data)
|
||||||
|
|
||||||
# FIXME
|
|
||||||
shipping_data = {'exact_price': 1.0,
|
|
||||||
'tracking_number': ''}
|
|
||||||
res.append(shipping_data)
|
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -7,6 +7,36 @@ from odoo.exceptions import UserError
|
|||||||
|
|
||||||
|
|
||||||
class PurolatorClient(object):
|
class PurolatorClient(object):
|
||||||
|
|
||||||
|
# clients and factories
|
||||||
|
_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')
|
||||||
|
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')
|
||||||
|
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):
|
def __init__(self, api_key, password, activation_key, account_number, is_prod):
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.password = password
|
self.password = password
|
||||||
@@ -20,19 +50,20 @@ class PurolatorClient(object):
|
|||||||
session.auth = HTTPBasicAuth(self.api_key, self.password)
|
session.auth = HTTPBasicAuth(self.api_key, self.password)
|
||||||
self.transport = Transport(cache=SqliteCache(), session=session)
|
self.transport = Transport(cache=SqliteCache(), session=session)
|
||||||
|
|
||||||
def _get_client(self, wsdl_path):
|
def _get_client(self, wsdl_path, version='2.0'):
|
||||||
|
# version added because shipping documents needs a different one
|
||||||
client = Client(self._wsdl_base + wsdl_path,
|
client = Client(self._wsdl_base + wsdl_path,
|
||||||
transport=self.transport)
|
transport=self.transport)
|
||||||
request_context = client.get_element('ns1:RequestContext')
|
request_context = client.get_element('ns1:RequestContext')
|
||||||
header_value = request_context(
|
header_value = request_context(
|
||||||
Version='2.0',
|
Version=version,
|
||||||
Language='en',
|
Language='en',
|
||||||
GroupID='xxx',
|
GroupID='xxx',
|
||||||
RequestReference='RatingExample',
|
RequestReference='RatingExample', # TODO need to paramatarize this or something, doesn't make sense to shipment, maybe GroupID
|
||||||
UserToken=self.activation_key,
|
UserToken=self.activation_key,
|
||||||
)
|
)
|
||||||
client.set_default_soapheaders([header_value])
|
client.set_default_soapheaders([header_value])
|
||||||
return client
|
return client
|
||||||
|
|
||||||
def get_quick_estimate(self, sender_postal_code, receiver_address, package_type, total_weight):
|
def get_quick_estimate(self, sender_postal_code, receiver_address, package_type, total_weight):
|
||||||
""" Call GetQuickEstimate
|
""" Call GetQuickEstimate
|
||||||
@@ -52,7 +83,7 @@ class PurolatorClient(object):
|
|||||||
SenderPostalCode=sender_postal_code,
|
SenderPostalCode=sender_postal_code,
|
||||||
ReceiverAddress=receiver_address,
|
ReceiverAddress=receiver_address,
|
||||||
PackageType=package_type,
|
PackageType=package_type,
|
||||||
TotalWeight={
|
TotalWeight={ # TODO FIX/paramatarize
|
||||||
'Value': 10.0,
|
'Value': 10.0,
|
||||||
'WeightUnit': 'lb',
|
'WeightUnit': 'lb',
|
||||||
},
|
},
|
||||||
@@ -73,3 +104,104 @@ class PurolatorClient(object):
|
|||||||
'shipments': False,
|
'shipments': False,
|
||||||
'error': 'Purolator service did not return any matching rates.',
|
'error': 'Purolator service did not return any matching rates.',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def shipment_request(self):
|
||||||
|
shipment = self.shipping_factory.Shipment()
|
||||||
|
shipment.SenderInformation = self.shipping_factory.SenderInformation()
|
||||||
|
shipment.SenderInformation.Address = self.shipping_factory.Address()
|
||||||
|
shipment.SenderInformation.Address.PhoneNumber = self.shipping_factory.PhoneNumber()
|
||||||
|
shipment.ReceiverInformation = self.shipping_factory.ReceiverInformation()
|
||||||
|
shipment.ReceiverInformation.Address = self.shipping_factory.Address()
|
||||||
|
shipment.ReceiverInformation.Address.PhoneNumber = self.shipping_factory.PhoneNumber()
|
||||||
|
shipment.PackageInformation = self.shipping_factory.PackageInformation()
|
||||||
|
shipment.PackageInformation.TotalWeight = self.shipping_factory.TotalWeight()
|
||||||
|
shipment.PackageInformation.PiecesInformation = self.shipping_factory.ArrayOfPiece()
|
||||||
|
shipment.PaymentInformation = self.shipping_factory.PaymentInformation()
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
def shipment_add_picking_packages(self, 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 1
|
||||||
|
if not packages:
|
||||||
|
# setup default package
|
||||||
|
package_weight = picking.shipping_weight # TODO need conversion (lb) below
|
||||||
|
if package_weight < 1.0:
|
||||||
|
package_weight = 1.0
|
||||||
|
total_weight_value += package_weight
|
||||||
|
package_type = carrier.purolator_default_package_type_id
|
||||||
|
p = self.shipping_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',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
shipment.PackageInformation.PiecesInformation.Piece.append(p)
|
||||||
|
else:
|
||||||
|
for package in packages:
|
||||||
|
package_weight = package.shipping_weight # TODO need conversion (lb) below
|
||||||
|
if package_weight < 1.0:
|
||||||
|
package_weight = 1.0
|
||||||
|
package_type = package.package_type_id
|
||||||
|
total_weight_value += package_weight
|
||||||
|
p = self.shipping_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',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# TODO p.Options.OptionIDValuePair (ID='SpecialHandling', Value='true')
|
||||||
|
# can we do per-package signature requirements?
|
||||||
|
# Packaging specific codes?
|
||||||
|
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 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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
from odoo.tests.common import Form, TransactionCase
|
from odoo.tests.common import Form, TransactionCase
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class TestPurolator(TransactionCase):
|
class TestPurolator(TransactionCase):
|
||||||
@@ -11,9 +12,18 @@ class TestPurolator(TransactionCase):
|
|||||||
if self.carrier.prod_environment:
|
if self.carrier.prod_environment:
|
||||||
self.skipTest('Purolator Shipping configured to use production credentials, skipping tests.')
|
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({
|
self.shipper_partner = self.env['res.partner'].create({
|
||||||
'name': 'Canadian Address',
|
'name': 'The Great North Ltd.',
|
||||||
'zip': 'L4W5M8',
|
'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({
|
self.shipper_warehouse = self.env['stock.warehouse'].create({
|
||||||
'partner_id': self.shipper_partner.id,
|
'partner_id': self.shipper_partner.id,
|
||||||
@@ -23,6 +33,7 @@ class TestPurolator(TransactionCase):
|
|||||||
self.receiver_partner = self.env['res.partner'].create({
|
self.receiver_partner = self.env['res.partner'].create({
|
||||||
'name': 'Receiver Address',
|
'name': 'Receiver Address',
|
||||||
'city': 'Burnaby',
|
'city': 'Burnaby',
|
||||||
|
'street': '1234 Test Rd.',
|
||||||
'state_id': self.ref('base.state_ca_bc'),
|
'state_id': self.ref('base.state_ca_bc'),
|
||||||
'country_id': self.ref('base.ca'),
|
'country_id': self.ref('base.ca'),
|
||||||
'zip': 'V5C5A9',
|
'zip': 'V5C5A9',
|
||||||
@@ -106,9 +117,18 @@ class TestPurolator(TransactionCase):
|
|||||||
self.sale_order.action_confirm()
|
self.sale_order.action_confirm()
|
||||||
picking = self.sale_order.picking_ids
|
picking = self.sale_order.picking_ids
|
||||||
self.assertEqual(picking.carrier_id, self.carrier)
|
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
|
# Basic case: no qty done or packages or anything at all really
|
||||||
# it makes sense to be able to do 'something' in this case even if that
|
# it makes sense to be able to do 'something' in this case
|
||||||
# is just an error
|
|
||||||
picking.send_to_shipper()
|
picking.send_to_shipper()
|
||||||
self.assertTrue(picking.carrier_tracking_ref)
|
self.assertTrue(picking.carrier_tracking_ref)
|
||||||
|
self.assertEqual(picking.message_attachment_count, 1) # has tracking label now
|
||||||
|
|||||||
Reference in New Issue
Block a user