# Part of Odoo. See LICENSE file for full copyright and licensing details. import json from json import JSONDecodeError import requests from requests import RequestException from odoo import _ from odoo.exceptions import ValidationError, UserError from odoo.tools import float_repr TEST_BASE_URL = "https://apis-sandbox.fedex.com" PROD_BASE_URL = "https://apis.fedex.com" # Why using standardized ISO codes? It's way more fun to use made up codes... # https://developer.fedex.com/api/en-us/guides/api-reference.html#currencycodes FEDEX_CURR_MATCH = { 'XCD': 'ECD', 'MXN': 'NMP', 'KYD': 'CID', 'CHF': 'SFR', 'DOP': 'RDD', 'JPY': 'JYE', 'KRW': 'WON', 'SGD': 'SID', 'CLP': 'CHP', 'JMD': 'JAD', 'KWD': 'KUD', 'AED': 'DHS', 'TWD': 'NTD', 'ARS': 'ARN', 'VES': 'VEF', # 'LVL': 'EUR', # 'UYU': 'UYP', # 'GBP': 'UKL', # 'IDR': 'RPA', } class FedexRequest: def __init__(self, carrier): super_carrier = carrier.sudo() self.base_url = PROD_BASE_URL if super_carrier.prod_environment else TEST_BASE_URL self.access_token = super_carrier.fedex_rest_access_token self.client_id = super_carrier.fedex_rest_developer_key self.client_secret = super_carrier.fedex_rest_developer_password self.account_number = super_carrier.fedex_rest_account_number self.weight_units = super_carrier.fedex_rest_weight_unit self.vat_override = super_carrier.fedex_rest_override_shipper_vat self.email_notifications = super_carrier.fedex_rest_email_notifications self.documentation_type = super_carrier.fedex_rest_documentation_type self.insurance = super_carrier.shipping_insurance self.check_residential = super_carrier.fedex_rest_residential_address self.dropoff_type = super_carrier.fedex_rest_droppoff_type self.service_type = super_carrier.fedex_rest_service_type self.label_stock = super_carrier.fedex_rest_label_stock_type self.label_file = super_carrier.fedex_rest_label_file_type self.duty_payment = super_carrier.fedex_rest_duty_payment self.make_return = super_carrier.return_label_on_delivery self.debug_logger = super_carrier.log_xml self.carrier = super_carrier self.session = requests.Session() def _send_fedex_request(self, url, data, method='POST'): new_token = False if not self.access_token: self.access_token = self._get_new_access_token() self.carrier.fedex_rest_access_token = self.access_token new_token = True def _request_call(): try: response = self.session.request(method, self.base_url + url, json=data, headers={ 'Content-Type': "application/json", 'Authorization': "Bearer " + self.access_token }, timeout=15 ) self.debug_logger("%s %s\n%s\n\n%s" % ( response.request.method, response.request.url, '\n'.join([f'{k}: {v}' for k, v in response.request.headers.items()]), response.request.body.decode('utf-8') ), 'fedex_rest_request') self.debug_logger("%s %s\n%s\n\n%s" % ( response.status_code, response.reason, '\n'.join([f'{k}: {v}' for k, v in response.headers.items()]), response.text ), 'fedex_rest_response') except RequestException: raise ValidationError(_('Something went wrong, please try again later!!')) from None return response res = _request_call() if res.status_code == 401 and not new_token: self.access_token = self._get_new_access_token() self.carrier.fedex_rest_access_token = self.access_token res = _request_call() try: response_data = res.json() except JSONDecodeError: raise ValidationError(_('Could not decode response')) from None if not res.ok: raise ValidationError(self._process_errors(response_data)) if 'output' not in response_data: raise ValidationError(_('Could not decode response')) return response_data['output'] def _process_errors(self, res_body): err_msgs = [] for err in res_body.get('errors', []): err_msgs.append(f"{err['message']} ({err['code']})") return ','.join(err_msgs) def _process_alerts(self, response): messages = [] alerts = response.get('alerts', []) if 'rateReplyDetails' in response: alerts += response['rateReplyDetails'][0].get('customerMessages', []) for alert in alerts: messages.append(f"{alert['message']} ({alert['code']})") return '\n'.join(messages) def _get_new_access_token(self): if not self.client_id or not self.client_secret: raise ValidationError(_('You must setup a client ID and secret on the carrier first')) try: response = self.session.post( self.base_url + "/oauth/token", f"grant_type=client_credentials&client_id={self.client_id}&client_secret={self.client_secret}", headers={'Content-Type': "application/x-www-form-urlencoded"}, timeout=15 ) response_data = response.json() except RequestException: raise ValidationError(_('Something went wrong, please try again later!!')) from None except JSONDecodeError: raise ValidationError(_('Could not decode response')) from None if not response.ok: raise ValidationError(self._process_errors(response_data)) if 'access_token' not in response_data: raise ValidationError(_('Could not decode response')) return response_data['access_token'] def _get_location_from_partner(self, partner, check_residential=False): res = {'countryCode': partner.country_id.code} if partner.city: res['city'] = partner.city if partner.zip: res['postalCode'] = partner.zip if partner.state_id: res['stateOrProvinceCode'] = partner.state_id.code if check_residential: setting = self.check_residential if setting == 'always' or (setting == 'check' and self._check_residential_address({**res, 'streetLines': [partner.street, partner.street2]})): res['residential'] = True return res def _check_residential_address(self, address): if not address['streetLines'][1]: del address['streetLines'][1] result = self._send_fedex_request('/address/v1/addresses/resolve', { 'addressesToValidate': [{'address': address}] }) return result['resolvedAddresses'][0]['classification'] != 'BUSINESS' # We assume residential until proven otherwise def _get_address_from_partner(self, partner, check_residential=False): res = self._get_location_from_partner(partner, check_residential) res['streetLines'] = [partner.street] if partner.street2: res['streetLines'].append(partner.street2) return res def _get_contact_from_partner(self, partner, company_partner=False): res = {'phoneNumber': partner.phone or partner.mobile} if company_partner and not res['phoneNumber']: # Fallback to phone on the company if none on the WH res['phoneNumber'] = company_partner.phone or company_partner.mobile if company_partner: # Always put the name of the company, if the partner is a WH res['companyName'] = partner.name elif partner.is_company: res['companyName'] = partner.name else: res['personName'] = partner.name if partner.parent_id: res['companyName'] = partner.parent_id.name if partner.email: res['emailAddress'] = partner.email elif company_partner and company_partner.email: res['emailAddress'] = company_partner.email return res def _get_package_info(self, package): res = { 'weight': { 'units': self.weight_units, 'value': self.carrier._fedex_rest_convert_weight(package.weight) }, } if int(package.dimension['length']) or int(package.dimension['width']) or int(package.dimension['height']): # FedEx will raise a warning when mixing imperial and metric units (MIXED.MEASURING.UNITS.INCLUDED). # So we force the dimension unit based on the selected weight unit on the delivery method. res['dimensions'] = { 'units': 'IN' if self.weight_units == 'LB' else 'CM', 'length': int(package.dimension['length']), 'width': int(package.dimension['width']), 'height': int(package.dimension['height']), } if self.insurance: res['declaredValue'] = { 'amount': float_repr(package.total_cost * self.insurance / 100, 2), 'currency': _convert_curr_iso_fdx(package.currency_id.name), } return res def _get_detailed_package_info(self, package, customPackaging, order_no=False): res = self._get_package_info(package) if customPackaging: res['subPackagingType'] = 'PACKAGE' description = ', '.join([c.product_id.name for c in package.commodities]) res['itemDescription'] = description res['itemDescriptionForClearance'] = description if order_no: res['customerReferences'] = [{ 'customerReferenceType': 'P_O_NUMBER', 'value': order_no }] return res def _get_commodities_info(self, commodity, currency): res = { 'description': commodity.product_id.name, 'customsValue': ({'amount': commodity.monetary_value * commodity.qty, 'currency': currency}), 'unitPrice': ({'amount': commodity.monetary_value, 'currency': currency}), 'countryOfManufacture': commodity.country_of_origin, 'weight': { 'units': self.weight_units, 'value': self.carrier._fedex_rest_convert_weight(commodity.product_id.weight), }, 'quantity': commodity.qty, 'quantityUnits': commodity.product_id.uom_id.fedex_code, 'numberOfPieces': 1, } if commodity.product_id.hs_code: res['harmonizedCode'] = commodity.product_id.hs_code return res def _get_tins_from_partner(self, partner, custom_vat=False): res = [] if custom_vat: res.append({ 'number': self.vat_override, 'tinType': 'BUSINESS_UNION' }) if partner.vat and partner.is_company: res.append({'number': partner.vat, 'tinType': 'BUSINESS_NATIONAL'}) elif partner.parent_id and partner.parent_id.vat and partner.parent_id.is_company: res.append({'number': partner.parent_id.vat, 'tinType': 'BUSINESS_NATIONAL'}) return res def _get_shipping_price(self, ship_from, ship_to, packages, currency): fedex_currency = _convert_curr_iso_fdx(currency) request_data = { 'accountNumber': {'value': self.account_number}, 'requestedShipment': { 'rateRequestType': ['PREFERRED'], 'preferredCurrency': fedex_currency, 'pickupType': self.dropoff_type, 'serviceType': self.service_type, 'packagingType': packages[0].packaging_type, 'shipper': {'address': self._get_location_from_partner(ship_from)}, 'recipient': {'address': self._get_location_from_partner(ship_to, True)}, 'requestedPackageLineItems': [self._get_package_info(p) for p in packages], 'customsClearanceDetail': { 'commercialInvoice': {'shipmentPurpose': 'SOLD'}, 'commodities': [self._get_commodities_info(c, fedex_currency) for pkg in packages for c in pkg.commodities], 'freightOnValue': 'CARRIER_RISK' if self.insurance == 100 else 'OWN_RISK', 'dutiesPayment': {'paymentType': 'SENDER'} # Only allowed value... } } } self._add_extra_data_to_request(request_data, 'rate') res = self._send_fedex_request("/rate/v1/rates/quotes", request_data) try: rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {}) if rate.get('totalNetChargeWithDutiesAndTaxes', 0): price = rate['totalNetChargeWithDutiesAndTaxes'] else: price = rate['totalNetCharge'] except KeyError: raise ValidationError(_('Could not decode response')) from None return { 'price': price, 'alert_message': self._process_alerts(res), } def _ship_package(self, ship_from_wh, ship_from_company, ship_to, sold_to, packages, currency, order_no, customer_ref, picking_no, incoterms, freight_charge): fedex_currency = _convert_curr_iso_fdx(currency) package_type = packages[0].packaging_type request_data = { 'accountNumber': {'value': self.account_number}, 'labelResponseOptions': 'LABEL', 'requestedShipment': { 'rateRequestType': ['PREFERRED'], 'preferredCurrency': fedex_currency, 'pickupType': self.dropoff_type, 'serviceType': self.service_type, 'packagingType': package_type, 'shippingChargesPayment': {'paymentType': 'SENDER'}, 'labelSpecification': {'labelStockType': self.label_stock, 'imageType': self.label_file}, 'shipper': { 'address': self._get_address_from_partner(ship_from_wh), 'contact': self._get_contact_from_partner(ship_from_wh, ship_from_company), 'tins': self._get_tins_from_partner(ship_from_company, self.vat_override), }, 'recipients': [{ 'address': self._get_address_from_partner(ship_to, True), 'contact': self._get_contact_from_partner(ship_to), 'tins': self._get_tins_from_partner(ship_to), }], 'requestedPackageLineItems': [self._get_detailed_package_info(p, package_type == 'YOUR_PACKAGING', order_no) for p in packages], 'customsClearanceDetail': { 'dutiesPayment': {'paymentType': self.duty_payment}, 'commodities': [self._get_commodities_info(c, fedex_currency) for pkg in packages for c in pkg.commodities], 'commercialInvoice': { 'shipmentPurpose': 'SOLD', 'originatorName': ship_from_company.name, 'comments': ['', picking_no], # First one is special instructions }, } } } if freight_charge: request_data['requestedShipment']['customsClearanceDetail']['commercialInvoice']['freightCharge'] = { 'amount': freight_charge, 'currency': fedex_currency, } if incoterms: request_data['requestedShipment']['customsClearanceDetail']['commercialInvoice']['termsOfSale'] = incoterms if customer_ref: request_data['requestedShipment']['customsClearanceDetail']['commercialInvoice']['customerReferences'] = [{ 'customerReferenceType': 'CUSTOMER_REFERENCE', 'value': customer_ref, }] if request_data['requestedShipment']['shipper']['address']['countryCode'] == 'IN' and request_data['requestedShipment']['recipients'][0]['address']['countryCode'] == 'IN': request_data['requestedShipment']['customsClearanceDetail']['freightOnValue'] = 'CARRIER_RISK' if self.insurance == 100 else 'OWN_RISK' if sold_to and sold_to != ship_to: request_data['requestedShipment']['soldTo'] = { 'address': self._get_address_from_partner(sold_to), 'contact': self._get_contact_from_partner(sold_to), 'tins': self._get_tins_from_partner(sold_to), } if ship_to.vat or ship_to.parent_id.vat: request_data['requestedShipment']['customsClearanceDetail']['recipientCustomsId'] = { 'type': 'COMPANY', 'value': ship_to.vat or ship_to.parent_id.vat, } if self.email_notifications and ship_to.email: request_data['requestedShipment']['emailNotificationDetail'] = { 'aggregationType': 'PER_PACKAGE', 'emailNotificationRecipients': [{ 'emailNotificationRecipientType': 'RECIPIENT', 'emailAddress': ship_to.email, 'name': ship_to.name, 'notificationFormatType': 'HTML', 'notificationType': 'EMAIL', 'notificationEventType': ['ON_DELIVERY', 'ON_EXCEPTION', 'ON_SHIPMENT', 'ON_TENDER', 'ON_ESTIMATED_DELIVERY'] }] } if self.documentation_type != 'none': request_data['requestedShipment']['shippingDocumentSpecification'] = { 'shippingDocumentTypes': ['COMMERCIAL_INVOICE'], 'commercialInvoiceDetail': { 'documentFormat': {'stockType': 'PAPER_LETTER', 'docType': 'PDF'} } } if self.documentation_type == 'etd': request_data['requestedShipment']['shipmentSpecialServices'] = { "specialServiceTypes": [ "ELECTRONIC_TRADE_DOCUMENTS" ], "etdDetail": { "requestedDocumentTypes": [ "COMMERCIAL_INVOICE" ] } } if self.make_return: request_data['requestedShipment']['customsClearanceDetail']['customsOption'] = {'type': 'COURTESY_RETURN_LABEL'} self._add_extra_data_to_request(request_data, 'ship') res = self._send_fedex_request("/ship/v1/shipments", request_data) try: shipment = res['transactionShipments'][0] details = shipment['completedShipmentDetail'] pieces = shipment['pieceResponses'] # Sometimes the shipment might be created but no pricing calculated, we just set to 0. price = self._decode_pricing(details['shipmentRating']) if 'shipmentRating' in details else 0.0 except KeyError: raise ValidationError(_('Could not decode response')) from None return { 'service_info': f"{details.get('carrierCode', '')} > {details.get('serviceDescription', {}).get('description', '')} > {details.get('packagingDescription', '')}", 'tracking_numbers': ','.join([ t.get('trackingNumber', '') for pkg in details.get('completedPackageDetails', []) for t in pkg.get('trackingIds', []) ]), 'labels': [ ( p.get('trackingNumber', ''), next(filter(lambda d: d.get('contentType', '') == 'LABEL', p.get('packageDocuments', {})), {}).get('encodedLabel') ) for p in pieces ], 'price': price, 'documents': ', '.join([ f"{d.get('minimumCopiesRequired')}x {d.get('type', '')}" for d in details.get('documentRequirements', {}).get('generationDetails', {}) if d.get('minimumCopiesRequired', 0) ]), 'alert_message': self._process_alerts(shipment), 'invoice': next(filter( lambda d: d.get('contentType', '') == 'COMMERCIAL_INVOICE', shipment.get('shipmentDocuments', {}) ), {}).get('encodedLabel', ''), 'date': shipment.get('shipDatestamp', ''), } def _return_package(self, ship_from, ship_to_company, ship_to_wh, packages, currency, tracking, date): fedex_currency = _convert_curr_iso_fdx(currency) package_type = packages[0].packaging_type request_data = { 'accountNumber': {'value': self.account_number}, 'labelResponseOptions': 'LABEL', 'requestedShipment': { 'rateRequestType': ['PREFERRED'], 'preferredCurrency': fedex_currency, 'pickupType': self.dropoff_type, 'serviceType': self.service_type, 'packagingType': package_type, 'shippingChargesPayment': {'paymentType': 'SENDER'}, 'shipmentSpecialServices': { 'specialServiceTypes': ['RETURN_SHIPMENT'], 'returnShipmentDetail': { 'returnType': 'PRINT_RETURN_LABEL', 'returnAssociationDetail': {'trackingNumber': tracking, 'shipDatestamp': date}, } }, 'labelSpecification': {'labelStockType': self.label_stock, 'imageType': self.label_file}, 'shipper': { 'address': self._get_address_from_partner(ship_from), 'contact': self._get_contact_from_partner(ship_from), 'tins': self._get_tins_from_partner(ship_from), }, 'recipients': [{ 'address': self._get_address_from_partner(ship_to_wh), 'contact': self._get_contact_from_partner(ship_to_wh, ship_to_company), 'tins': self._get_tins_from_partner(ship_to_company, self.vat_override), }], 'requestedPackageLineItems': [self._get_detailed_package_info(p, package_type == 'YOUR_PACKAGING') for p in packages], 'customsClearanceDetail': { 'dutiesPayment': {'paymentType': 'SENDER'}, # Only allowed value for returns 'commodities': [self._get_commodities_info(c, fedex_currency) for pkg in packages for c in pkg.commodities], 'customsOption': {'type': 'REJECTED'}, } } } if request_data['requestedShipment']['shipper']['address']['countryCode'] == 'IN' and request_data['requestedShipment']['recipients'][0]['address']['countryCode'] == 'IN': request_data['requestedShipment']['customsClearanceDetail']['freightOnValue'] = 'CARRIER_RISK' if self.insurance == 100 else 'OWN_RISK' if self.vat_override or ship_to_company.vat: request_data['requestedShipment']['customsClearanceDetail']['recipientCustomsId'] = { 'type': 'COMPANY', 'value': self.vat_override or ship_to_company.vat, } self._add_extra_data_to_request(request_data, 'return') res = self._send_fedex_request("/ship/v1/shipments", request_data) try: shipment = res['transactionShipments'][0] details = shipment['completedShipmentDetail'] pieces = shipment['pieceResponses'] except KeyError: raise ValidationError(_('Could not decode response')) from None return { 'tracking_numbers': ','.join([ t.get('trackingNumber', '') for pkg in details.get('completedPackageDetails', []) for t in pkg.get('trackingIds', []) ]), 'labels': [ ( p.get('trackingNumber', ''), next(filter(lambda d: d.get('contentType', '') == 'LABEL', p.get('packageDocuments', {})), {}).get('encodedLabel') ) for p in pieces ], 'documents': ', '.join([ f"{d.get('minimumCopiesRequired')}x {d.get('type', '')}" for d in details.get('documentRequirements', {}).get('generationDetails', {}) if d.get('minimumCopiesRequired', 0) ]), 'alert_message': self._process_alerts(shipment), } def _decode_pricing(self, rating_result): actual = next(filter(lambda d: d['rateType'] == rating_result['actualRateType'], rating_result['shipmentRateDetails']), {}) if actual.get('totalNetChargeWithDutiesAndTaxes', False): return actual['totalNetChargeWithDutiesAndTaxes'] return actual['totalNetCharge'] def cancel_shipment(self, tracking_nr): res = self._send_fedex_request('/ship/v1/shipments/cancel', { 'accountNumber': {'value': self.account_number}, 'deletionControl': 'DELETE_ALL_PACKAGES', # Cancel the entire shipment, not only the individual package. 'trackingNumber': tracking_nr, }, 'PUT') if not res.get('cancelledShipment', False): return { 'delete_success': False, 'errors_message': res.get('message', 'Cancel shipment failed. Reason unknown.'), } return { 'delete_success': True, 'alert_message': self._process_alerts(res), } def _add_extra_data_to_request(self, request, request_type): """Adds the extra data to the request. When there are multiple items in a list, they will all be affected by the change. """ extra_data_input = { 'rate': self.carrier.fedex_rest_extra_data_rate_request, 'ship': self.carrier.fedex_rest_extra_data_ship_request, 'return': self.carrier.fedex_rest_extra_data_return_request, }.get(request_type) or '' try: extra_data = json.loads('{' + extra_data_input + '}') except SyntaxError: raise UserError(_('Invalid syntax for FedEx extra data.')) from None def extra_data_to_request(request, extra_data): """recursive function that adds extra data to the current request.""" for key, new_value in extra_data.items(): request[key] = current_value = request.get(key) if isinstance(current_value, list): for item in current_value: extra_data_to_request(item, new_value) elif isinstance(new_value, dict) and isinstance(current_value, dict): extra_data_to_request(current_value, new_value) else: request[key] = new_value extra_data_to_request(request, extra_data) 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)