first commit
This commit is contained in:
525
modules/stock_package_shipping_ups/stock.py
Normal file
525
modules/stock_package_shipping_ups/stock.py
Normal file
@@ -0,0 +1,525 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
import base64
|
||||
import re
|
||||
import ssl
|
||||
import urllib.parse
|
||||
from decimal import Decimal
|
||||
from itertools import zip_longest
|
||||
from math import ceil
|
||||
|
||||
import requests
|
||||
|
||||
import trytond.config as config
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import fields
|
||||
from trytond.model.exceptions import AccessError
|
||||
from trytond.modules.stock_package_shipping.exceptions import (
|
||||
PackingValidationError)
|
||||
from trytond.modules.stock_package_shipping.stock import address_name
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.wizard import StateAction, StateTransition, Wizard
|
||||
|
||||
from .exceptions import UPSError
|
||||
|
||||
TRACKING_URL = 'https://www.ups.com/track'
|
||||
|
||||
|
||||
class PackageType(metaclass=PoolMeta):
|
||||
__name__ = 'stock.package.type'
|
||||
|
||||
ups_code = fields.Selection([
|
||||
(None, ''),
|
||||
('01', 'UPS Letter'),
|
||||
('02', 'Customer Supplied Package'),
|
||||
('03', 'Tube'),
|
||||
('04', 'PAK'),
|
||||
('21', 'UPS Express Box'),
|
||||
('24', 'UPS 25KG Box'),
|
||||
('25', 'UPS 10KG Box'),
|
||||
('30', 'Pallet'),
|
||||
('2a', 'Small Express Box'),
|
||||
('2b', 'Medium Express Box'),
|
||||
('2c', 'Large Express Box'),
|
||||
('56', 'Flats'),
|
||||
('57', 'Parcels'),
|
||||
('58', 'BPM'),
|
||||
('59', 'First Class'),
|
||||
('60', 'Priority'),
|
||||
('61', 'Machinables'),
|
||||
('62', 'Irregulars'),
|
||||
('63', 'Parcel Post'),
|
||||
('64', 'BPM Parcel'),
|
||||
('65', 'Media Mail'),
|
||||
('66', 'BPM Flat'),
|
||||
('67', 'Standard Flat'),
|
||||
], 'UPS Code', sort=False, translate=False)
|
||||
|
||||
@classmethod
|
||||
def default_ups_code(cls):
|
||||
return None
|
||||
|
||||
|
||||
class Package(metaclass=PoolMeta):
|
||||
__name__ = 'stock.package'
|
||||
|
||||
def get_shipping_tracking_url(self, name):
|
||||
url = super().get_shipping_tracking_url(name)
|
||||
if (self.shipping_reference
|
||||
and self.shipment
|
||||
and self.shipment.id >= 0
|
||||
and self.shipment.carrier
|
||||
and self.shipment.carrier.shipping_service == 'ups'):
|
||||
party = self.shipment.shipping_to
|
||||
address = self.shipment.shipping_to_address
|
||||
parts = urllib.parse.urlsplit(TRACKING_URL)
|
||||
query = urllib.parse.parse_qsl(parts.query)
|
||||
if party and party.lang and address and address.country:
|
||||
loc = '_'.join(
|
||||
(party.lang.code.split('_')[0], address.country.code))
|
||||
query.append(('loc', loc))
|
||||
query.append(('tracknum', self.shipping_reference))
|
||||
parts = list(parts)
|
||||
parts[3] = urllib.parse.urlencode(query)
|
||||
url = urllib.parse.urlunsplit(parts)
|
||||
return url
|
||||
|
||||
|
||||
class ShippingUPSMixin:
|
||||
__slots__ = ()
|
||||
|
||||
def validate_packing_ups(self, usage=None):
|
||||
warehouse = self.shipping_warehouse
|
||||
if not warehouse.address:
|
||||
raise PackingValidationError(
|
||||
gettext('stock_package_shipping_ups'
|
||||
'.msg_warehouse_address_required',
|
||||
shipment=self.rec_name,
|
||||
warehouse=warehouse.rec_name))
|
||||
if warehouse.address.country != self.shipping_to_address.country:
|
||||
for address in [self.shipping_to_address, warehouse.address]:
|
||||
if not address.contact_mechanism_get(
|
||||
{'phone', 'mobile'}, usage=usage):
|
||||
raise PackingValidationError(
|
||||
gettext('stock_package_shipping_ups'
|
||||
'.msg_phone_required',
|
||||
shipment=self.rec_name,
|
||||
address=address.rec_name))
|
||||
|
||||
|
||||
class CreateShipping(metaclass=PoolMeta):
|
||||
__name__ = 'stock.shipment.create_shipping'
|
||||
|
||||
ups = StateAction(
|
||||
'stock_package_shipping_ups.act_create_shipping_ups_wizard')
|
||||
|
||||
def transition_start(self):
|
||||
next_state = super().transition_start()
|
||||
if self.record.carrier.shipping_service == 'ups':
|
||||
next_state = 'ups'
|
||||
return next_state
|
||||
|
||||
def do_ups(self, action):
|
||||
ctx = Transaction().context
|
||||
return action, {
|
||||
'model': ctx['active_model'],
|
||||
'id': ctx['active_id'],
|
||||
'ids': [ctx['active_id']],
|
||||
}
|
||||
|
||||
|
||||
class CreateShippingUPS(Wizard):
|
||||
__name__ = 'stock.shipment.create_shipping.ups'
|
||||
|
||||
start = StateTransition()
|
||||
|
||||
def transition_start(self):
|
||||
pool = Pool()
|
||||
Package = pool.get('stock.package')
|
||||
|
||||
shipment = self.record
|
||||
if shipment.shipping_reference:
|
||||
raise AccessError(
|
||||
gettext('stock_package_shipping_ups'
|
||||
'.msg_shipment_has_shipping_reference_number',
|
||||
shipment=shipment.rec_name))
|
||||
|
||||
credential = self.get_credential(shipment)
|
||||
carrier = shipment.carrier
|
||||
packages = shipment.root_packages
|
||||
shipment_request = self.get_request(shipment, packages, credential)
|
||||
token = credential.get_token()
|
||||
api_url = credential.get_shipment_url()
|
||||
headers = {
|
||||
'transactionSrc': "Tryton",
|
||||
'Authorization': f"Bearer {token}",
|
||||
}
|
||||
nb_tries, response = 0, None
|
||||
error_message = ''
|
||||
timeout = config.getfloat(
|
||||
'stock_package_shipping_ups', 'requests_timeout', default=300)
|
||||
try:
|
||||
while nb_tries < 5 and response is None:
|
||||
try:
|
||||
response = requests.post(
|
||||
api_url, json=shipment_request, headers=headers,
|
||||
timeout=timeout)
|
||||
except ssl.SSLError as e:
|
||||
error_message = e.reason
|
||||
nb_tries += 1
|
||||
continue
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
except requests.HTTPError as e:
|
||||
try:
|
||||
errors = e.response.json()['response']['errors']
|
||||
error_message = "\n".join(m['message'] for m in errors)
|
||||
except (requests.JSONDecodeError, TypeError, KeyError):
|
||||
error_message = e.args[0]
|
||||
|
||||
if error_message:
|
||||
raise UPSError(
|
||||
gettext('stock_package_shipping_ups.msg_ups_webservice_error',
|
||||
message=error_message))
|
||||
|
||||
shipment_response = response['ShipmentResponse']
|
||||
response_status = shipment_response['Response']['ResponseStatus']
|
||||
if response_status['Code'] != '1':
|
||||
raise UPSError(
|
||||
gettext('stock_package_shipping_ups.msg_ups_webservice_error',
|
||||
message=response_status['Description']))
|
||||
|
||||
shipment_results = shipment_response['ShipmentResults']
|
||||
shipment.shipping_reference = (
|
||||
shipment_results['ShipmentIdentificationNumber'])
|
||||
ups_packages = shipment_results['PackageResults']
|
||||
if not isinstance(ups_packages, list):
|
||||
# In case only one package is requested UPS may return a dictionary
|
||||
# instead of a list of one package
|
||||
ups_packages = [ups_packages]
|
||||
|
||||
for tryton_pkg, ups_pkg in zip_longest(packages, ups_packages):
|
||||
label = fields.Binary.cast(base64.b64decode(
|
||||
ups_pkg['ShippingLabel']['GraphicImage']))
|
||||
tryton_pkg.shipping_reference = ups_pkg['TrackingNumber']
|
||||
tryton_pkg.shipping_label = label
|
||||
tryton_pkg.shipping_label_mimetype = (
|
||||
carrier.shipping_label_mimetype)
|
||||
Package.save(packages)
|
||||
shipment.save()
|
||||
|
||||
return 'end'
|
||||
|
||||
def get_credential_pattern(self, shipment):
|
||||
return {
|
||||
'company': shipment.company.id,
|
||||
}
|
||||
|
||||
def get_credential(self, shipment):
|
||||
pool = Pool()
|
||||
UPSCredential = pool.get('carrier.credential.ups')
|
||||
|
||||
credential_pattern = self.get_credential_pattern(shipment)
|
||||
for credential in UPSCredential.search([]):
|
||||
if credential.match(credential_pattern):
|
||||
return credential
|
||||
|
||||
def get_request_container(self, shipment):
|
||||
return {
|
||||
'RequestOption': 'validate',
|
||||
'TransactionReference': {
|
||||
'CustomerContext': (shipment.number or '')[:512],
|
||||
},
|
||||
}
|
||||
|
||||
def get_shipping_party(self, party, address, usage=None):
|
||||
name = address_name(address, party)
|
||||
attention_name = party.full_name
|
||||
shipping_party = {
|
||||
'Name': name[:35],
|
||||
'AttentionName': attention_name[:35],
|
||||
'Address': {
|
||||
'AddressLine': [l[:35]
|
||||
for l in (address.street or '').splitlines()[:3]],
|
||||
'City': (address.city or '')[:30],
|
||||
'PostalCode': (address.postal_code or '').replace(' ', '')[:9],
|
||||
'CountryCode': address.country.code if address.country else '',
|
||||
},
|
||||
}
|
||||
if address.post_box:
|
||||
shipping_party['Address']['POBoxIndicator'] = True
|
||||
phone = address.contact_mechanism_get({'phone', 'mobile'}, usage=usage)
|
||||
if phone:
|
||||
shipping_party['Phone'] = {
|
||||
'Number': re.sub('[() .-]', '', phone.value)[:15]
|
||||
}
|
||||
email = address.contact_mechanism_get('email')
|
||||
if email and len(email.value) <= 50:
|
||||
shipping_party['EMailAddress'] = email.value
|
||||
|
||||
return shipping_party
|
||||
|
||||
def get_payment_information(self, shipment, credential):
|
||||
return {
|
||||
'ShipmentCharge': {
|
||||
# Type 01 is for Transportation Charges
|
||||
'Type': '01',
|
||||
'BillShipper': {
|
||||
'AccountNumber': credential.account_number,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_package(self, use_metric, package):
|
||||
pool = Pool()
|
||||
UoM = pool.get('product.uom')
|
||||
ModelData = pool.get('ir.model.data')
|
||||
|
||||
cm = UoM(ModelData.get_id('product', 'uom_centimeter'))
|
||||
inch = UoM(ModelData.get_id('product', 'uom_inch'))
|
||||
|
||||
kg = UoM(ModelData.get_id('product', 'uom_kilogram'))
|
||||
lb = UoM(ModelData.get_id('product', 'uom_pound'))
|
||||
|
||||
pkg = {
|
||||
'Packaging': {
|
||||
'Code': package.type.ups_code,
|
||||
},
|
||||
}
|
||||
if (package.length is not None
|
||||
and package.width is not None
|
||||
and package.height is not None):
|
||||
pkg['Dimensions'] = {
|
||||
'UnitOfMeasurement': {
|
||||
'Code': 'CM' if use_metric else 'IN',
|
||||
},
|
||||
'Length': '%i' % ceil(
|
||||
UoM.compute_qty(
|
||||
package.length_uom, package.length,
|
||||
cm if use_metric else inch, round=False)),
|
||||
'Width': '%i' % ceil(
|
||||
UoM.compute_qty(
|
||||
package.width_uom, package.width,
|
||||
cm if use_metric else inch, round=False)),
|
||||
'Height': '%i' % ceil(
|
||||
UoM.compute_qty(
|
||||
package.height_uom, package.height,
|
||||
cm if use_metric else inch, round=False)),
|
||||
}
|
||||
if package.total_weight is not None:
|
||||
pkg['PackageWeight'] = {
|
||||
'UnitOfMeasurement': {
|
||||
'Code': 'KGS' if use_metric else 'LBS',
|
||||
},
|
||||
'Weight': '%i' % ceil(
|
||||
UoM.compute_qty(
|
||||
kg, package.total_weight,
|
||||
kg if use_metric else lb, round=False)),
|
||||
}
|
||||
return pkg
|
||||
|
||||
def get_service_options(self, shipment):
|
||||
service_options = {}
|
||||
notifications = list(self.get_notifications(shipment))
|
||||
if notifications:
|
||||
service_options['Notification'] = notifications
|
||||
return service_options
|
||||
|
||||
def get_notifications(self, shipment):
|
||||
if not shipment.carrier.ups_notifications:
|
||||
return
|
||||
for code in shipment.carrier.ups_notifications:
|
||||
shipping_to_address = shipment.shipping_to_address
|
||||
email = shipping_to_address.contact_mechanism_get('email')
|
||||
if email and len(email.value) <= 50:
|
||||
notification = {
|
||||
'NotificationCode': code,
|
||||
'EMail': {
|
||||
'EMailAddress': email.value,
|
||||
},
|
||||
}
|
||||
if code in {'012', '013'}:
|
||||
phone = shipping_to_address.contact_mechanism_get(
|
||||
{'phone', 'mobile'})
|
||||
if phone and len(phone.value) <= 15:
|
||||
notification['VoiceMessage'] = {
|
||||
'PhoneNumber': phone.value,
|
||||
}
|
||||
mobile = shipping_to_address.contact_mechanism_get(
|
||||
'mobile')
|
||||
if mobile and len(mobile.value) <= 15:
|
||||
notification['TextMessage'] = {
|
||||
'PhoneNumber': phone.value,
|
||||
}
|
||||
yield notification
|
||||
|
||||
def get_request(self, shipment, packages, credential):
|
||||
shipper = self.get_shipping_party(
|
||||
shipment.company.party, shipment.shipping_warehouse.address)
|
||||
shipper['ShipperNumber'] = credential.account_number
|
||||
# email is not required but must be associated with the UserId
|
||||
# which can not be ensured.
|
||||
shipper.pop('EMailAddress', None)
|
||||
|
||||
packages = [self.get_package(credential.use_metric, p)
|
||||
for p in packages]
|
||||
options = self.get_service_options(shipment)
|
||||
if options:
|
||||
# options are set on package instead of shipment
|
||||
# despite what UPS documentation says
|
||||
for pkg in packages:
|
||||
pkg['ShipmentServiceOptions'] = options
|
||||
description = (
|
||||
shipment.shipping_description_used
|
||||
or gettext('stock_package_shipping_ups.msg_general_merchandise'))
|
||||
return {
|
||||
'ShipmentRequest': {
|
||||
'Request': self.get_request_container(shipment),
|
||||
'Shipment': {
|
||||
'Description': description[:50],
|
||||
'Shipper': shipper,
|
||||
'ShipTo': self.get_shipping_party(
|
||||
shipment.shipping_to, shipment.shipping_to_address),
|
||||
'PaymentInformation': self.get_payment_information(
|
||||
shipment, credential),
|
||||
'Service': {
|
||||
'Code': shipment.carrier.ups_service_type,
|
||||
},
|
||||
'Package': packages,
|
||||
},
|
||||
'LabelSpecification': {
|
||||
'LabelImageFormat': {
|
||||
'Code': shipment.carrier.ups_label_image_format,
|
||||
},
|
||||
'LabelStockSize': {
|
||||
'Width': '4',
|
||||
'Height': shipment.carrier.ups_label_height,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CreateShippingUPS_Customs_Incoterm(metaclass=PoolMeta):
|
||||
__name__ = 'stock.shipment.create_shipping.ups'
|
||||
|
||||
def get_request(self, shipment, packages, credential):
|
||||
request = super().get_request(shipment, packages, credential)
|
||||
if (shipment.customs_international
|
||||
and credential.use_international_forms):
|
||||
international_form = self.get_international_form(
|
||||
shipment, credential)
|
||||
request['ShipmentRequest']['Shipment'].setdefault(
|
||||
'ShipmentServiceOptions', {})['InternationalForms'] = (
|
||||
international_form)
|
||||
return request
|
||||
|
||||
def get_international_form(self, shipment, credential):
|
||||
form_type = self.get_international_form_type(shipment, credential)
|
||||
return getattr(self, f'get_international_form_{form_type}')(
|
||||
credential.use_metric, shipment)
|
||||
|
||||
def get_international_form_type(self, shipment, credential):
|
||||
return 'invoice'
|
||||
|
||||
def get_international_form_invoice(self, use_metric, shipment):
|
||||
pool = Pool()
|
||||
Date = pool.get('ir.date')
|
||||
|
||||
with Transaction().set_context(company=shipment.company.id):
|
||||
today = Date.today()
|
||||
|
||||
if customs_agent := shipment.customs_agent:
|
||||
sold_to_party = customs_agent.party
|
||||
sold_to_address = customs_agent.address
|
||||
else:
|
||||
sold_to_party = shipment.shipping_to
|
||||
sold_to_address = shipment.shipping_to_address
|
||||
|
||||
invoice_date = shipment.effective_date or today
|
||||
currency, = {m.currency for m in shipment.customs_moves}
|
||||
sold_to = self.get_shipping_party(sold_to_party, sold_to_address)
|
||||
if customs_agent:
|
||||
sold_to['AccountNumber'] = customs_agent.ups_account_number
|
||||
return {
|
||||
'FormType': '01', # Invoice
|
||||
'Contacts': {
|
||||
'SoldTo': sold_to,
|
||||
},
|
||||
'Product': [
|
||||
self.get_international_form_invoice_product(
|
||||
use_metric, shipment, *k, **v)
|
||||
for k, v in shipment.customs_products.items()],
|
||||
'InvoiceNumber': shipment.number[-35:],
|
||||
'InvoiceDate': invoice_date.strftime('%Y%m%d'),
|
||||
'TermsOfShipment': (
|
||||
shipment.incoterm.code if shipment.incoterm else None),
|
||||
'ReasonForExport': self.get_international_form_invoice_reason(
|
||||
shipment),
|
||||
'CurrencyCode': currency.code,
|
||||
}
|
||||
|
||||
def get_international_form_invoice_product(
|
||||
self, use_metric, shipment, product, price, currency, unit,
|
||||
quantity, weight):
|
||||
pool = Pool()
|
||||
UoM = pool.get('product.uom')
|
||||
ModelData = pool.get('ir.model.data')
|
||||
|
||||
kg = UoM(ModelData.get_id('product', 'uom_kilogram'))
|
||||
lb = UoM(ModelData.get_id('product', 'uom_pound'))
|
||||
|
||||
tariff_code = product.get_tariff_code({
|
||||
'date': shipment.effective_date or shipment.planned_date,
|
||||
'country': (
|
||||
shipment.customs_to_country.id
|
||||
if shipment.customs_to_country else None),
|
||||
})
|
||||
weight = UoM.compute_qty(
|
||||
kg, weight, kg if use_metric else lb, round=False)
|
||||
if len(str(weight)) > 5:
|
||||
weight = ceil(weight)
|
||||
|
||||
if not quantity.is_integer():
|
||||
value = price * Decimal(str(quantity))
|
||||
quantity = 1
|
||||
unit_code = 'OTH'
|
||||
else:
|
||||
value = price
|
||||
unit_code = unit.ups_code
|
||||
|
||||
for i in range(6, -1, -1):
|
||||
value = str(price.quantize(Decimal(10) ** -i)).rstrip('0')
|
||||
if len(value) <= 19:
|
||||
break
|
||||
return {
|
||||
'Description': product.name[:35],
|
||||
'Unit': {
|
||||
'Number': '%i' % quantity,
|
||||
'UnitOfMeasurement': {
|
||||
'Code': unit_code,
|
||||
'Description': (
|
||||
unit.ups_code if unit.ups_code != 'OTH'
|
||||
else unit.name)[:3],
|
||||
},
|
||||
'Value': value,
|
||||
},
|
||||
'CommodityCode': tariff_code.code if tariff_code else None,
|
||||
'OriginCountryCode': (
|
||||
product.country_of_origin.code if product.country_of_origin
|
||||
else None),
|
||||
'ProductWeight': {
|
||||
'UnitOfMeasurement': {
|
||||
'Code': 'KGS' if use_metric else 'LBS',
|
||||
},
|
||||
'Weight': str(weight),
|
||||
},
|
||||
}
|
||||
|
||||
def get_international_form_invoice_reason(self, shipment):
|
||||
return {
|
||||
'stock.shipment.out': 'SALE',
|
||||
'stock.shipment.in.return': 'RETURN',
|
||||
}.get(shipment.__class__.__name__)
|
||||
Reference in New Issue
Block a user