forked from Mapan/odoo17e
361 lines
18 KiB
Python
361 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import binascii
|
|
import math
|
|
import re
|
|
|
|
import requests
|
|
from lxml import etree
|
|
|
|
from odoo import fields, _
|
|
|
|
|
|
# This re should match postcodes like 12345 and 12345-6789
|
|
ZIP_ZIP4 = re.compile('^[0-9]{5}(-[0-9]{4})?$')
|
|
|
|
|
|
def split_zip(zipcode):
|
|
'''If zipcode is a ZIP+4, split it into two parts.
|
|
Else leave it unchanged '''
|
|
if ZIP_ZIP4.match(zipcode) and '-' in zipcode:
|
|
return zipcode.split('-')
|
|
else:
|
|
return [zipcode, '']
|
|
|
|
|
|
class USPSRequest():
|
|
|
|
def __init__(self, prod_environment, debug_logger):
|
|
self.debug_logger = debug_logger
|
|
if not prod_environment:
|
|
self.url = 'https://stg-secure.shippingapis.com/ShippingAPI.dll'
|
|
else:
|
|
self.url = 'https://secure.shippingapis.com/ShippingAPI.dll'
|
|
self.prod_environment = prod_environment
|
|
|
|
def check_required_value(self, recipient, delivery_nature, shipper, order=False, picking=False):
|
|
recipient_required_field = ['city', 'zip', 'country_id']
|
|
# The street isn't required if we compute the rate with a partial delivery address in the
|
|
# express checkout flow.
|
|
if not recipient.street and not recipient.street2 and not recipient._context.get(
|
|
'express_checkout_partial_delivery_address', False
|
|
):
|
|
recipient_required_field.append('street')
|
|
shipper_required_field = ['city', 'zip', 'phone', 'state_id', 'country_id']
|
|
if not shipper.street and not shipper.street2:
|
|
shipper_required_field.append('street')
|
|
|
|
res = [field for field in shipper_required_field if not shipper[field]]
|
|
if res:
|
|
return _("The address of your company is missing or wrong (Missing field(s) : \n %s)", ", ".join(res).replace("_id", ""))
|
|
if shipper.country_id.code != 'US':
|
|
return _("Please set country U.S.A in your company address, Service is only available for U.S.A")
|
|
if not ZIP_ZIP4.match(shipper.zip):
|
|
return _("Please enter a valid ZIP code in your Company address")
|
|
if not self._convert_phone_number(shipper.phone):
|
|
return _("Company phone number is invalid. Please insert a US phone number.")
|
|
res = [field for field in recipient_required_field if not recipient[field]]
|
|
if res:
|
|
return _("The recipient address is missing or wrong (Missing field(s) : \n %s)", ", ".join(res).replace("_id", ""))
|
|
if delivery_nature == 'domestic' and not ZIP_ZIP4.match(recipient.zip):
|
|
return _("Please enter a valid ZIP code in recipient address")
|
|
if recipient.country_id.code == "US" and delivery_nature == 'international':
|
|
return _("USPS International is used only to ship outside of the U.S.A. Please change the delivery method into USPS Domestic.")
|
|
if recipient.country_id.code != "US" and delivery_nature == 'domestic':
|
|
return _("USPS Domestic is used only to ship inside of the U.S.A. Please change the delivery method into USPS International.")
|
|
if order:
|
|
if not order.order_line:
|
|
return _("Please provide at least one item to ship.")
|
|
error_lines = order.order_line.filtered(lambda line: not line.product_id.weight and not line.is_delivery and line.product_id.type != 'service' and not line.display_type)
|
|
if error_lines:
|
|
return _("The estimated shipping price cannot be computed because the weight is missing for the following product(s): \n %s", ", ".join(error_lines.product_id.mapped('name')))
|
|
tot_weight = order._get_estimated_weight()
|
|
weight_uom_id = order.env['product.template']._get_weight_uom_id_from_ir_config_parameter()
|
|
weight_in_pounds = weight_uom_id._compute_quantity(tot_weight, order.env.ref('uom.product_uom_lb'))
|
|
if weight_in_pounds > 4 and order.carrier_id.usps_service == 'First Class': # max weight of FirstClass Service
|
|
return _("Please choose another service (maximum weight of this service is 4 pounds)")
|
|
if picking and picking.move_ids:
|
|
# https://www.usps.com/business/web-tools-apis/evs-international-label-api.htm
|
|
if max(picking.move_ids.mapped('product_uom_qty')) > 999:
|
|
return _("Quantity for each move line should be less than 1000.")
|
|
return False
|
|
|
|
def _usps_request_data(self, carrier, order):
|
|
currency = carrier.env['res.currency'].search([('name', '=', 'USD')], limit=1) # USPS Works in USDollars
|
|
tot_weight = order._get_estimated_weight()
|
|
total_weight = carrier._usps_convert_weight(tot_weight)
|
|
total_value = sum([(line.price_unit * line.product_uom_qty) for line in order.order_line.filtered(lambda line: not line.is_delivery and not line.display_type)]) or 0.0
|
|
|
|
if order.currency_id.name == currency.name:
|
|
price = total_value
|
|
else:
|
|
quote_currency = order.currency_id
|
|
price = quote_currency._convert(
|
|
total_value, currency, order.company_id, order.date_order or fields.Date.today())
|
|
|
|
rate_detail = {
|
|
'api': 'RateV4' if carrier.usps_delivery_nature == 'domestic' else 'IntlRateV2',
|
|
'ID': carrier.sudo().usps_username,
|
|
'revision': "2",
|
|
'package_id': '%s%d' % ("PKG", order.id),
|
|
'ZipOrigination': split_zip(order.warehouse_id.partner_id.zip)[0],
|
|
'ZipDestination': split_zip(order.partner_shipping_id.zip)[0],
|
|
'FirstClassMailType': carrier.usps_first_class_mail_type,
|
|
'Pounds': total_weight['pound'],
|
|
'Ounces': total_weight['ounce'],
|
|
'Size': carrier.usps_size_container,
|
|
'Service': carrier.usps_service,
|
|
'Container': carrier.usps_container,
|
|
'DomesticRegularontainer': carrier.usps_domestic_regular_container,
|
|
'InternationalRegularContainer': carrier.usps_international_regular_container,
|
|
'MailType': carrier.usps_mail_type,
|
|
'Machinable': str(carrier.usps_machinable),
|
|
'ValueOfContents': price,
|
|
'Country': order.partner_shipping_id.country_id.name,
|
|
'Width': carrier.usps_custom_container_width,
|
|
'Height': carrier.usps_custom_container_height,
|
|
'Length': carrier.usps_custom_container_length,
|
|
'Girth': carrier.usps_custom_container_girth,
|
|
}
|
|
|
|
# Shipping to Canada requires additional information
|
|
if order.partner_shipping_id.country_id.code == "CA":
|
|
rate_detail.update(OriginZip=order.warehouse_id.partner_id.zip)
|
|
|
|
return rate_detail
|
|
|
|
def usps_rate_request(self, order, carrier):
|
|
request_detail = self._usps_request_data(carrier, order)
|
|
request_text = carrier.env['ir.qweb']._render('delivery_usps.usps_price_request', request_detail, inherit_branding=False)
|
|
dict_response = {'price': 0.0, 'currency_code': "USD"}
|
|
api = 'RateV4' if carrier.usps_delivery_nature == 'domestic' else 'IntlRateV2'
|
|
|
|
try:
|
|
self.debug_logger(request_text, 'usps_request_rate')
|
|
req = requests.get(self.url, params={'API': api, 'XML': request_text})
|
|
req.raise_for_status()
|
|
response_text = req.content
|
|
self.debug_logger(response_text, 'usps_response_rate')
|
|
except IOError:
|
|
dict_response['error_message'] = 'USPS Server Not Found - Check your connectivity'
|
|
return dict_response
|
|
root = etree.fromstring(response_text)
|
|
errors_return = root.findall('.//Description')
|
|
errors_number = root.findall('.//Number')
|
|
if errors_return:
|
|
dict_response['error_message'] = self._error_message(errors_number[0].text if errors_number else '', errors_return[0].text)
|
|
return dict_response
|
|
# Domestic Rate
|
|
elif root.tag == 'RateV4Response':
|
|
package_root = root.findall('Package')
|
|
postage_roots = package_root[0].findall('Postage')
|
|
for postage_root in postage_roots:
|
|
rate = postage_root.findtext('Rate')
|
|
dict_response['price'] = float(rate)
|
|
# International Rate
|
|
else:
|
|
package_root = root.findall('Package')
|
|
services = package_root[0].findall("Service")
|
|
postages_prices = []
|
|
for service in services:
|
|
if carrier.usps_service in service.findall("SvcDescription")[0].text:
|
|
postages_prices += [float(service.findall("Postage")[0].text)]
|
|
if not postages_prices:
|
|
dict_response['error_message'] = _("The selected USPS service (%s) cannot be used to deliver this package.", carrier.usps_service)
|
|
return dict_response
|
|
else:
|
|
dict_response['price'] = min(postages_prices)
|
|
return dict_response
|
|
|
|
def _item_data(self, line, weight, price):
|
|
return {
|
|
'Description': line.name,
|
|
'Quantity': max(int(line.product_uom_qty), 1), # the USPS API does not accept 1.0 but 1
|
|
'Value': price,
|
|
'NetPounds': weight['pound'],
|
|
'NetOunces': round(weight['ounce'], 0),
|
|
'CountryOfOrigin': line.warehouse_id.partner_id.country_id.name or ''
|
|
}
|
|
|
|
def _usps_shipping_data(self, picking, is_return=False):
|
|
carrier = picking.carrier_id
|
|
itemdetail = []
|
|
|
|
api = self._api_url(carrier.usps_delivery_nature, carrier.usps_service)
|
|
|
|
for line in picking.move_ids:
|
|
USD = carrier.env['res.currency'].search([('name', '=', 'USD')], limit=1)
|
|
order = picking.sale_id
|
|
company = order.company_id or picking.company_id or self.env.company
|
|
shipper_currency = picking.sale_id.currency_id or picking.company_id.currency_id
|
|
if shipper_currency.name == USD.name:
|
|
price = line.product_id.lst_price * line.product_uom_qty
|
|
else:
|
|
quote_currency = picking.env['res.currency'].search([('name', '=', shipper_currency.name)], limit=1)
|
|
amount = line.product_id.lst_price * line.product_uom_qty
|
|
price = quote_currency._convert(
|
|
amount, USD, company, order.date_order or fields.Date.today())
|
|
weight = carrier._usps_convert_weight(line.product_id.weight * line.product_uom_qty)
|
|
itemdetail.append(self._item_data(line, weight, price))
|
|
|
|
if not is_return:
|
|
gross_weight = carrier._usps_convert_weight(picking.shipping_weight)
|
|
weight_in_ounces = 16 * gross_weight['pound'] + gross_weight['ounce']
|
|
else:
|
|
gross_weight = carrier._usps_convert_weight(picking._get_estimated_weight())
|
|
weight_in_ounces = picking._get_estimated_weight() * 35.274
|
|
shipping_detail = {
|
|
'api': api,
|
|
'ID': carrier.sudo().usps_username,
|
|
'revision': '2' if carrier.usps_delivery_nature == 'international' else '',
|
|
'ImageParameters': '',
|
|
'picking_carrier': picking.carrier_id,
|
|
'ToPOBoxFlag': 'N',
|
|
'ToPOBoxFlagDom': 'false',
|
|
'shipping': itemdetail,
|
|
'GrossPounds': gross_weight['pound'],
|
|
'GrossOunces': int(round(gross_weight['ounce'], 0)), # API want 1 and no 1.0
|
|
'MailType': carrier.usps_mail_type,
|
|
'FirstClassMailType': 'LETTER',
|
|
'ImageType': carrier.usps_label_file_type,
|
|
'ImageLayout': 'ALLINONEFILE',
|
|
'Size': carrier.usps_size_container,
|
|
'ContentType': carrier.usps_content_type,
|
|
'WeightInOunces': int(weight_in_ounces),
|
|
'Agreement': 'Y',
|
|
'Width': carrier.usps_custom_container_width,
|
|
'Height': carrier.usps_custom_container_height,
|
|
'Length': carrier.usps_custom_container_length,
|
|
'Girth': carrier.usps_custom_container_girth,
|
|
'ServiceType': carrier.usps_service,
|
|
'domestic_regular_container': carrier.usps_domestic_regular_container,
|
|
'UspsNonDeliveryOption': carrier.usps_intl_non_delivery_option,
|
|
'AltReturnAddress1': carrier.usps_redirect_partner_id.street,
|
|
'AltReturnAddress2': carrier.usps_redirect_partner_id.street2,
|
|
'AltReturnAddress3': carrier.usps_redirect_partner_id.zip + " " + carrier.usps_redirect_partner_id.city if carrier.usps_redirect_partner_id else '',
|
|
'AltReturnCountry': carrier.usps_redirect_partner_id.country_id.name,
|
|
'Machinable': str(carrier.usps_machinable),
|
|
'Container': carrier.usps_container,
|
|
'IsReturn': is_return,
|
|
# We pass the function so that the template can use it too
|
|
'func_split_zip': split_zip,
|
|
}
|
|
if not is_return:
|
|
shipping_detail.update({
|
|
'picking_warehouse_partner': picking.picking_type_id.warehouse_id.partner_id,
|
|
'picking_warehouse_partner_phone': self._convert_phone_number(picking.picking_type_id.warehouse_id.partner_id.phone),
|
|
'picking_partner': picking.partner_id,
|
|
'picking_partner_phone': self._convert_phone_number(picking.partner_id.phone or picking.partner_id.mobile or ''),
|
|
})
|
|
else:
|
|
shipping_detail.update({
|
|
'picking_warehouse_partner': picking.partner_id,
|
|
'picking_warehouse_partner_phone': self._convert_phone_number(picking.partner_id.phone or picking.partner_id.mobile or ''),
|
|
'picking_partner': picking.picking_type_id.warehouse_id.partner_id,
|
|
'picking_partner_phone': self._convert_phone_number(picking.picking_type_id.warehouse_id.partner_id.phone),
|
|
})
|
|
|
|
return shipping_detail
|
|
|
|
def usps_request(self, picking, delivery_nature, service, is_return=False):
|
|
ship_detail = self._usps_shipping_data(picking, is_return)
|
|
request_text = picking.env['ir.qweb']._render('delivery_usps.usps_shipping_common', ship_detail)
|
|
api = self._api_url(delivery_nature, service)
|
|
dict_response = {'tracking_number': 0.0, 'price': 0.0, 'currency': "USD"}
|
|
try:
|
|
self.debug_logger(request_text, 'usps_request_ship')
|
|
req = requests.get(self.url, params={'API': api, 'XML': request_text})
|
|
req.raise_for_status()
|
|
response_text = req.content
|
|
self.debug_logger(response_text, 'usps_response_ship')
|
|
except IOError:
|
|
dict_response['error_message'] = 'USPS Server Not Found - Check your connectivity'
|
|
|
|
root = etree.fromstring(response_text)
|
|
errors_return = root.findall('.//Description')
|
|
errors_number = root.findall('.//Number')
|
|
|
|
if errors_return:
|
|
dict_response['error_message'] = self._error_message(errors_number[0].text, errors_return[0].text)
|
|
return dict_response
|
|
else:
|
|
dict_response['tracking_number'] = root.findtext('BarcodeNumber')
|
|
dict_response['price'] = float(root.findtext('Postage'))
|
|
dict_response['label'] = binascii.a2b_base64(root.findtext('LabelImage'))
|
|
|
|
return dict_response
|
|
|
|
def _usps_cancel_shipping_data(self, picking):
|
|
return {
|
|
'ID': picking.carrier_id.sudo().usps_username,
|
|
'BarcodeNumber': picking.carrier_tracking_ref,
|
|
'carrier_type': picking.carrier_id.usps_delivery_nature,
|
|
'api': 'eVSCancel' if self.prod_environment else 'eVSCancelCertify'
|
|
}
|
|
|
|
def cancel_shipment(self, picking, account_validated):
|
|
cancel_detail = self._usps_cancel_shipping_data(picking)
|
|
request_text = picking.env["ir.qweb"]._render('delivery_usps.usps_cancel_request', cancel_detail)
|
|
dict_response = {'ShipmentDeleted': False, 'error_found': False}
|
|
# If the account isn't validated by USPS you can't use cancelling methods. It returns an authentication error.
|
|
if not account_validated:
|
|
dict_response['ShipmentDeleted'] = True
|
|
else:
|
|
api = 'eVSCancel' if self.prod_environment else 'eVSCancelCertify'
|
|
try:
|
|
self.debug_logger(request_text, 'usps_request_cancel')
|
|
req = requests.get(self.url, params={'API': api, 'XML': request_text})
|
|
req.raise_for_status()
|
|
response_text = req.content
|
|
self.debug_logger(response_text, 'usps_response_cancel')
|
|
except IOError:
|
|
dict_response['error_message'] = 'USPS Server Not Found - Check your connectivity'
|
|
root = etree.fromstring(response_text)
|
|
errors_return = root.findall('.//Description')
|
|
if errors_return:
|
|
dict_response['error_message'] = errors_return[0].text
|
|
dict_response['error_found'] = True
|
|
return dict_response
|
|
else:
|
|
dict_response['ShipmentDeleted'] = True
|
|
return dict_response
|
|
|
|
def _api_url(self, delivery_nature, service):
|
|
api = ''
|
|
if not self.prod_environment:
|
|
if delivery_nature == 'domestic':
|
|
api = 'eVSCertify'
|
|
else:
|
|
api = "eVS%s%s" % (str(service).replace(" ", ""), 'MailIntlCertify')
|
|
else:
|
|
if delivery_nature == 'domestic':
|
|
api = 'eVS'
|
|
else:
|
|
api = "eVS%s%s" % (str(service).replace(" ", ""), 'MailIntl')
|
|
return api
|
|
|
|
def _convert_phone_number(self, phone):
|
|
phone_pattern = re.compile(r'''
|
|
# don't match beginning of string, number can start anywhere
|
|
(\d{3}) # area code is 3 digits (e.g. '800')
|
|
\D* # optional separator is any number of non-digits
|
|
(\d{3}) # trunk is 3 digits (e.g. '555')
|
|
\D* # optional separator
|
|
(\d{4}) # rest of number is 4 digits (e.g. '1212')
|
|
\D* # optional separator
|
|
(\d*) # extension is optional and can be any number of digits
|
|
$ # end of string
|
|
''', re.VERBOSE)
|
|
match = phone_pattern.search(phone)
|
|
if match:
|
|
return ''.join(str(digits_number) for digits_number in match.groups())
|
|
else:
|
|
return False
|
|
|
|
def _error_message(self, error_number, api_error_message):
|
|
if error_number == '-2147219401':
|
|
api_error_message += _("Recipient address cannot be found. Please check the address exists.")
|
|
elif error_number == '-2147219385':
|
|
api_error_message += _("Your company or recipient ZIP code is incorrect.")
|
|
return api_error_message
|