diff --git a/modules/stock_package_shipping_ups/CHANGELOG b/modules/stock_package_shipping_ups/CHANGELOG
index 1f67b40d3c..f429b0a998 100644
--- a/modules/stock_package_shipping_ups/CHANGELOG
+++ b/modules/stock_package_shipping_ups/CHANGELOG
@@ -1,3 +1,4 @@
+* Support international invoice form
* Use "General Merchandise" as fallback shipping description
Version 7.6.0 - 2025-04-28
diff --git a/modules/stock_package_shipping_ups/carrier.py b/modules/stock_package_shipping_ups/carrier.py
index 3fd4835093..1c549815e0 100644
--- a/modules/stock_package_shipping_ups/carrier.py
+++ b/modules/stock_package_shipping_ups/carrier.py
@@ -22,6 +22,7 @@ class CredentialUPS(sequence_ordered(), ModelSQL, ModelView, MatchMixin):
client_secret = fields.Char("Client Secret", required=True)
account_number = fields.Char('Account Number', required=True)
use_metric = fields.Boolean('Use Metric')
+ use_international_forms = fields.Boolean("Use International Forms")
server = fields.Selection([
('testing', 'Testing'),
('production', 'Production'),
@@ -41,6 +42,10 @@ class CredentialUPS(sequence_ordered(), ModelSQL, ModelView, MatchMixin):
def default_use_metric(cls):
return True
+ @classmethod
+ def default_use_international_forms(cls):
+ return False
+
@classmethod
def default_server(cls):
return 'testing'
diff --git a/modules/stock_package_shipping_ups/customs.py b/modules/stock_package_shipping_ups/customs.py
new file mode 100644
index 0000000000..41dd935f4f
--- /dev/null
+++ b/modules/stock_package_shipping_ups/customs.py
@@ -0,0 +1,11 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+from trytond.model import fields
+from trytond.pool import PoolMeta
+
+
+class Agent(metaclass=PoolMeta):
+ __name__ = 'customs.agent'
+
+ ups_account_number = fields.Char("UPS Account Number")
diff --git a/modules/stock_package_shipping_ups/customs.xml b/modules/stock_package_shipping_ups/customs.xml
new file mode 100644
index 0000000000..17fba3ebea
--- /dev/null
+++ b/modules/stock_package_shipping_ups/customs.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ customs.agent
+
+ customs_agent_form
+
+
+
+ customs.agent
+
+ customs_agent_list
+
+
+
diff --git a/modules/stock_package_shipping_ups/product.py b/modules/stock_package_shipping_ups/product.py
new file mode 100644
index 0000000000..a0b47ef6c9
--- /dev/null
+++ b/modules/stock_package_shipping_ups/product.py
@@ -0,0 +1,56 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+from trytond.model import fields
+from trytond.pool import PoolMeta
+
+
+class UoM(metaclass=PoolMeta):
+ __name__ = 'product.uom'
+
+ ups_code = fields.Selection([
+ ('BA', "Barrel"),
+ ('BE', "Bundle"),
+ ('BG', "Bag"),
+ ('BH', "Bunch"),
+ ('BOX', "Box"),
+ ('BT', "Bolt"),
+ ('BU', "Butt"),
+ ('CI', "Canister"),
+ ('CM', "Centimeter"),
+ ('CON', "Container"),
+ ('CR', "Crate"),
+ ('CS', "Case"),
+ ('CT', "Carton"),
+ ('CY', "Cylinder"),
+ ('DOZ', "Dozen"),
+ ('EA', "Each"),
+ ('EN', "Envelope"),
+ ('FT', "Feet"),
+ ('KG', "Kilogram"),
+ ('KGS', "Kilograms"),
+ ('LB', "Pound"),
+ ('LBS', "Pounds"),
+ ('L', "Liter"),
+ ('M', "Meter"),
+ ('NMB', "Number"),
+ ('PA', "Packet"),
+ ('PAL', "Pallet"),
+ ('PC', "Piece"),
+ ('PCS', "Pieces"),
+ ('PF', "Proof Liters"),
+ ('PKG', "Package"),
+ ('PR', "Pair"),
+ ('PRS', "Pairs"),
+ ('RL', "Roll"),
+ ('SET', "Set"),
+ ('SME', "Square Meters"),
+ ('SYD', "Square Yards"),
+ ('TU', "Tube"),
+ ('YD', "Yard"),
+ ('OTH', "Other"),
+ ], "UPS Code", required=True)
+
+ @classmethod
+ def default_ups_code(cls):
+ return 'OTH'
diff --git a/modules/stock_package_shipping_ups/product.xml b/modules/stock_package_shipping_ups/product.xml
new file mode 100644
index 0000000000..934c4aba29
--- /dev/null
+++ b/modules/stock_package_shipping_ups/product.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+ product.uom
+
+ product_uom_form
+
+
+
+
+ PC
+
+
+ KG
+
+
+ LB
+
+
+ M
+
+
+ CM
+
+
+ YD
+
+
+ L
+
+
+ SME
+
+
+ SYD
+
+
+
diff --git a/modules/stock_package_shipping_ups/setup.py b/modules/stock_package_shipping_ups/setup.py
index aea465c99c..7c6d7cdf97 100755
--- a/modules/stock_package_shipping_ups/setup.py
+++ b/modules/stock_package_shipping_ups/setup.py
@@ -52,8 +52,11 @@ requires.append(get_require_version('trytond'))
tests_require = [
get_require_version('proteus'),
+ get_require_version('trytond_customs'),
+ get_require_version('trytond_incoterm'),
get_require_version('trytond_sale'),
get_require_version('trytond_sale_shipment_cost'),
+ get_require_version('trytond_stock_shipment_customs'),
]
setup(name=name,
diff --git a/modules/stock_package_shipping_ups/stock.py b/modules/stock_package_shipping_ups/stock.py
index cb0addb5dd..95c94d6217 100644
--- a/modules/stock_package_shipping_ups/stock.py
+++ b/modules/stock_package_shipping_ups/stock.py
@@ -5,6 +5,7 @@ import base64
import re
import ssl
import urllib.parse
+from decimal import Decimal
from itertools import zip_longest
from math import ceil
@@ -396,3 +397,126 @@ class CreateShippingUPS(Wizard):
}
},
}
+
+
+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__)
diff --git a/modules/stock_package_shipping_ups/tests/scenario_shipping_ups.rst b/modules/stock_package_shipping_ups/tests/scenario_shipping_ups.rst
index c32c11bd48..f0d276397b 100644
--- a/modules/stock_package_shipping_ups/tests/scenario_shipping_ups.rst
+++ b/modules/stock_package_shipping_ups/tests/scenario_shipping_ups.rst
@@ -175,7 +175,7 @@ Create a UPS Carrier and the related credential::
>>> carrier.party = ups
>>> carrier.carrier_product = carrier_product
>>> carrier.shipping_service = 'ups'
- >>> carrier.ups_service_type = '65'
+ >>> carrier.ups_service_type = '11'
>>> carrier.ups_label_image_format = 'GIF'
>>> carrier.ups_notifications = ['5', '7', '012']
>>> carrier.save()
@@ -204,7 +204,6 @@ Create the packs and ship the shipment::
>>> Package = Model.get('stock.package')
>>> shipment, = sale.shipments
- >>> shipment.shipping_description = 'Football Players'
>>> shipment.click('assign_try')
>>> shipment.click('pick')
>>> pack = shipment.packages.new()
diff --git a/modules/stock_package_shipping_ups/tests/scenario_stock_package_shipping_ups_international.rst b/modules/stock_package_shipping_ups/tests/scenario_stock_package_shipping_ups_international.rst
new file mode 100644
index 0000000000..4f627fbd00
--- /dev/null
+++ b/modules/stock_package_shipping_ups/tests/scenario_stock_package_shipping_ups_international.rst
@@ -0,0 +1,187 @@
+======================================================
+Stock Package Shipping with UPS International Scenario
+======================================================
+
+Imports::
+
+ >>> import os
+ >>> from decimal import Decimal
+
+ >>> from proteus import Model
+ >>> from trytond.modules.company.tests.tools import create_company, get_company
+ >>> from trytond.tests.tools import activate_modules, assertEqual
+
+Activate modules::
+
+ >>> config = activate_modules(
+ ... ['stock_package_shipping_ups', 'stock_shipment_customs'],
+ ... create_company)
+
+ >>> Agent = Model.get('customs.agent')
+ >>> AgentSelection = Model.get('customs.agent.selection')
+ >>> Address = Model.get('party.address')
+ >>> Carrier = Model.get('carrier')
+ >>> Country = Model.get('country.country')
+ >>> Location = Model.get('stock.location')
+ >>> Package = Model.get('stock.package')
+ >>> PackageType = Model.get('stock.package.type')
+ >>> Party = Model.get('party.party')
+ >>> ProductTemplate = Model.get('product.template')
+ >>> Shipment = Model.get('stock.shipment.out')
+ >>> UPSCredential = Model.get('carrier.credential.ups')
+ >>> UoM = Model.get('product.uom')
+
+Get company::
+
+ >>> company = get_company()
+
+Create countries::
+
+ >>> belgium = Country(code='BE', name="Belgium")
+ >>> belgium.save()
+ >>> switzerland = Country(code='CH', name="Switerland")
+ >>> switzerland.save()
+ >>> taiwan = Country(code='TW', name="Taiwan")
+ >>> taiwan.save()
+
+Create parties::
+
+ >>> customer = Party(name="Customer")
+ >>> customer_address, = customer.addresses
+ >>> customer_address.street = "Pfistergasse 17"
+ >>> customer_address.postal_code = "6003"
+ >>> customer_address.city = "Lucerna"
+ >>> customer_address.country = switzerland
+ >>> customer_phone = customer.contact_mechanisms.new()
+ >>> customer_phone.type = 'phone'
+ >>> customer_phone.value = "+(41) (041) 410-62-66"
+ >>> customer_email = customer.contact_mechanisms.new()
+ >>> customer_email.type = 'email'
+ >>> customer_email.value = 'customer@example.com'
+ >>> customer.save()
+
+ >>> agent_party = Party(name="Agent")
+ >>> agent_address, = agent_party.addresses
+ >>> agent_address.street = "Gerechtigkeitsgasse 53"
+ >>> agent_address.postal_code = "3011"
+ >>> agent_address.city = "Berna"
+ >>> agent_address.country = switzerland
+ >>> agent_identifier = agent_party.identifiers.new()
+ >>> agent_identifier.type = 'ch_vat'
+ >>> agent_identifier.code = "CHE-123.456.788 IVA"
+ >>> agent_party.save()
+ >>> agent = Agent(party=agent_party)
+ >>> agent.save()
+ >>> AgentSelection(to_country=switzerland, agent=agent).save()
+
+Set the warehouse address::
+
+ >>> warehouse, = Location.find([('type', '=', 'warehouse')])
+ >>> company_address = Address()
+ >>> company_address.party = company.party
+ >>> company_address.street = '2 rue de la Centrale'
+ >>> company_address.postal_code = '4000'
+ >>> company_address.city = 'Sclessin'
+ >>> company_address.country = belgium
+ >>> company_address.save()
+ >>> company_phone = company.party.contact_mechanisms.new()
+ >>> company_phone.type = 'phone'
+ >>> company_phone.value = '+32 4 2522122'
+ >>> company_phone.save()
+ >>> warehouse.address = company_address
+ >>> warehouse.save()
+
+Get some units::
+
+ >>> unit, = UoM.find([('name', '=', "Unit")], limit=1)
+ >>> cm, = UoM.find([('name', '=', "Centimeter")], limit=1)
+ >>> gram, = UoM.find([('name', '=', "Gram")], limit=1)
+
+Create product::
+
+ >>> template = ProductTemplate()
+ >>> template.name = "Product"
+ >>> template.default_uom = unit
+ >>> template.type = 'goods'
+ >>> template.weight = 100
+ >>> template.weight_uom = gram
+ >>> template.list_price = Decimal('10.0000')
+ >>> template.country_of_origin = taiwan
+ >>> template.save()
+ >>> product, = template.products
+
+Create Package Type::
+
+ >>> ups_box = PackageType(
+ ... name='UPS Box', ups_code='02',
+ ... length=10, length_uom=cm,
+ ... height=8, height_uom=cm,
+ ... width=1, width_uom=cm)
+ >>> ups_box.save()
+
+Create a UPS Carrier and the related credential::
+
+ >>> credential = UPSCredential()
+ >>> credential.company = company
+ >>> credential.client_id = os.getenv('UPS_CLIENT_ID')
+ >>> credential.client_secret = os.getenv('UPS_CLIENT_SECRET')
+ >>> credential.account_number = os.getenv('UPS_ACCOUNT_NUMBER')
+ >>> credential.server = 'testing'
+ >>> credential.use_international_forms = True
+ >>> credential.save()
+
+ >>> carrier_product_template = ProductTemplate()
+ >>> carrier_product_template.name = 'UPS Ground'
+ >>> carrier_product_template.default_uom = unit
+ >>> carrier_product_template.type = 'service'
+ >>> carrier_product_template.list_price = Decimal(20)
+ >>> carrier_product_template.save()
+ >>> carrier_product, = carrier_product_template.products
+
+ >>> ups = Party(name='UPS')
+ >>> ups.save()
+
+ >>> carrier = Carrier()
+ >>> carrier.party = ups
+ >>> carrier.carrier_product = carrier_product
+ >>> carrier.shipping_service = 'ups'
+ >>> carrier.ups_service_type = '65'
+ >>> carrier.ups_label_image_format = 'GIF'
+ >>> carrier.ups_notifications = ['5', '7', '012']
+ >>> carrier.save()
+
+Create a shipment::
+
+ >>> shipment = Shipment()
+ >>> shipment.customer = customer
+ >>> shipment.carrier = carrier
+ >>> shipment.shipping_description = "Shipping description"
+ >>> move = shipment.outgoing_moves.new()
+ >>> move.product = product
+ >>> move.unit = unit
+ >>> move.quantity = 2
+ >>> move.from_location = shipment.warehouse_output
+ >>> move.to_location = shipment.customer_location
+ >>> move.unit_price = Decimal('50.0000')
+ >>> move.currency = company.currency
+ >>> shipment.click('wait')
+ >>> assertEqual(shipment.customs_agent, agent)
+ >>> shipment.click('assign_force')
+ >>> shipment.click('pick')
+ >>> shipment.state
+ 'picked'
+
+Create the packs and ship the shipment::
+
+ >>> pack = shipment.packages.new()
+ >>> pack.type = ups_box
+ >>> pack_move, = pack.moves.find([])
+ >>> pack.moves.append(pack_move)
+ >>> shipment.click('pack')
+ >>> shipment.state
+ 'packed'
+
+ >>> create_shipping = shipment.click('create_shipping')
+ >>> shipment.reload()
+ >>> bool(shipment.shipping_reference)
+ True
diff --git a/modules/stock_package_shipping_ups/tests/test_module.py b/modules/stock_package_shipping_ups/tests/test_module.py
index d188e0e2ae..5269a885a7 100644
--- a/modules/stock_package_shipping_ups/tests/test_module.py
+++ b/modules/stock_package_shipping_ups/tests/test_module.py
@@ -7,6 +7,7 @@ from trytond.tests.test_tryton import ModuleTestCase
class ShippingUpsTestCase(ModuleTestCase):
'Test Shipping Ups module'
module = 'stock_package_shipping_ups'
+ extras = ['customs', 'incoterm', 'stock_shipment_customs']
del ModuleTestCase
diff --git a/modules/stock_package_shipping_ups/tests/test_scenario.py b/modules/stock_package_shipping_ups/tests/test_scenario.py
index 2fda2be23d..5cbef40783 100644
--- a/modules/stock_package_shipping_ups/tests/test_scenario.py
+++ b/modules/stock_package_shipping_ups/tests/test_scenario.py
@@ -11,5 +11,8 @@ def load_tests(*args, **kwargs):
or not (os.getenv('UPS_CLIENT_ID')
and os.getenv('UPS_CLIENT_SECRET')
and os.getenv('UPS_ACCOUNT_NUMBER'))):
- kwargs.setdefault('skips', set()).add('scenario_shipping_ups.rst')
+ kwargs.setdefault('skips', set()).update([
+ 'scenario_shipping_ups.rst',
+ 'scenario_stock_package_shipping_ups_international.rst',
+ ])
return load_doc_tests(__name__, __file__, *args, **kwargs)
diff --git a/modules/stock_package_shipping_ups/tryton.cfg b/modules/stock_package_shipping_ups/tryton.cfg
index 14ad982a3a..976acd7810 100644
--- a/modules/stock_package_shipping_ups/tryton.cfg
+++ b/modules/stock_package_shipping_ups/tryton.cfg
@@ -10,13 +10,20 @@ depends:
stock_shipment_measurements
stock_package
stock_package_shipping
+extras_depend:
+ customs
+ incoterm
+ stock_shipment_customs
xml:
+ product.xml
carrier.xml
+ customs.xml
stock.xml
message.xml
[register]
model:
+ product.UoM
carrier.CredentialUPS
carrier.Carrier
stock.PackageType
@@ -25,5 +32,13 @@ wizard:
stock.CreateShipping
stock.CreateShippingUPS
+[register customs]
+model:
+ customs.Agent
+
+[register incoterm stock_shipment_customs]
+wizard:
+ stock.CreateShippingUPS_Customs_Incoterm
+
[register_mixin]
stock.ShippingUPSMixin: trytond.modules.stock_package_shipping.stock.ShippingMixin
diff --git a/modules/stock_package_shipping_ups/view/customs_agent_form.xml b/modules/stock_package_shipping_ups/view/customs_agent_form.xml
new file mode 100644
index 0000000000..3373e150f3
--- /dev/null
+++ b/modules/stock_package_shipping_ups/view/customs_agent_form.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/stock_package_shipping_ups/view/customs_agent_list.xml b/modules/stock_package_shipping_ups/view/customs_agent_list.xml
new file mode 100644
index 0000000000..eda2054bea
--- /dev/null
+++ b/modules/stock_package_shipping_ups/view/customs_agent_list.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/modules/stock_package_shipping_ups/view/product_uom_form.xml b/modules/stock_package_shipping_ups/view/product_uom_form.xml
new file mode 100644
index 0000000000..797c901406
--- /dev/null
+++ b/modules/stock_package_shipping_ups/view/product_uom_form.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/stock_package_shipping_ups/view/ups_credential_form.xml b/modules/stock_package_shipping_ups/view/ups_credential_form.xml
index 84bf906ea7..1f4fa95edb 100644
--- a/modules/stock_package_shipping_ups/view/ups_credential_form.xml
+++ b/modules/stock_package_shipping_ups/view/ups_credential_form.xml
@@ -14,6 +14,8 @@ this repository contains the full copyright notices and license terms. -->
+
+