1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/delivery_usps/models/usps_request.py
2024-12-10 09:04:09 +07:00

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