# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import binascii import logging import re from datetime import datetime, date from os.path import join as opj from odoo.tools.zeep import Client, Plugin, Settings from odoo.tools.zeep.exceptions import Fault from odoo.tools.zeep.wsdl.utils import etree_to_string from odoo.tools import remove_accents, float_repr from odoo.tools.misc import file_path _logger = logging.getLogger(__name__) # uncomment to enable logging of Zeep requests and responses # logging.getLogger('zeep.transports').setLevel(logging.DEBUG) STATECODE_REQUIRED_COUNTRIES = ['US', 'CA', 'PR ', 'IN'] # Why using standardized ISO codes? It's way more fun to use made up codes... # https://www.fedex.com/us/developer/WebHelp/ws/2014/dvg/WS_DVG_WebHelp/Appendix_F_Currency_Codes.htm FEDEX_CURR_MATCH = { u'UYU': u'UYP', u'XCD': u'ECD', u'MXN': u'NMP', u'KYD': u'CID', u'CHF': u'SFR', u'GBP': u'UKL', u'IDR': u'RPA', u'DOP': u'RDD', u'JPY': u'JYE', u'KRW': u'WON', u'SGD': u'SID', u'CLP': u'CHP', u'JMD': u'JAD', u'KWD': u'KUD', u'AED': u'DHS', u'TWD': u'NTD', u'ARS': u'ARN', u'LVL': u'EURO', } class LogPlugin(Plugin): """ Small plugin for zeep that catches out/ingoing XML requests and logs them""" def __init__(self, debug_logger): self.debug_logger = debug_logger def egress(self, envelope, http_headers, operation, binding_options): self.debug_logger(etree_to_string(envelope).decode(), 'fedex_request') return envelope, http_headers def ingress(self, envelope, http_headers, operation): self.debug_logger(etree_to_string(envelope).decode(), 'fedex_response') return envelope, http_headers def marshalled(self, context): context.envelope = context.envelope.prune() class FedexRequest(): """ Low-level object intended to interface Odoo recordsets with FedEx, through appropriate SOAP requests """ def __init__(self, debug_logger, request_type="shipping", prod_environment=False, ): self.debug_logger = debug_logger self.hasCommodities = False wsdl_folder = 'prod' if prod_environment else 'test' if request_type == "shipping": wsdl_path = opj('delivery_fedex', 'api', wsdl_folder, 'ShipService_v28.wsdl') self.start_shipping_transaction(wsdl_path) elif request_type == "rating": wsdl_path = opj('delivery_fedex', 'api', wsdl_folder, 'RateService_v31.wsdl') self.start_rating_transaction(wsdl_path) # Authentification stuff def web_authentication_detail(self, key, password): WebAuthenticationCredential = self.factory.WebAuthenticationCredential() WebAuthenticationCredential.Key = key WebAuthenticationCredential.Password = password self.WebAuthenticationDetail = self.factory.WebAuthenticationDetail() self.WebAuthenticationDetail.UserCredential = WebAuthenticationCredential def transaction_detail(self, transaction_id): self.TransactionDetail = self.factory.TransactionDetail() self.TransactionDetail.CustomerTransactionId = transaction_id def client_detail(self, account_number, meter_number): self.ClientDetail = self.factory.ClientDetail() self.ClientDetail.AccountNumber = account_number self.ClientDetail.MeterNumber = meter_number # Common stuff def set_shipper(self, company_partner, warehouse_partner): Contact = self.factory.Contact() Contact.PersonName = remove_accents(company_partner.name) if not company_partner.is_company else '' Contact.CompanyName = remove_accents(company_partner.commercial_company_name) or '' Contact.PhoneNumber = warehouse_partner.phone or '' Contact.EMailAddress = warehouse_partner.email or '' # TODO fedex documentation asks for TIN number, but it seems to work without Address = self.factory.Address() Address.StreetLines = [remove_accents(warehouse_partner.street) or '',remove_accents(warehouse_partner.street2) or ''] Address.City = remove_accents(warehouse_partner.city) or '' if warehouse_partner.country_id.code in STATECODE_REQUIRED_COUNTRIES: Address.StateOrProvinceCode = warehouse_partner.state_id.code or '' else: Address.StateOrProvinceCode = '' Address.PostalCode = warehouse_partner.zip or '' Address.CountryCode = warehouse_partner.country_id.code or '' self.RequestedShipment.Shipper = self.factory.Party() self.RequestedShipment.Shipper.Contact = Contact self.RequestedShipment.Shipper.Address = Address def set_recipient(self, recipient_partner): Contact = self.factory.Contact() if recipient_partner.is_company: Contact.PersonName = '' Contact.CompanyName = remove_accents(recipient_partner.name) else: Contact.PersonName = remove_accents(recipient_partner.name) Contact.CompanyName = remove_accents(recipient_partner.commercial_company_name) or '' Contact.PhoneNumber = recipient_partner.phone or '' Contact.EMailAddress = recipient_partner.email or '' Address = self.factory.Address() Address.StreetLines = [remove_accents(recipient_partner.street) or '', remove_accents(recipient_partner.street2) or ''] Address.City = remove_accents(recipient_partner.city) or '' if recipient_partner.country_id.code in STATECODE_REQUIRED_COUNTRIES: Address.StateOrProvinceCode = recipient_partner.state_id.code or '' else: Address.StateOrProvinceCode = '' Address.PostalCode = recipient_partner.zip or '' Address.CountryCode = recipient_partner.country_id.code or '' self.RequestedShipment.Recipient = self.factory.Party() self.RequestedShipment.Recipient.Contact = Contact self.RequestedShipment.Recipient.Address = Address def shipment_request(self, dropoff_type, service_type, packaging_type, overall_weight_unit, saturday_delivery): self.RequestedShipment = self.factory.RequestedShipment() self.RequestedShipment.SpecialServicesRequested = self.factory.ShipmentSpecialServicesRequested() self.RequestedShipment.ShipTimestamp = datetime.now() self.RequestedShipment.DropoffType = dropoff_type self.RequestedShipment.ServiceType = service_type self.RequestedShipment.PackagingType = packaging_type # Resuest estimation of duties and taxes for international shipping if service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY']: self.RequestedShipment.EdtRequestType = 'ALL' else: self.RequestedShipment.EdtRequestType = 'NONE' self.RequestedShipment.PackageCount = 0 self.RequestedShipment.TotalWeight = self.factory.Weight() self.RequestedShipment.TotalWeight.Units = overall_weight_unit self.RequestedShipment.TotalWeight.Value = 0 self.listCommodities = [] if saturday_delivery: timestamp_day = self.RequestedShipment.ShipTimestamp.strftime("%A") if (service_type == 'FEDEX_2_DAY' and timestamp_day == 'Thursday') or (service_type in ['PRIORITY_OVERNIGHT', 'FIRST_OVERNIGHT', 'INTERNATIONAL_PRIORITY'] and timestamp_day == 'Friday'): self.RequestedShipment.SpecialServicesRequested.SpecialServiceTypes.append('SATURDAY_DELIVERY') def set_currency(self, currency): # set perferred currency as GBP instead of UKL currency = 'GBP' if currency == 'UKL' else currency self.RequestedShipment.PreferredCurrency = currency # ask Fedex to include our preferred currency in the response self.RequestedShipment.RateRequestTypes = 'PREFERRED' def set_master_package(self, weight, package_count, master_tracking_id=False): self.RequestedShipment.TotalWeight.Value = weight self.RequestedShipment.PackageCount = package_count if master_tracking_id: self.RequestedShipment.MasterTrackingId = self.factory.TrackingId() self.RequestedShipment.MasterTrackingId.TrackingIdType = 'FEDEX' self.RequestedShipment.MasterTrackingId.TrackingNumber = master_tracking_id # weight_value, package_code=False, package_height=0, package_width=0, package_length=0, def add_package(self, carrier, delivery_package, fdx_company_currency, sequence_number=False, mode='shipping', po_number=False, dept_number=False, reference=False): package = self.factory.RequestedPackageLineItem() package_weight = self.factory.Weight() package_weight.Value = carrier._fedex_convert_weight(delivery_package.weight, carrier.fedex_weight_unit) package_weight.Units = self.RequestedShipment.TotalWeight.Units package.PhysicalPackaging = 'BOX' if delivery_package.packaging_type == 'YOUR_PACKAGING': package.Dimensions = self.factory.Dimensions() package.Dimensions.Height = int(delivery_package.dimension['height']) package.Dimensions.Width = int(delivery_package.dimension['width']) package.Dimensions.Length = int(delivery_package.dimension['length']) # TODO in master, add unit in product packaging and perform unit conversion package.Dimensions.Units = "IN" if self.RequestedShipment.TotalWeight.Units == 'LB' else 'CM' if po_number: po_reference = self.factory.CustomerReference() po_reference.CustomerReferenceType = 'P_O_NUMBER' po_reference.Value = po_number package.CustomerReferences.append(po_reference) if dept_number: dept_reference = self.factory.CustomerReference() dept_reference.CustomerReferenceType = 'DEPARTMENT_NUMBER' dept_reference.Value = dept_number package.CustomerReferences.append(dept_reference) if reference: customer_reference = self.factory.CustomerReference() customer_reference.CustomerReferenceType = 'CUSTOMER_REFERENCE' customer_reference.Value = reference package.CustomerReferences.append(customer_reference) if carrier.shipping_insurance: package.InsuredValue = self.factory.Money() insured_value = delivery_package.total_cost * carrier.shipping_insurance / 100 pkg_order = delivery_package.order_id or delivery_package.picking_id.sale_id # Get the currency from the sale order if it exists, so that it matches that of customs_value if pkg_order: package.InsuredValue.Currency = _convert_curr_iso_fdx(pkg_order.currency_id.name) package.InsuredValue.Amount = float_repr(delivery_package.company_id.currency_id._convert(insured_value, pkg_order.currency_id, pkg_order.company_id, date.today()), 2) else: package.InsuredValue.Currency = fdx_company_currency package.InsuredValue.Amount = float_repr(insured_value, 2) package.Weight = package_weight if mode == 'rating': package.GroupPackageCount = 1 if sequence_number: package.SequenceNumber = sequence_number if mode == 'rating': self.RequestedShipment.RequestedPackageLineItems.append(package) else: self.RequestedShipment.RequestedPackageLineItems = package # Rating stuff def start_rating_transaction(self, wsdl_path): settings = Settings(strict=False) self.client = Client(file_path(wsdl_path), plugins=[LogPlugin(self.debug_logger)], settings=settings) self.factory = self.client.type_factory('ns0') self.VersionId = self.factory.VersionId() self.VersionId.ServiceId = 'crs' self.VersionId.Major = '31' self.VersionId.Intermediate = '0' self.VersionId.Minor = '0' def rate(self, request): formatted_response = {'price': {}} try: self.response = self.client.service.getRates(WebAuthenticationDetail=request['WebAuthenticationDetail'], ClientDetail=request['ClientDetail'], TransactionDetail=request['TransactionDetail'], Version=request['VersionId'], RequestedShipment=request['RequestedShipment']) if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'): if not getattr(self.response, "RateReplyDetails", False): raise Exception("No rating found") for rating in self.response.RateReplyDetails[0].RatedShipmentDetails: formatted_response['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = float(rating.ShipmentRateDetail.TotalNetFedExCharge.Amount) if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1: if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail and self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail['CurrencyExchangeRate']: formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = float(self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount) / float(self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate) else: errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')]) formatted_response['errors_message'] = errors_message if any([n.Severity == 'WARNING' for n in self.response.Notifications]): warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING']) formatted_response['warnings_message'] = warnings_message except Fault as fault: formatted_response['errors_message'] = fault except IOError: formatted_response['errors_message'] = "Fedex Server Not Found" except Exception as e: formatted_response['errors_message'] = e.args[0] return formatted_response # Shipping stuff def start_shipping_transaction(self, wsdl_path): self.client = Client(file_path(wsdl_path), plugins=[LogPlugin(self.debug_logger)]) self.factory = self.client.type_factory("ns0") self.VersionId = self.factory.VersionId() self.VersionId.ServiceId = 'ship' self.VersionId.Major = '28' self.VersionId.Intermediate = '0' self.VersionId.Minor = '0' def shipment_label(self, label_format_type, image_type, label_stock_type, label_printing_orientation, label_order): LabelSpecification = self.factory.LabelSpecification() LabelSpecification.LabelFormatType = label_format_type LabelSpecification.ImageType = image_type LabelSpecification.LabelStockType = label_stock_type LabelSpecification.LabelPrintingOrientation = label_printing_orientation LabelSpecification.LabelOrder = label_order self.RequestedShipment.LabelSpecification = LabelSpecification def commercial_invoice(self, document_stock_type, send_etd=False): shipping_document = self.factory.ShippingDocumentSpecification() shipping_document.ShippingDocumentTypes = "COMMERCIAL_INVOICE" commercial_invoice_detail = self.factory.CommercialInvoiceDetail() commercial_invoice_detail.Format = self.factory.ShippingDocumentFormat() commercial_invoice_detail.Format.ImageType = "PDF" commercial_invoice_detail.Format.StockType = document_stock_type shipping_document.CommercialInvoiceDetail = commercial_invoice_detail self.RequestedShipment.ShippingDocumentSpecification = shipping_document if send_etd: self.RequestedShipment.SpecialServicesRequested.SpecialServiceTypes.append('ELECTRONIC_TRADE_DOCUMENTS') etd_details = self.factory.EtdDetail() etd_details.RequestedDocumentCopies.append('COMMERCIAL_INVOICE') self.RequestedShipment.SpecialServicesRequested.EtdDetail = etd_details def shipping_charges_payment(self, shipping_charges_payment_account): self.RequestedShipment.ShippingChargesPayment = self.factory.Payment() self.RequestedShipment.ShippingChargesPayment.PaymentType = 'SENDER' Payor = self.factory.Payor() Payor.ResponsibleParty = self.factory.Party() Payor.ResponsibleParty.AccountNumber = shipping_charges_payment_account self.RequestedShipment.ShippingChargesPayment.Payor = Payor def duties_payment(self, sender_party, responsible_account_number, payment_type): self.RequestedShipment.CustomsClearanceDetail.DutiesPayment = self.factory.Payment() self.RequestedShipment.CustomsClearanceDetail.DutiesPayment.PaymentType = payment_type if payment_type == 'SENDER': Payor = self.factory.Payor() Payor.ResponsibleParty = self.factory.Party() Payor.ResponsibleParty.Address = self.factory.Address() Payor.ResponsibleParty.Address.CountryCode = sender_party.country_id.code Payor.ResponsibleParty.AccountNumber = responsible_account_number self.RequestedShipment.CustomsClearanceDetail.DutiesPayment.Payor = Payor def customs_value(self, customs_value_currency, customs_value_amount, document_content): self.RequestedShipment.CustomsClearanceDetail = self.factory.CustomsClearanceDetail() if self.hasCommodities: self.RequestedShipment.CustomsClearanceDetail.Commodities = self.listCommodities self.RequestedShipment.CustomsClearanceDetail.CustomsValue = self.factory.Money() self.RequestedShipment.CustomsClearanceDetail.CustomsValue.Currency = customs_value_currency self.RequestedShipment.CustomsClearanceDetail.CustomsValue.Amount = float_repr(customs_value_amount, 2) if self.RequestedShipment.Shipper.Address.CountryCode == "IN" and self.RequestedShipment.Recipient.Address.CountryCode == "IN": if not self.RequestedShipment.CustomsClearanceDetail.CommercialInvoice: self.RequestedShipment.CustomsClearanceDetail.CommercialInvoice = self.factory.CommercialInvoice() else: del self.RequestedShipment.CustomsClearanceDetail.CommercialInvoice.TaxesOrMiscellaneousChargeType self.RequestedShipment.CustomsClearanceDetail.CommercialInvoice.Purpose = 'SOLD' # Old keys not requested anymore but still in WSDL; not removing them causes crash del self.RequestedShipment.CustomsClearanceDetail['ClearanceBrokerage'] del self.RequestedShipment.CustomsClearanceDetail['FreightOnValue'] self.RequestedShipment.CustomsClearanceDetail.DocumentContent = document_content def commodities(self, carrier, delivery_commodity, commodity_currency): self.hasCommodities = True commodity = self.factory.Commodity() commodity.UnitPrice = self.factory.Money() commodity.UnitPrice.Currency = commodity_currency commodity.UnitPrice.Amount = delivery_commodity.monetary_value commodity.NumberOfPieces = '1' commodity.CountryOfManufacture = delivery_commodity.country_of_origin commodity_weight = self.factory.Weight() commodity_weight.Value = carrier._fedex_convert_weight(delivery_commodity.product_id.weight * delivery_commodity.qty, carrier.fedex_weight_unit) commodity_weight.Units = carrier.fedex_weight_unit commodity.Weight = commodity_weight commodity.Description = re.sub(r'[\[\]<>;={}"|]', '', delivery_commodity.product_id.name) commodity.Quantity = delivery_commodity.qty commodity.QuantityUnits = 'EA' customs_value = self.factory.Money() customs_value.Currency = commodity_currency customs_value.Amount = delivery_commodity.monetary_value * delivery_commodity.qty commodity.CustomsValue = customs_value commodity.HarmonizedCode = delivery_commodity.product_id.hs_code.replace(".", "") if delivery_commodity.product_id.hs_code else '' self.listCommodities.append(commodity) def return_label(self, tracking_number, origin_date): return_details = self.factory.ReturnShipmentDetail() return_details.ReturnType = "PRINT_RETURN_LABEL" if tracking_number and origin_date: return_association = self.factory.ReturnAssociationDetail() return_association.TrackingNumber = tracking_number return_association.ShipDate = origin_date return_details.ReturnAssociation = return_association self.RequestedShipment.SpecialServicesRequested.SpecialServiceTypes.append("RETURN_SHIPMENT") self.RequestedShipment.SpecialServicesRequested.ReturnShipmentDetail = return_details if self.hasCommodities: bla = self.factory.CustomsOptionDetail() bla.Type = "FAULTY_ITEM" self.RequestedShipment.CustomsClearanceDetail.CustomsOptions = bla def process_shipment(self, request): formatted_response = {'tracking_number': 0.0, 'price': {}, 'master_tracking_id': None, 'date': None} try: self.response = self.client.service.processShipment(WebAuthenticationDetail=request['WebAuthenticationDetail'], ClientDetail=request['ClientDetail'], TransactionDetail=request['TransactionDetail'], Version=request['VersionId'], RequestedShipment=request['RequestedShipment']) if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'): formatted_response['tracking_number'] = self.response.CompletedShipmentDetail.CompletedPackageDetails[0].TrackingIds[0].TrackingNumber if 'CommitDate' in self.response.CompletedShipmentDetail.OperationalDetail: formatted_response['date'] = self.response.CompletedShipmentDetail.OperationalDetail.CommitDate else: formatted_response['date'] = date.today() if 'ShipmentRating' in self.response.CompletedShipmentDetail and self.response.CompletedShipmentDetail.ShipmentRating: for rating in self.response.CompletedShipmentDetail.ShipmentRating.ShipmentRateDetails: formatted_response['price'][rating.TotalNetFedExCharge.Currency] = float(rating.TotalNetFedExCharge.Amount) if 'CurrencyExchangeRate' in rating and rating.CurrencyExchangeRate: formatted_response['price'][rating.CurrencyExchangeRate.FromCurrency] = float(rating.TotalNetFedExCharge.Amount / rating.CurrencyExchangeRate.Rate) else: formatted_response['price']['USD'] = 0.0 if 'MasterTrackingId' in self.response.CompletedShipmentDetail: formatted_response['master_tracking_id'] = self.response.CompletedShipmentDetail.MasterTrackingId.TrackingNumber else: errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')]) formatted_response['errors_message'] = errors_message if any([n.Severity == 'WARNING' for n in self.response.Notifications]): warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING']) formatted_response['warnings_message'] = warnings_message except Fault as fault: formatted_response['errors_message'] = fault except IOError: formatted_response['errors_message'] = "Fedex Server Not Found" return formatted_response def _get_labels(self, file_type): labels = [self.get_label()] if file_type.upper() in ['PNG'] and self.response.CompletedShipmentDetail.CompletedPackageDetails[0].PackageDocuments: for auxiliary in self.response.CompletedShipmentDetail.CompletedPackageDetails[0].PackageDocuments[0].Parts: labels.append(auxiliary.Image) return labels def get_label(self): return self.response.CompletedShipmentDetail.CompletedPackageDetails[0].Label.Parts[0].Image def get_document(self): if self.response.CompletedShipmentDetail.ShipmentDocuments: return self.response.CompletedShipmentDetail.ShipmentDocuments[0].Parts[0].Image else: return False # Deletion stuff def set_deletion_details(self, tracking_number): self.TrackingId = self.factory.TrackingId() self.TrackingId.TrackingIdType = 'FEDEX' self.TrackingId.TrackingNumber = tracking_number self.DeletionControl = self.factory.DeletionControlType('DELETE_ALL_PACKAGES') def delete_shipment(self, request): formatted_response = {'delete_success': False} try: # Here, we send the Order 66 self.response = self.client.service.deleteShipment(WebAuthenticationDetail=request['WebAuthenticationDetail'], ClientDetail=request['ClientDetail'], TransactionDetail=request['TransactionDetail'], Version=request['VersionId'], TrackingId=request['TrackingId'], DeletionControl=request['DeletionControl']) if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'): formatted_response['delete_success'] = True else: errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')]) formatted_response['errors_message'] = errors_message if any([n.Severity == 'WARNING' for n in self.response.Notifications]): warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING']) formatted_response['warnings_message'] = warnings_message except Fault as fault: formatted_response['errors_message'] = fault except IOError: formatted_response['errors_message'] = "Fedex Server Not Found" return formatted_response def _convert_curr_fdx_iso(code): curr_match = {v: k for k, v in FEDEX_CURR_MATCH.items()} return curr_match.get(code, code) def _convert_curr_iso_fdx(code): return FEDEX_CURR_MATCH.get(code, code)