feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
656
fusion_shipping/api/fedex_rest/request.py
Normal file
656
fusion_shipping/api/fedex_rest/request.py
Normal file
@@ -0,0 +1,656 @@
|
||||
# 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',
|
||||
}
|
||||
|
||||
FEDEX_MX_STATE_MATCH = {
|
||||
'AGU': 'AG',
|
||||
'BCN': 'BC',
|
||||
'BCS': 'BS',
|
||||
'CAM': 'CM',
|
||||
'CHH': 'CH',
|
||||
'CHP': 'CS',
|
||||
'CMX': 'DF',
|
||||
'COA': 'CO',
|
||||
'COL': 'CL',
|
||||
'DUR': 'DG',
|
||||
'GRO': 'GR',
|
||||
'GUA': 'GT',
|
||||
'HID': 'HG',
|
||||
'JAL': 'JA',
|
||||
'MEX': 'EM',
|
||||
'MIC': 'MI',
|
||||
'MOR': 'MO',
|
||||
'NAY': 'NA',
|
||||
'NLE': 'NL',
|
||||
'OAX': 'OA',
|
||||
'PUE': 'PU',
|
||||
'QUE': 'QE',
|
||||
'ROO': 'QR',
|
||||
'SIN': 'SI',
|
||||
'SLP': 'SL',
|
||||
'SON': 'SO',
|
||||
'TAB': 'TB',
|
||||
'TAM': 'TM',
|
||||
'TLA': 'TL',
|
||||
'VER': 'VE',
|
||||
'YUC': 'YU',
|
||||
'ZAC': 'ZA'
|
||||
}
|
||||
|
||||
FEDEX_AE_STATE_MATCH = {
|
||||
'AZ': 'AB',
|
||||
'AJ': 'AJ',
|
||||
'DU': 'DU',
|
||||
'FU': 'FU',
|
||||
'RK': 'RA',
|
||||
'SH': 'SH',
|
||||
'UQ': 'UM',
|
||||
}
|
||||
|
||||
FEDEX_STOCK_TYPE_MATCH = {
|
||||
'PAPER_4X6.75': 'PAPER_4X675',
|
||||
'PAPER_7X4.75': 'PAPER_7X475',
|
||||
'PAPER_8.5X11_BOTTOM_HALF_LABEL': 'PAPER_85X11_BOTTOM_HALF_LABEL',
|
||||
'PAPER_8.5X11_TOP_HALF_LABEL': 'PAPER_85X11_TOP_HALF_LABEL',
|
||||
'STOCK_4X6.75': 'STOCK_4X675',
|
||||
'STOCK_4X6.75_LEADING_DOC_TAB': 'STOCK_4X675_LEADING_DOC_TAB',
|
||||
'STOCK_4X6.75_TRAILING_DOC_TAB': 'STOCK_4X675_TRAILING_DOC_TAB',
|
||||
}
|
||||
|
||||
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 = _convert_stock_type(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 _parse_state_code(self, state_code, country_code):
|
||||
if country_code == 'CH':
|
||||
# For Switzerland, keep the part before the hyphen
|
||||
return state_code.split('-')[0]
|
||||
else:
|
||||
# For other countries, keep the part after the hyphen
|
||||
split_code = state_code.split('-')
|
||||
if split_code[0] == country_code and len(split_code) > 1:
|
||||
return split_code[1]
|
||||
else:
|
||||
return state_code
|
||||
|
||||
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:
|
||||
state_code = self._parse_state_code(partner.state_id.code, partner.country_id.code)
|
||||
# need to adhere to two character length state code
|
||||
if partner.country_id.code == 'MX':
|
||||
state_code = FEDEX_MX_STATE_MATCH[state_code]
|
||||
if partner.country_id.code == 'AE':
|
||||
state_code = FEDEX_AE_STATE_MATCH.get(state_code, state_code)
|
||||
if partner.country_id.code == 'IN' and partner.state_id.code == 'UK':
|
||||
state_code = 'UT'
|
||||
if len(state_code) <= 2:
|
||||
res['stateOrProvinceCode'] = state_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}
|
||||
if company_partner and not res['phoneNumber']:
|
||||
# Fallback to phone on the company if none on the WH
|
||||
res['phoneNumber'] = company_partner.phone
|
||||
if company_partner:
|
||||
# Always put the name of the company, if the partner is a WH
|
||||
res['companyName'] = partner.name[:35]
|
||||
res['personName'] = partner.name[:70]
|
||||
elif partner.is_company:
|
||||
res['companyName'] = partner.name[:35]
|
||||
res['personName'] = partner.name[:70]
|
||||
else:
|
||||
res['personName'] = partner.name[:70]
|
||||
if partner.parent_id:
|
||||
res['companyName'] = partner.parent_id.name[:35]
|
||||
elif partner.company_name:
|
||||
res['companyName'] = partner.company_name[:35]
|
||||
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[:50]
|
||||
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[:450],
|
||||
'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, True),
|
||||
'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)
|
||||
|
||||
|
||||
def _convert_stock_type(stock_type):
|
||||
return FEDEX_STOCK_TYPE_MATCH.get(stock_type, stock_type)
|
||||
Reference in New Issue
Block a user