Files
enterprise-addons/delivery_dhl_rest/models/delivery_dhl.py
T

562 lines
30 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from markupsafe import Markup
import json
from json import JSONDecodeError
import pytz
from datetime import timedelta
from odoo import fields, models, _
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_round, json_float_round
from odoo.tools.misc import groupby
from .dhl_request import DHLProvider
HELP_EXTRA_DATA = """Extra data to be sent in the request. It should be JSON-formatted.
This functionality is advanced/technical and should only be used if you know what you are doing.
Example of a valid value: ```
"content": {"packages": {"description": "amazing package"}}
```
With the above example, the description of each package will be updated.
For more information, please refer to the DHL API documentation: https://developer.dhl.com/api-reference/dhl-express-mydhl-api.
"""
LABEL_FORMAT_MAPPING = {
'8X4_A4_PDF': 'ECOM26_84_A4_001',
'8X4_thermal': 'ECOM26_84_001',
'8X4_A4_TC_PDF': 'ECOM_TC_A4',
'6X4_thermal': 'ECOM26_A6_002',
'6X4_A4_PDF': 'ECOM26_A6_002',
'8X4_CI_PDF': 'ECOM26_84CI_001',
'8X4_CI_thermal': 'ECOM26_84CI_001',
'8X4_RU_A4_PDF': 'ECOM_A4_RU_002',
'6X4_PDF': 'ECOM26_A6_002',
'8X4_PDF': 'ECOM26_84_001',
}
class ProviderDHL(models.Model):
_inherit = 'delivery.carrier'
delivery_type = fields.Selection(selection_add=[
('dhl_rest', "DHL")
], ondelete={'dhl_rest': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})})
dhl_api_key = fields.Char(string="DHL API Key", groups="base.group_system")
dhl_api_secret = fields.Char(string="DHL API Secret", groups="base.group_system")
dhl_account_number = fields.Char(string="DHL Account Number", groups="base.group_system")
dhl_unit_system = fields.Selection([('imperial', 'Imperial'),
('metric', 'Metric')],
default='metric',
string='Unit System')
dhl_default_package_type_id = fields.Many2one('stock.package.type', string='DHLxw Package Type')
dhl_region_code = fields.Selection([('AP', 'Asia Pacific'),
('AM', 'America'),
('EU', 'Europe')],
default='AM',
string='Region')
# Nowadays hidden, by default it's the D, couldn't find any documentation on other services
dhl_product_code = fields.Selection([('0', '0 - Logistics Services'),
('1', '1 - Domestic Express 12:00'),
('2', '2 - B2C'),
('3', '3 - B2C'),
('4', '4 - Jetline'),
('5', '5 - Sprintline'),
('6', '6 - Secureline'),
('7', '7 - Express Easy'),
('8', '8 - Express Easy'),
('9', '9 - Europack'),
('A', 'A - Auto Reversals'),
('B', 'B - Break Bulk Express'),
('C', 'C - Medical Express'),
('D', 'D - Express Worldwide'),
('E', 'E - Express 9:00'),
('F', 'F - Freight Worldwide'),
('G', 'G - Domestic Economy Select'),
('H', 'H - Economy Select'),
('I', 'I - Break Bulk Economy'),
('J', 'J - Jumbo Box'),
('K', 'K - Express 9:00'),
('L', 'L - Express 10:30'),
('M', 'M - Express 10:30'),
('N', 'N - Domestic Express'),
('O', 'O - DOM Express 10:30'),
('P', 'P - Express Worldwide'),
('Q', 'Q - Medical Express'),
('R', 'R - GlobalMail Business'),
('S', 'S - Same Day'),
('T', 'T - Express 12:00'),
('U', 'U - Express Worldwide'),
('V', 'V - Europack'),
('W', 'W - Economy Select'),
('X', 'X - Express Envelope'),
('Y', 'Y - Express 12:00'),
('Z', 'Z - Destination Charges'),
],
default='D',
string='DHL Product')
dhl_dutiable = fields.Boolean(string="Dutiable Material", help="Check this if your package is dutiable.")
dhl_duty_payment = fields.Selection([('S', 'Sender'), ('R', 'Recipient')], required=True, default="S")
dhl_label_image_format = fields.Selection([('EPL2', 'EPL2'),
('PDF', 'PDF'),
('ZPL2', 'ZPL2')], string="Label Image Format", default='PDF')
dhl_label_template = fields.Selection([('8X4_A4_PDF', '8X4_A4_PDF'),
('8X4_thermal', '8X4_thermal'),
('8X4_A4_TC_PDF', '8X4_A4_TC_PDF'),
('6X4_thermal', '6X4_thermal'),
('6X4_A4_PDF', '6X4_A4_PDF'),
('8X4_CI_PDF', '8X4_CI_PDF'),
('8X4_CI_thermal', '8X4_CI_thermal'),
('8X4_RU_A4_PDF', '8X4_RU_A4_PDF'),
('6X4_PDF', '6X4_PDF'),
('8X4_PDF', '8X4_PDF')], string="Label Template", default='8X4_A4_PDF')
dhl_extra_data_rate_request = fields.Text('Extra data for rate requests', help=HELP_EXTRA_DATA)
dhl_extra_data_ship_request = fields.Text('Extra data for ship requests', help=HELP_EXTRA_DATA)
dhl_extra_data_return_request = fields.Text('Extra data for return requests', help=HELP_EXTRA_DATA)
def _compute_can_generate_return(self):
super()._compute_can_generate_return()
for carrier in self:
if carrier.delivery_type == 'dhl_rest':
carrier.can_generate_return = True
def _compute_supports_shipping_insurance(self):
super()._compute_supports_shipping_insurance()
for carrier in self:
if carrier.delivery_type == 'dhl_rest':
carrier.supports_shipping_insurance = True
def dhl_rest_rate_shipment(self, order):
res = self._rate_shipment_vals(order=order)
return res
def _dhl_rest_get_order_packages(self, order):
self.ensure_one()
total_weight = order._get_estimated_weight()
total_weight = self._dhl_rest_convert_weight(total_weight)
if total_weight == 0.0:
weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
raise UserError(_("The package cannot be created because the total weight of the products in the picking is 0.0 %s", weight_uom_name))
max_weight = self.dhl_default_package_type_id.max_weight or total_weight + 1
number_of_packages = int(total_weight / max_weight)
last_package_weight = total_weight % max_weight
weights = [max_weight] * number_of_packages + ([last_package_weight] if last_package_weight else [])
return [{
'weight': weight,
'dimension': {
'length': self.dhl_default_package_type_id.packaging_length,
'width': self.dhl_default_package_type_id.width,
'height': self.dhl_default_package_type_id.height,
},
} for weight in weights]
def _dhl_rest_get_commodities_from_stock_move_lines(self, move_lines):
commodities = []
product_lines = move_lines.filtered(lambda line: line.product_id.type in ['product', 'consu'])
for product, lines in groupby(product_lines, lambda x: x.product_id):
unit_quantity = sum(
line.product_uom_id._compute_quantity(
line.quantity,
product.uom_id)
for line in lines)
rounded_qty = max(1, float_round(unit_quantity, precision_digits=0))
country_of_origin = lines[0].picking_id.picking_type_id.warehouse_id.partner_id.country_id.code
unit_price = sum(line.sale_price for line in lines) / rounded_qty
commodities.append({
'product_id': product,
'qty': rounded_qty,
'monetary_value': unit_price,
'country_of_origin': country_of_origin
})
return commodities
def _dhl_rest_get_picking_packages(self, picking):
self.ensure_one()
packages = []
if picking.is_return_picking:
commodities = self._dhl_rest_get_commodities_from_stock_move_lines(picking.move_line_ids)
weight = picking._get_estimated_weight()
packages.append({
'commodities': commodities,
'weight': weight,
'dimension': {
'length': self.dhl_default_package_type_id.packaging_length,
'width': self.dhl_default_package_type_id.width,
'height': self.dhl_default_package_type_id.height,
},
})
return packages
# Create all packages.
for package in picking.move_line_ids.result_package_id:
move_lines = picking.move_line_ids.filtered(lambda ml: ml.result_package_id == package)
commodities = self._dhl_rest_get_commodities_from_stock_move_lines(move_lines)
packages.append({
'commodities': commodities,
'weight': package.shipping_weight or package.weight,
'dimension': {
'length': package.package_type_id.packaging_length,
'width': package.package_type_id.width,
'height': package.package_type_id.height,
},
'name': package.name,
})
# Create one package: either everything is in pack or nothing is.
if picking.weight_bulk:
commodities = self._dhl_rest_get_commodities_from_stock_move_lines(picking.move_line_ids)
packages.append({
'commodities': commodities,
'weight': picking.weight_bulk,
'dimension': {
'length': self.dhl_default_package_type_id.packaging_length,
'width': self.dhl_default_package_type_id.width,
'height': self.dhl_default_package_type_id.height,
},
'name': 'Bulk Content'
})
elif not packages:
raise UserError(_(
"The package cannot be created because the total weight of the "
"products in the picking is 0.0 %s",
picking.weight_uom_name
))
return packages
def _convert_to_utc_string(self, datetime_object):
return datetime_object.astimezone(tz=pytz.utc).strftime('%Y-%m-%dT%H:%M:%S GMT+00:00')
def _get_dhl_label_format(self):
match self.dhl_label_image_format:
case 'ZPL2':
return 'zpl'
case 'EPL2':
return 'epl'
case _:
return 'pdf'
def _rate_shipment_vals(self, order=False, picking=False):
if picking:
warehouse_partner_id = picking.picking_type_id.warehouse_id.partner_id
currency_id = picking.sale_id.currency_id or picking.company_id.currency_id
destination_partner_id = picking.partner_id
total_value = sum(sml.sale_price for sml in picking.move_line_ids)
planned_date = picking.scheduled_date + timedelta(hours=1)
else:
warehouse_partner_id = order.warehouse_id.partner_id
currency_id = order.currency_id or order.company_id.currency_id
total_value = sum(line.price_reduce_taxinc * line.product_uom_qty for line in order.order_line.filtered(lambda l: l.product_id.type in ('consu', 'product') and not l.display_type))
destination_partner_id = order.partner_shipping_id
if hasattr(order, 'website_id') and order.website_id:
planned_date = fields.Datetime.now() + timedelta(hours=1)
else:
planned_date = order.date_order + timedelta(hours=1)
rating_request = {}
account_number = self.sudo().dhl_account_number
srm = DHLProvider(self)
check_value = srm._check_required_value(self, destination_partner_id, warehouse_partner_id, order=order, picking=picking)
if check_value:
return {'success': False,
'price': 0.0,
'error_message': check_value,
'warning_message': False}
rating_request['customerDetails'] = {
'shipperDetails': srm._get_from_vals(warehouse_partner_id),
'receiverDetails': srm._get_to_vals(destination_partner_id)
}
if picking:
packages = self._dhl_rest_get_picking_packages(picking)
else:
packages = self._dhl_rest_get_order_packages(order)
rating_request['packages'] = srm._get_package_vals(self, packages)
rating_request['isCustomsDeclarable'] = self.dhl_dutiable
if self.dhl_dutiable:
rating_request['monetaryAmount'] = srm._get_dutiable_vals(total_value, currency_id.name)
rating_request['unitOfMeasurement'] = self.dhl_unit_system
if planned_date <= fields.Datetime.now():
raise UserError(_("The planned date for the shipment must be in the future."))
rating_request['plannedShippingDateAndTime'] = self._convert_to_utc_string(planned_date)
rating_request['nextBusinessDay'] = True
rating_request['accounts'] = srm._get_billing_vals(account_number, "shipper")
self._dhl_add_extra_data_to_request(rating_request, 'rate')
rating_request['productsAndServices'] = [{
'productCode': self.dhl_product_code,
'valueAddedServices': [],
}]
if self.supports_shipping_insurance and self.shipping_insurance:
rating_request['productsAndServices'][0]['valueAddedServices'].append(srm._get_insurance_vals(self.shipping_insurance, total_value, currency_id.name))
response = srm._get_rates(rating_request)
available_product_code = []
shipping_charge = False
products = response['products']
for product in products:
charge = [price for price in product['totalPrice'] if price['currencyType'] == 'BILLC'] # get the price in the billing currency
global_product_code = product['productCode']
if global_product_code == self.dhl_product_code and charge:
shipping_charge = charge[0]['price']
shipping_currency = charge[0]['priceCurrency']
break
else:
available_product_code.append(global_product_code)
if shipping_charge:
if order:
order_currency = order.currency_id
else:
order_currency = picking.sale_id.currency_id or picking.company_id.currency_id
if shipping_currency is None or order_currency.name == shipping_currency:
price = float(shipping_charge)
else:
quote_currency = self.env['res.currency'].search([('name', '=', shipping_currency)], limit=1)
price = quote_currency._convert(float(shipping_charge), order_currency, (order or picking).company_id, order.date_order if order else fields.Date.today())
if self.supports_shipping_insurance and self.shipping_insurance:
for product in products:
services = []
for price_breakdown in product['detailedPriceBreakdown']:
services.extend([service['serviceCode'] for service in price_breakdown['breakdown'] if 'serviceCode' in service])
if 'II' not in services:
return {'success': False,
'price': 0.0,
'error_message': _("Shipment insurance is not available between the origin and destination. You should try with another DHL product, or select a delivery method with no insurance."),
'warning_message': False}
return {'success': True,
'price': price,
'error_message': False,
'warning_message': False}
if available_product_code:
return {'success': False,
'price': 0.0,
'error_message': _(
"There is no price available for this shipping, you should rather try with the DHL product %s",
available_product_code[0]),
'warning_message': False}
def dhl_rest_send_shipping(self, pickings):
res = []
for picking in pickings:
shipment_request = {}
srm = DHLProvider(self)
account_number = self.sudo().dhl_account_number
planned_date = picking.scheduled_date
scheduled_date_warning = False
if not planned_date or planned_date <= fields.Datetime.now():
# DHL requires a planned date in the future => +1 hr to now
planned_date = fields.Datetime.now() + timedelta(hours=1)
scheduled_date_warning = _(" Extra Info: The scheduled time for the shipment was either not set or was in the past, so DHL has been sent a planned shipping time of one hour from now")
shipment_request['plannedShippingDateAndTime'] = self._convert_to_utc_string(planned_date)
shipment_request['pickup'] = {'isRequested': True}
shipment_request['accounts'] = srm._get_billing_vals(account_number, "shipper")
shipment_request['customerDetails'] = {}
shipment_request['customerDetails']['receiverDetails'] = srm._get_consignee_vals(picking.partner_id)
shipment_request['customerDetails']['shipperDetails'] = srm._get_shipper_vals(picking.company_id.partner_id, picking.picking_type_id.warehouse_id.partner_id)
shipment_request['productCode'] = self.dhl_product_code
shipment_request['customerReferences'] = [{
'value': picking.sale_id.name if picking.sale_id else picking.name,
'typeCode': 'CU'
}]
shipment_request['content'] = {}
shipment_request['content']['description'] = picking.sale_id.name if picking.sale_id else picking.name
shipment_request['content']['unitOfMeasurement'] = self.dhl_unit_system
incoterm = picking.sale_id.incoterm or self.env.company.incoterm_id
shipment_request['content']['incoterm'] = incoterm.code or 'EXW'
total_value, currency_name = self._dhl_calculate_value(picking)
shipment_request['content']['isCustomsDeclarable'] = self.dhl_dutiable
if self.dhl_dutiable:
shipment_request['content']['declaredValue'] = total_value
shipment_request['content']['declaredValueCurrency'] = currency_name
if picking._should_generate_commercial_invoice():
shipment_request['content']['exportDeclaration'] = srm._get_export_declaration_vals(self, picking)
shipment_request['content']['declaredValueCurrency'] = currency_name
shipment_request['content']['packages'] = srm._get_shipment_vals(picking)
shipment_request['outputImageProperties'] = {
'encodingFormat': self._get_dhl_label_format(),
}
shipment_request['outputImageProperties']['imageOptions'] = [{
'typeCode': 'label',
'templateName': LABEL_FORMAT_MAPPING.get(self.dhl_label_template, 'ECOM26_84_001'),
'fitLabelsToA4': self.dhl_label_image_format == 'PDF' and 'A4' in self.dhl_label_template,
}]
if self.supports_shipping_insurance and self.shipping_insurance:
shipment_request['valueAddedServices'] = [srm._get_insurance_vals(self.shipping_insurance, total_value, currency_name)]
self._dhl_add_extra_data_to_request(shipment_request, 'ship')
dhl_response = srm._send_shipment(shipment_request)
tracking_number = dhl_response['shipmentTrackingNumber']
logmessage = Markup("%s<br/><b>%s:</b> %s") % (_("Shipment created into DHL"), _("Tracking Number"), tracking_number)
if scheduled_date_warning:
logmessage += scheduled_date_warning
dhl_labels = [
(
'LabelShipping-DHL-{}.{}'.format(tracking_number, document['imageFormat']),
base64.b64decode(document['content'])
)
for document in dhl_response['documents'] if document['typeCode'] == 'label'
]
other_documents = [
(
'ShippingDoc-DHL-{}.{}'.format(document['packageReferenceNumber'], document['imageFormat']),
base64.b64decode(document['content'])
)
for document in dhl_response['documents'] if document['typeCode'] != 'label'
]
lognote_pickings = picking.sale_id.picking_ids if picking.sale_id else picking
for pick in lognote_pickings:
pick.message_post(body=logmessage, attachments=dhl_labels)
if other_documents:
pick.message_post(body=_("DHL Documents"), attachments=other_documents)
shipping_data = {
'exact_price': 0,
'tracking_number': tracking_number,
}
rate = self._rate_shipment_vals(picking=picking)
shipping_data['exact_price'] = rate['price']
if self.return_label_on_delivery:
self.get_return_label(picking)
res = res + [shipping_data]
return res
def dhl_rest_get_return_label(self, picking, tracking_number=None, origin_date=None):
shipment_request = {}
srm = DHLProvider(self)
account_number = self.sudo().dhl_account_number
planned_date = picking.scheduled_date
scheduled_date_warning = False
if not planned_date or planned_date <= fields.Datetime.now():
# DHL requires a planned date in the future => +1 hr to now
planned_date = fields.Datetime.now() + timedelta(hours=1)
scheduled_date_warning = _(" Extra Info: The scheduled time for the shipment was either not set or was in the past, so DHL has been sent a planned shipping time of one hour from now")
shipment_request['plannedShippingDateAndTime'] = self._convert_to_utc_string(planned_date)
shipment_request['pickup'] = {'isRequested': False}
shipment_request['accounts'] = srm._get_billing_vals(account_number, "shipper")
shipment_request['customerDetails'] = {
'shipperDetails': srm._get_shipper_vals(picking.partner_id, picking.partner_id),
'receiverDetails': srm._get_consignee_vals(picking.picking_type_id.warehouse_id.partner_id)
}
shipment_request['productCode'] = self.dhl_product_code
shipment_request['content'] = {
'description': picking.sale_id.name if picking.sale_id else picking.name,
'unitOfMeasurement': self.dhl_unit_system,
'incoterm': picking.sale_id.incoterm.code or self.env.company.incoterm_id.code or 'EXW',
'isCustomsDeclarable': self.dhl_dutiable,
'packages': srm._get_shipment_vals(picking)
}
total_value, currency_name = self._dhl_calculate_value(picking)
if self.dhl_dutiable:
shipment_request['content']['declaredValue'] = total_value
shipment_request['content']['declaredValueCurrency'] = currency_name
if picking._should_generate_commercial_invoice():
shipment_request['content']['exportDeclaration'] = srm._get_export_declaration_vals(self, picking)
shipment_request['content']['declaredValueCurrency'] = currency_name
shipment_request['outputImageProperties'] = {
'imageOptions': [{
'typeCode': 'label',
'templateName': self.dhl_label_template,
}]
}
shipment_request['valueAddedServices'] = [{'serviceCode': 'PV'}]
self._dhl_add_extra_data_to_request(shipment_request, 'return')
dhl_response = srm._send_shipment(shipment_request)
tracking_number = dhl_response['shipmentTrackingNumber']
logmessage = Markup("%s<br/><b>%s:</b> %s") % (_("Shipment created into DHL"), _("Tracking Number"), tracking_number)
if scheduled_date_warning:
logmessage += scheduled_date_warning
dhl_labels = [
(
'LabelReturn-DHL-{}.{}'.format(tracking_number, document['imageFormat']),
base64.b64decode(document['content'])
)
for document in dhl_response['documents'] if document['typeCode'] == 'label'
]
other_documents = [
(
'ShippingDoc-DHL-{}.{}'.format(document['packageReferenceNumber'], document['imageFormat']),
base64.b64decode(document['content'])
)
for document in dhl_response['documents'] if document['typeCode'] != 'label'
]
lognote_pickings = picking.sale_id.picking_ids if picking.sale_id else picking
for pick in lognote_pickings:
pick.message_post(body=logmessage, attachments=dhl_labels)
if other_documents:
pick.message_post(body=_("DHL Documents"), attachments=other_documents)
shipping_data = {
'exact_price': 0,
'tracking_number': tracking_number,
}
return shipping_data
def dhl_rest_get_tracking_link(self, picking):
return f'http://www.dhl.com/en/express/tracking.html?AWB={picking.carrier_tracking_ref}'
def dhl_rest_cancel_shipment(self, picking):
# Obviously you need a pick up date to delete SHIPMENT by DHL. So you can't do it if you didn't schedule a pick-up.
picking.message_post(body=_("You can't cancel DHL shipping without pickup date."))
picking.write({'carrier_tracking_ref': '', 'carrier_price': 0.0})
def _dhl_rest_convert_weight(self, weight):
weight_uom_id = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter()
unit = self.dhl_unit_system
if unit == 'imperial':
weight = weight_uom_id._compute_quantity(weight, self.env.ref('uom.product_uom_lb'), round=False)
else:
weight = weight_uom_id._compute_quantity(weight, self.env.ref('uom.product_uom_kgm'), round=False)
# float_round doesn't work here, for example float_round(0.7000000000000001, 3) = 0.7000000000000001
return json_float_round(weight, 3)
def _dhl_calculate_value(self, picking):
sale_order = picking.sale_id
if sale_order:
total_value = sum(
line.price_reduce_taxinc * line.product_uom_qty
for line in sale_order.order_line
if not line.display_type
if line.product_id.type in ['product', 'consu']
)
currency_name = picking.sale_id.currency_id.name
else:
total_value = sum(line.product_id.lst_price * line.product_qty for line in picking.move_ids)
currency_name = picking.company_id.currency_id.name
return total_value, currency_name
def _dhl_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.
for example, with
{"content": {"packages": {"description": "amazon package"}}}
the description of each piece will be updated."""
extra_data_input = {
'rate': self.dhl_extra_data_rate_request,
'ship': self.dhl_extra_data_ship_request,
'return': self.dhl_extra_data_return_request,
}.get(request_type) or ''
try:
extra_data = json.loads('{' + extra_data_input + '}')
except JSONDecodeError:
raise UserError(_("Invalid syntax for DHL extra data."))
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():
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)