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. -->