Files
enterprise-addons/delivery_usps_rest/models/delivery_usps.py
T

480 lines
30 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_round, float_is_zero
from odoo.tools.misc import groupby
from .usps_request import USPSRequest, split_zip
HELP_EXTRA_DATA = """Extra data to be sent in the request. It should be a JSON-formatted string. For example:
This functionality is advanced/technical and should only be used if you know what you are doing.
Example of a valid value: ```
"packageDescription": {"extraServices": [920] }
```
With the above example, the service (920) will be added to the shipment request.
For more information, please refer to the USS API documentation: https://developer.usps.com/apis.
"""
class ProviderUSPS(models.Model):
_inherit = 'delivery.carrier'
delivery_type = fields.Selection(selection_add=[
('usps_rest', "USPS")
], ondelete={'usps_rest': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})})
# Fields required to configure
usps_eps_account_number = fields.Char(string='EPS Account Number', groups="base.group_system")
usps_crid = fields.Char(string='CRID', groups="base.group_system")
usps_mid = fields.Char(string='MID', groups="base.group_system")
usps_manifest_mid = fields.Char(string='Manifest MID', groups="base.group_system")
usps_api_key = fields.Char(string='USPS API Key', groups="base.group_system")
usps_api_secret = fields.Char(string='USPS API Secret', groups="base.group_system")
usps_access_token = fields.Char(string='USPS Access Token', groups="base.group_system")
usps_payment_token = fields.Char(string='USPS Payment Token', groups="base.group_system")
usps_delivery_nature = fields.Selection([('domestic', 'Domestic'),
('international', 'International')],
string="Delivery Nature", default='domestic', required=True)
usps_default_package_type_id = fields.Many2one('stock.package.type', string="USPS Package Type")
usps_rest_label_file_type = fields.Selection([('PDF', 'PDF'),
('TIFF', 'TIFF'),
('JPG', 'JPG'),
('SVG', 'SVG')],
string="USPS Label File Type", default='PDF')
usps_label_size = fields.Selection([('4X6LABEL', '4x6'),
('4X5LABEL', '4x5'),
('3X5FMP', '3x5')], string="USPS Label Size", default='4X6LABEL')
usps_domestic_service = fields.Selection([('PARCEL_SELECT', 'Parcel Select'),
('PARCEL_SELECT_LIGHTWEIGHT', 'Parcel Select Lightweight'),
('PRIORITY_MAIL_EXPRESS', 'Priority Mail Express'),
('PRIORITY_MAIL', 'Priority Mail'),
('FIRST-CLASS_PACKAGE_SERVICE', 'First-Class Package Service'),
('LIBRARY_MAIL', 'Library Mail'),
('MEDIA_MAIL', 'Media Mail'),
('BOUND_PRINTED_MATTER', 'Bound Printed Matter'),
('USPS_CONNECT_LOCAL', 'USPS Connect Local'),
('USPS_CONNECT_MAIL', 'USPS Connect Mail'),
('USPS_CONNECT_NEXT_DAY', 'USPS Connect Next Day'),
('USPS_CONNECT_REGIONAL', 'USPS Connect Regional'),
('USPS_CONNECT_SAME_DAY', 'USPS Connect Same Day'),
('USPS_GROUND_ADVANTAGE', 'USPS Ground Advantage'),
('USPS_GROUND_ADVANTAGE_RETURN_SERVICE', 'USPS Ground Advantage Return Service'),
('USPS_RETAIL_GROUND', 'USPS Retail Ground')],
string="USPS Domestic Service", default="PARCEL_SELECT")
usps_international_service = fields.Selection([('FIRST-CLASS_PACKAGE_INTERNATIONAL_SERVICE', 'First-Class Package International Service'),
('PRIORITY_MAIL_INTERNATIONAL', 'Priority Mail International'),
('PRIORITY_MAIL_EXPRESS_INTERNATIONAL', 'Priority Mail Express International'),
('GLOBAL_EXPRESS_GUARANTEED', 'Global Express Guaranteed')],
string="USPS International Service", default="PRIORITY_MAIL_INTERNATIONAL")
usps_rest_content_type = fields.Selection([('MERCHANDISE', 'Merchandise'),
('SAMPLE', 'Sample'),
('GIFT', 'Gift'),
('DOCUMENT', 'Document'),
('COMMERCIAL_SAMPLE', 'Commercial Sample'),
('RETURNED_GOODS', 'Returned Goods'),
('HUMANITARIAN_DONATIONS', 'Humanitarian Donations'),
('DANGEROUS_GOODS', 'Dangerous Goods'),
('CREMATED_REMAINS', 'Cremated Remains'),
('NON_NEGOTIABLE_DOCUMENT', 'Non-Negotiable Document'),
('MEDICAL_SUPPLIES', 'Medical Supplies'),
('PHARMACEUTICALS', 'Pharmaceuticals'),
('OTHER', 'Other')],
default='MERCHANDISE', string="Content Type (REST)")
usps_domestic_rating_indicator = fields.Selection([('3D', '3D - 3-Digit'),
('3N', '3N - 3-Digit Dimensional Rectangular'),
('3R', '3R - 3-Digit Dimensional Nonrectangular'),
('5D', '5D - 5-Digit'),
('BA', 'BA - Basic'),
('BB', 'BB - Mixed NDC'),
('CP', 'CP - Cubic Parcel'),
('CM', 'CM - USPS Connect® Local Mail'),
('DC', 'DC - NDC'),
('DE', 'DE - SCF'),
('DF', 'DF - 5-Digit'),
('DN', 'DN - Dimensional Nonrectangular'),
('DR', 'DR - Dimensional Rectangular'),
('E4', 'E4 - Priority Mail Express Flat Rate Envelope - Post Office To Addressee'),
('E6', 'E6 - Priority Mail Express Legal Flat Rate Envelope'),
('FA', 'FA - Legal Flat Rate Envelope'),
('FB', 'FB - Medium Flat Rate Box/Large Flat Rate Bag'),
('FE', 'FE - Flat Rate Envelope'),
('FP', 'FP - Padded Flat Rate Envelope'),
('FS', 'FS - Small Flat Rate Box'),
('LC', 'LC - USPS Connect® Local Single Piece'),
('LF', 'LF - Flat Rate Box'),
('LL', 'LL - Large Flat Rate Bag'),
('LO', 'LO - USPS Connect® Local Oversized'),
('LS', 'LS - Small Flat Rate Bag'),
('NP', 'NP - Non-Presorted'),
('OS', 'OS - Oversized'),
('P5', 'P5 - Cubic Soft Pack Tier 1'),
('P6', 'P6 - Cubic Soft Pack Tier 2'),
('P7', 'P7 - Cubic Soft Pack Tier 3'),
('P8', 'P8 - Cubic Soft Pack Tier 4'),
('P9', 'P9 - Cubic Soft Pack Tier 5'),
('Q6', 'Q6 - Cubic Soft Pack Tier 6'),
('Q7', 'Q7 - Cubic Soft Pack Tier 7'),
('Q8', 'Q8 - Cubic Soft Pack Tier 8'),
('Q9', 'Q9 - Cubic Soft Pack Tier 9'),
('Q0', 'Q0 - Cubic Soft Pack Tier 10'),
('PA', 'PA - Priority Mail Express Single Piece'),
('PL', 'PL - Large Flat Rate Box'),
('PM', 'PM - Large Flat Rate Box APO/FPO/DPO'),
('PR', 'PR - Presorted'),
('SN', 'SN - SCF Dimensional Nonrectangular'),
('SP', 'SP - Single Piece'),
('SR', 'SR - SCF Dimensional Rectangular')], string="Domestic Rating Indicator")
usps_international_rating_indicator = fields.Selection([('E4', 'E4 - Priority Mail Express Flat Rate Envelope - Post Office To Addressee'),
('E6', 'E6 - Priority Mail Express Legal Flat Rate Envelope'),
('E7', 'E7 - Priority Mail Express Legal Flat Rate Envelope Sunday / Holiday'),
('FA', 'FA - Legal Flat Rate Envelope'),
('FB', 'FB - Medium Flat Rate Box/Large Flat Rate Bag'),
('FE', 'FE - Flat Rate Envelope'),
('FP', 'FP - Padded Flat Rate Envelope'),
('FS', 'FS - Small Flat Rate Box'),
('PA', 'PA - Priority Mail Express International Single Piece'),
('PL', 'PL - Large Flat Rate Box'),
('SP', 'SP - Single Piece')
], string="International Rating Indicator")
usps_processing_category = fields.Selection([('LETTERS', 'Letters'),
('FLATS', 'Flats'),
('MACHINABLE', 'Machinable'),
('IRREGULAR', 'Irregular'),
('NON_MACHINABLE', 'Non-Machinable')],
string="Processing Category", help="Please check on USPS website to check your package processing category")
usps_extra_data_rate_request = fields.Text('Extra Data for Rate Requests', help=HELP_EXTRA_DATA)
usps_extra_data_shipment_request = fields.Text('Extra Data for Shipment Requests', help=HELP_EXTRA_DATA)
usps_extra_data_payment_token_request = fields.Text('Extra Data for Payment Token Requests', help=HELP_EXTRA_DATA)
@api.depends('usps_delivery_nature')
def _compute_can_generate_return(self):
super()._compute_can_generate_return()
for carrier in self:
if carrier.delivery_type == 'usps_rest':
if carrier.usps_delivery_nature == 'international':
carrier.can_generate_return = False
else:
carrier.can_generate_return = True
def _usps_convert_weight(self, weight):
weight_uom_id = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter()
weight_in_pounds = weight_uom_id._compute_quantity(weight, self.env.ref('uom.product_uom_lb'))
return weight_in_pounds
def _usps_convert_length(self, length):
length_uom_id = self.env['product.template']._get_length_uom_id_from_ir_config_parameter()
length_in_inches = length_uom_id._compute_quantity(length, self.env.ref('uom.product_uom_inch'))
return length_in_inches
def _usps_rest_get_order_packages(self, order):
self.ensure_one()
total_weight = order._get_estimated_weight()
if float_is_zero(total_weight, precision_rounding=0.01):
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))
total_weight = self._usps_convert_weight(total_weight)
max_weight = self.usps_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.usps_default_package_type_id.packaging_length,
'width': self.usps_default_package_type_id.width,
'height': self.usps_default_package_type_id.height,
},
} for weight in weights]
def usps_rest_rate_shipment(self, order):
srm = USPSRequest(self)
check_result = srm.check_required_value(order.partner_shipping_id, self.usps_delivery_nature, order.warehouse_id.partner_id, order=order)
if check_result:
return {'success': False, 'price': 0.0, 'error_message': check_result, 'warning_message': False}
packages = self._usps_rest_get_order_packages(order)
quotes_list = []
for package in packages:
request_body = {
'originZIPCode': split_zip(order.warehouse_id.partner_id.zip)[0],
'weight': package['weight'],
'length': self._usps_convert_length(package['dimension']['length']),
'width': self._usps_convert_length(package['dimension']['width']),
'height': self._usps_convert_length(package['dimension']['height']),
'accountType': 'EPS',
'accountNumber': self.sudo().usps_eps_account_number,
'mailingDate': order.date_order.strftime('%Y-%m-%d'),
}
if self.usps_delivery_nature == 'domestic':
request_body['destinationZIPCode'] = split_zip(order.partner_shipping_id.zip)[0]
else:
request_body['destinationCountryCode'] = order.partner_shipping_id.country_id.code
request_body['foreignPostalCode'] = order.partner_shipping_id.zip
self._usps_add_extra_data_to_request(request_body, 'rate')
rates_response = srm._get_rates(request_body)
quotes_list.append(rates_response.get('rateOptions', []))
price = 0
mail_class, rate_indicator = (self.usps_domestic_service, self.usps_domestic_rating_indicator) if self.usps_delivery_nature == 'domestic' else (self.usps_international_service, self.usps_international_rating_indicator)
mc_exists_for_all_packages = True
for quotes in quotes_list:
matching_quotes = [quote for quote in quotes if (
quote['rates'][0]['mailClass'] == mail_class and
quote['rates'][0]['rateIndicator'] == rate_indicator
)]
if not matching_quotes:
mc_exists_for_all_packages = False
break
price += matching_quotes[0].get('totalBasePrice')
if mc_exists_for_all_packages:
if order.currency_id.name == 'USD':
return {'success': True, 'price': price, 'error_message': False, 'warning_message': False}
else:
quote_currency = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)
price = quote_currency._convert(price, order.currency_id, order.company_id, order.date_order or fields.Date.today())
return {'success': True, 'price': price, 'error_message': False, 'warning_message': False}
else:
return {
'success': False,
'price': 0.0,
'error_message': _("There is no price available for this shipping, please choose another service"),
'warning_message': False
}
def _usps_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 _usps_rest_get_picking_packages(self, picking):
self.ensure_one()
packages = []
if picking.is_return_picking:
commodities = self._usps_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.usps_default_package_type_id.packaging_length,
'width': self.usps_default_package_type_id.width,
'height': self.usps_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._usps_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,
},
})
# Create one package: either everything is in pack or nothing is.
if picking.weight_bulk:
commodities = self._usps_rest_get_commodities_from_stock_move_lines(picking.move_line_ids)
packages.append({
'commodities': commodities,
'weight': picking.weight_bulk,
'dimension': {
'length': self.usps_default_package_type_id.packaging_length,
'width': self.usps_default_package_type_id.width,
'height': self.usps_default_package_type_id.height,
},
})
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 usps_rest_send_shipping(self, pickings):
res = []
srm = USPSRequest(self)
for picking in pickings:
check_result = srm.check_required_value(picking.partner_id, self.usps_delivery_nature, picking.picking_type_id.warehouse_id.partner_id, picking=picking)
if check_result:
raise UserError(check_result)
image_info = {
'imageType': self.usps_rest_label_file_type,
'labelType': self.usps_label_size,
}
if self.return_label_on_delivery and self.usps_delivery_nature == 'domestic':
image_info.update({
'returnLabel': True,
})
to_address = {
'streetAddress': picking.partner_id.street,
'secondaryAddress': picking.partner_id.street2 or '',
'city': picking.partner_id.city,
'state': picking.partner_id.state_id.code,
'ZIPCode': split_zip(picking.partner_id.zip)[0],
'ZIPPlus4': split_zip(picking.partner_id.zip)[1] or None,
'firstName': picking.partner_id.name or '.',
'lastName': picking.partner_id.company_name or '.',
}
if self.usps_delivery_nature == 'international':
to_address.update({
'country': picking.partner_id.country_id.name,
'countryISOAlpha2Code': picking.partner_id.country_id.code,
})
from_address = {
'streetAddress': picking.picking_type_id.warehouse_id.partner_id.street,
'secondaryAddress': picking.picking_type_id.warehouse_id.partner_id.street2 or '',
'city': picking.picking_type_id.warehouse_id.partner_id.city,
'state': picking.picking_type_id.warehouse_id.partner_id.state_id.code,
'ZIPCode': split_zip(picking.picking_type_id.warehouse_id.partner_id.zip)[0],
'ZIPPlus4': split_zip(picking.picking_type_id.warehouse_id.partner_id.zip)[1] or None,
'firstName': picking.picking_type_id.warehouse_id.partner_id.name or '.',
'lastName': picking.picking_type_id.warehouse_id.partner_id.company_name or '.',
}
customs_form = {
'AESITN': 'XYZ',
'customsContentType': self.usps_rest_content_type,
}
packages = self._usps_rest_get_picking_packages(picking)
shipping_data = {
'exact_price': [],
'tracking_number': [],
}
for package in packages:
package_description = {
'mailClass': self.usps_domestic_service if self.usps_delivery_nature == 'domestic' else self.usps_international_service,
'weightUOM': 'lb',
'weight': self._usps_convert_weight(package['weight']),
'dimensionUOM': 'in',
'length': self._usps_convert_length(package['dimension']['length']),
'width': self._usps_convert_length(package['dimension']['width']),
'height': self._usps_convert_length(package['dimension']['height']),
'rateIndicator': self.usps_domestic_rating_indicator if self.usps_delivery_nature == 'domestic' else self.usps_international_rating_indicator,
'processingCategory': self.usps_processing_category,
'destinationEntryFacilityType': 'NONE',
'mailingDate': picking.scheduled_date.strftime('%Y-%m-%d'),
}
shipping_request = {
'imageInfo': image_info,
'fromAddress': from_address,
'toAddress': to_address,
'packageDescription': package_description,
}
if self.usps_delivery_nature == 'international':
customs_form.update({
'contents': [{
'itemDescription': commodity['product_id'].name,
'itemQuantity': int(commodity['qty']),
'countryofOrigin': commodity['country_of_origin'],
'itemTotalValue': commodity['monetary_value'],
'itemTotalWeight': commodity['product_id'].weight * commodity['qty'],
'HSTariffNumber': commodity['product_id'].hs_code or ' ',
} for commodity in package['commodities']]
})
shipping_request.update({
'customsForm': customs_form,
})
self._usps_add_extra_data_to_request(shipping_request, 'shipment')
shipping_response = srm._get_shipping_label(shipping_request)
label_info = shipping_response.get('labelMetadata', {})
tracking_number = label_info.get('trackingNumber') or label_info.get('internationalTrackingNumber')
log_message = (_("Shipment created into USPS") + Markup("<br/><b>") + _("Tracking Number:") + Markup("</b> ") + tracking_number)
usps_label = ('%s-%s.%s' % ('LabelShipping-USPS', tracking_number, self.usps_rest_label_file_type), base64.b64decode(shipping_response.get('labelImage')))
if self.return_label_on_delivery and self.usps_delivery_nature == 'domestic':
return_label_info = shipping_response.get('returnLabelMetadata', {})
return_tracking_number = return_label_info.get('trackingNumber')
return_usps_label = ('%s-%s.%s' % ('LabelReturn-USPS', return_tracking_number, self.usps_rest_label_file_type), base64.b64decode(shipping_response.get('returnLabelImage')))
return_log_message = (_("Return Shipment created into USPS") + Markup("<br/><b>") + _("Tracking Number:") + Markup("</b> ") + return_tracking_number)
lognote_pickings = picking.sale_id.picking_ids if picking.sale_id else picking
for pick in lognote_pickings:
pick.message_post(body=log_message, attachments=[usps_label])
if self.return_label_on_delivery and self.usps_delivery_nature == 'domestic':
pick.message_post(body=return_log_message, attachments=[return_usps_label])
shipping_data['tracking_number'].append(tracking_number)
shipping_data['exact_price'].append(label_info.get('postage'))
if label_info.get('fees'):
shipping_data['exact_price'][-1] += sum(fee.get('price') for fee in label_info.get('fees'))
if label_info.get('extraServices'):
shipping_data['exact_price'][-1] += sum(service.get('price') for service in label_info.get('extraServices'))
res.append({
'exact_price': sum(shipping_data['exact_price']),
'tracking_number': ', '.join(shipping_data['tracking_number']),
})
return res
def usps_rest_get_tracking_link(self, picking):
return 'https://tools.usps.com/go/TrackConfirmAction?tLabels=%s' % picking.carrier_tracking_ref
def usps_rest_cancel_shipment(self, picking):
srm = USPSRequest(self)
result = srm._cancel_label(picking.carrier_tracking_ref)
if result:
picking.message_post(body=_("Shipment with tracking number %(tracking_ref)s has been cancelled", tracking_ref=picking.carrier_tracking_ref))
picking.write({'carrier_tracking_ref': '', 'carrier_price': 0.0})
def _usps_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
"roles": {"role": {"CRID": "12345"}}
the CRID of each role will be updated."""
extra_data_input = {
'rate': self.usps_extra_data_rate_request,
'shipment': self.usps_extra_data_shipment_request,
'payment_token': self.usps_extra_data_payment_token_request,
}.get(request_type) or ''
try:
extra_data = json.loads('{' + extra_data_input + '}')
except SyntaxError:
raise UserError(_("Invalid syntax for USPS extra data."))
def extra_data_to_request(request, extra_data):
"""recursive method that adds the 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)