diff --git a/delivery_purolator/models/delivery_purolator.py b/delivery_purolator/models/delivery_purolator.py index de016596..5c09cbb0 100644 --- a/delivery_purolator/models/delivery_purolator.py +++ b/delivery_purolator/models/delivery_purolator.py @@ -1,8 +1,11 @@ from odoo import fields, models, _ +from odoo.exceptions import UserError from .purolator_services import PurolatorClient import logging +_logger = logging.getLogger(__name__) + PUROLATOR_SERVICES = [ ('PurolatorExpress9AM', 'Purolator Express 9AM'), ('PurolatorExpress10:30AM', 'Purolator Express 10:30AM'), @@ -211,26 +214,124 @@ class ProviderPurolator(models.Model): ('purolator_account_number', '=', self.purolator_account_number), ], limit=1) 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 def purolator_send_shipping(self, pickings): res = [] - # service = self._get_purolator_service() - # had_customs = False + service = self._purolator_service() for picking in pickings: picking_packages = self.get_to_ship_picking_packages(picking) if picking_packages is None: continue - # do the shipment! - package_labels = [] - for x in []: - res = res + [shipping_data] # bug! fill in with appropriate data - picking.carrier_tracking_ref = ','.join(package_labels) + 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) + + # 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
Tracking Number/PIN : %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 diff --git a/delivery_purolator/models/purolator_services.py b/delivery_purolator/models/purolator_services.py index bc69c095..740bfdd0 100644 --- a/delivery_purolator/models/purolator_services.py +++ b/delivery_purolator/models/purolator_services.py @@ -7,6 +7,36 @@ from odoo.exceptions import UserError 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): self.api_key = api_key self.password = password @@ -20,19 +50,20 @@ class PurolatorClient(object): session.auth = HTTPBasicAuth(self.api_key, self.password) 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, transport=self.transport) request_context = client.get_element('ns1:RequestContext') header_value = request_context( - Version='2.0', + Version=version, Language='en', 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, ) client.set_default_soapheaders([header_value]) - return client + return client def get_quick_estimate(self, sender_postal_code, receiver_address, package_type, total_weight): """ Call GetQuickEstimate @@ -52,7 +83,7 @@ class PurolatorClient(object): SenderPostalCode=sender_postal_code, ReceiverAddress=receiver_address, PackageType=package_type, - TotalWeight={ + TotalWeight={ # TODO FIX/paramatarize 'Value': 10.0, 'WeightUnit': 'lb', }, @@ -73,3 +104,104 @@ class PurolatorClient(object): 'shipments': False, '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 diff --git a/delivery_purolator/tests/test_purolator.py b/delivery_purolator/tests/test_purolator.py index f9157cf7..c5bd9f2d 100644 --- a/delivery_purolator/tests/test_purolator.py +++ b/delivery_purolator/tests/test_purolator.py @@ -1,5 +1,6 @@ from odoo.tests.common import Form, TransactionCase +from odoo.exceptions import UserError class TestPurolator(TransactionCase): @@ -11,9 +12,18 @@ class TestPurolator(TransactionCase): 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': 'Canadian Address', + '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, @@ -23,6 +33,7 @@ class TestPurolator(TransactionCase): 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', @@ -106,9 +117,18 @@ class TestPurolator(TransactionCase): 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 even if that - # is just an error + # it makes sense to be able to do 'something' in this case picking.send_to_shipper() self.assertTrue(picking.carrier_tracking_ref) + self.assertEqual(picking.message_attachment_count, 1) # has tracking label now