From 8a772ad678fd6f7205e480fa64b473b1af7b3370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 15 Feb 2025 22:23:11 +0100 Subject: [PATCH] Support DPD international shipping --- modules/stock_package_shipping_dpd/CHANGELOG | 1 + modules/stock_package_shipping_dpd/setup.py | 1 + modules/stock_package_shipping_dpd/stock.py | 123 ++++++++++- ...ock_package_shipping_dpd_international.rst | 200 ++++++++++++++++++ .../tests/test_scenario.py | 5 +- modules/stock_package_shipping_dpd/tryton.cfg | 8 + 6 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 modules/stock_package_shipping_dpd/tests/scenario_stock_package_shipping_dpd_international.rst diff --git a/modules/stock_package_shipping_dpd/CHANGELOG b/modules/stock_package_shipping_dpd/CHANGELOG index 30f7e38b1f..92332cf6e5 100644 --- a/modules/stock_package_shipping_dpd/CHANGELOG +++ b/modules/stock_package_shipping_dpd/CHANGELOG @@ -1,3 +1,4 @@ +* Support international shipping * Update to Shipment Service 4.4 * Support international shipping diff --git a/modules/stock_package_shipping_dpd/setup.py b/modules/stock_package_shipping_dpd/setup.py index c0a64ab260..3a0e67e389 100755 --- a/modules/stock_package_shipping_dpd/setup.py +++ b/modules/stock_package_shipping_dpd/setup.py @@ -49,6 +49,7 @@ requires.append(get_require_version('trytond')) tests_require = [ get_require_version('proteus'), + get_require_version('trytond_incoterm'), get_require_version('trytond_sale'), get_require_version('trytond_sale_shipment_cost'), ] diff --git a/modules/stock_package_shipping_dpd/stock.py b/modules/stock_package_shipping_dpd/stock.py index ff7aac64d7..f7032ea38a 100644 --- a/modules/stock_package_shipping_dpd/stock.py +++ b/modules/stock_package_shipping_dpd/stock.py @@ -2,6 +2,7 @@ # this repository contains the full copyright notices and license terms. import locale +from decimal import Decimal from io import BytesIO from itertools import zip_longest from math import ceil @@ -235,7 +236,7 @@ class CreateDPDShipping(Wizard): return shipping_party - def get_parcel(self, package): + def get_parcel(self, shipment, package): pool = Pool() UoM = pool.get('product.uom') ModelData = pool.get('ir.model.data') @@ -269,22 +270,38 @@ class CreateDPDShipping(Wizard): return parcel def get_shipment_data(self, credential, shipment, packages): + pool = Pool() + UoM = pool.get('product.uom') + ModelData = pool.get('ir.model.data') + + cm3 = UoM(ModelData.get_id('product', 'uom_cubic_centimeter')) + kg = UoM(ModelData.get_id('product', 'uom_kilogram')) + + volume = round(UoM.compute_qty( + shipment.volume_uom, shipment.volume, cm3, round=False)) + weight = round(UoM.compute_qty( + shipment.weight_uom, shipment.weight, kg, round=False), 2) return { 'generalShipmentData': { 'identificationNumber': shipment.number, 'sendingDepot': credential.depot, 'product': shipment.carrier.dpd_product, + 'mpsVolume': int(volume), + 'mpsWeight': int(weight * 100), 'sender': self.shipping_party( shipment.company.party, shipment.shipping_warehouse.address), 'recipient': self.shipping_party( shipment.shipping_to, shipment.shipping_to_address), }, - 'parcels': [self.get_parcel(p) for p in packages], - 'productAndServiceData': { - 'orderType': 'consignment', - **self.get_notification(shipment), - }, + 'parcels': [self.get_parcel(shipment, p) for p in packages], + 'productAndServiceData': self.get_product_and_service(shipment), + } + + def get_product_and_service(self, shipment): + return { + 'orderType': 'consignment', + **self.get_notification(shipment), } def get_notification(self, shipment, usage=None): @@ -324,3 +341,97 @@ class CreateDPDShipping(Wizard): }, } return {} + + +class CreateDPDShipping_Customs(metaclass=PoolMeta): + __name__ = 'stock.shipment.create_shipping.dpd' + + def get_product_and_service(self, shipment): + pool = Pool() + Date = pool.get('ir.date') + Currency = pool.get('currency.currency') + + with Transaction().set_context(company=shipment.company.id): + today = Date.today() + + product_and_service = super().get_product_and_service(shipment) + + if shipment.customs_international: + invoice_date = shipment.effective_date or today + currency, = {m.currency for m in shipment.customs_moves} + + amount = sum( + Currency.compute( + curr, Decimal(str(v['quantity'])) * price, currency, + round=False) + for (product, price, curr, _), v in + shipment.customs_products.items()) + + international = { + 'parcelType': 0, + 'customsAmount': int( + amount.quantize(Decimal('.01')) * Decimal(100)), + 'customsCurrency': currency.code, + 'customsTerms': self.get_customs_terms(shipment), + 'customsPaper': 'A', + 'customsInvoice': shipment.number[:20], + 'customsInvoiceDate': invoice_date.strftime('%Y%m%d'), + } + if customs_agent := shipment.customs_agent: + international.update({ + 'commercialInvoiceConsigneeVatNumber': ( + customs_agent.tax_identifier.code)[:20], + 'commercialInvoiceConsignee': self.shipping_party( + customs_agent.party, + customs_agent.address), + }) + if shipment.tax_identifier: + international['commercialInvoiceConsignorVatNumber'] = ( + shipment.tax_identifier.code[:17]) + international['commercialInvoiceConsignor'] = self.shipping_party( + shipment.company.party, shipment.customs_from_address) + international['additionalInvoiceLines'] = [ + {'customsInvoicePosition': i, + **self.get_international_invoice_line(shipment, *k, **v)} + for i, (k, v) in enumerate( + shipment.customs_products.items(), 1)] + international['numberOfArticle'] = len( + international['additionalInvoiceLines']) + product_and_service['international'] = international + return product_and_service + + def get_customs_terms(self, shipment): + if shipment and shipment.incoterm: + if shipment.incoterm.code == 'DAP': + return '01' + elif shipment.incoterm.code == 'DDP': + return '03' + elif shipment.incoterm.code == 'EXW': + return '05' + + def get_international_invoice_line( + self, shipment, product, price, currency, unit, quantity, weight): + 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 = round(weight, 2) + + value = price * Decimal(str(quantity)) + if not quantity.is_integer(): + quantity = 1 + + return { + 'quantityItems': int(quantity), + 'customsContent': product.name[:200], + 'customsTarif': tariff_code.code if tariff_code else None, + 'customsAmountLine': int( + value.quantize(Decimal('.01')) * Decimal(100)), + 'customsOrigin': ( + product.country_of_origin.code_numeric + if product.country_of_origin else None), + 'customsGrossWeight': int(weight * 100), + } diff --git a/modules/stock_package_shipping_dpd/tests/scenario_stock_package_shipping_dpd_international.rst b/modules/stock_package_shipping_dpd/tests/scenario_stock_package_shipping_dpd_international.rst new file mode 100644 index 0000000000..a57bb87b11 --- /dev/null +++ b/modules/stock_package_shipping_dpd/tests/scenario_stock_package_shipping_dpd_international.rst @@ -0,0 +1,200 @@ +====================================================== +Stock Package Shipping with DPD 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_dpd', 'stock_shipment_customs', 'incoterm'], + ... create_company) + + >>> Address = Model.get('party.address') + >>> Agent = Model.get('customs.agent') + >>> AgentSelection = Model.get('customs.agent.selection') + >>> Carrier = Model.get('carrier') + >>> Country = Model.get('country.country') + >>> DPDCredential = Model.get('carrier.credential.dpd') + >>> Incoterm = Model.get('incoterm.incoterm') + >>> 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') + >>> TariffCode = Model.get('customs.tariff.code') + >>> 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', code_numeric='158', 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_email = agent_party.contact_mechanisms.new() + >>> agent_email.type = 'email' + >>> agent_email.value = 'agent@example.com' + >>> agent_phone = agent_party.contact_mechanisms.new() + >>> agent_phone.type = 'phone' + >>> agent_phone.value = '+(41) (041) 745-55-28' + >>> 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 tariff:: + + >>> tariff_code = TariffCode(code='17039099') + >>> tariff_code.save() + +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.tariff_codes.new(tariff_code=tariff_code) + >>> template.save() + >>> product, = template.products + +Create Package Type:: + + >>> box = PackageType( + ... name='Box', + ... length=10, length_uom=cm, + ... height=8, height_uom=cm, + ... width=5, width_uom=cm) + >>> box.save() + +Create a DPD Carrier and the related credential:: + + >>> credential = DPDCredential() + >>> credential.company = company + >>> credential.user_id = os.getenv('DPD_USER_ID') + >>> credential.password = os.getenv('DPD_PASSWORD') + >>> credential.server = 'testing' + >>> credential.save() + + >>> carrier_product_template = ProductTemplate() + >>> carrier_product_template.name = 'DPD Delivery' + >>> 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 + + >>> dpd = Party(name='DPD') + >>> dpd.save() + + >>> carrier = Carrier() + >>> carrier.party = dpd + >>> carrier.carrier_product = carrier_product + >>> carrier.shipping_service = 'dpd' + >>> carrier.dpd_product = 'CL' + >>> carrier.dpd_output_format = 'PDF' + >>> carrier.dpd_paper_format = 'A6' + >>> carrier.dpd_notification = 'sms' + >>> carrier.save() + +Create a shipment:: + + >>> shipment = Shipment() + >>> shipment.customer = customer + >>> shipment.carrier = carrier + >>> shipment.incoterm, = Incoterm.find([('code', '=', 'DAP')], limit=1) + >>> 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 = 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_dpd/tests/test_scenario.py b/modules/stock_package_shipping_dpd/tests/test_scenario.py index 9c0db3521b..dbdd844145 100644 --- a/modules/stock_package_shipping_dpd/tests/test_scenario.py +++ b/modules/stock_package_shipping_dpd/tests/test_scenario.py @@ -9,5 +9,8 @@ from trytond.tests.test_tryton import TEST_NETWORK, load_doc_tests def load_tests(*args, **kwargs): if (not TEST_NETWORK or not (os.getenv('DPD_USER_ID') and os.getenv('DPD_PASSWORD'))): - kwargs.setdefault('skips', set()).add('scenario_shipping_dpd.rst') + kwargs.setdefault('skips', set()).update([ + 'scenario_shipping_dpd.rst', + 'scenario_stock_package_shipping_dpd_international.rst', + ]) return load_doc_tests(__name__, __file__, *args, **kwargs) diff --git a/modules/stock_package_shipping_dpd/tryton.cfg b/modules/stock_package_shipping_dpd/tryton.cfg index 69a8f8b544..5e72358a0a 100644 --- a/modules/stock_package_shipping_dpd/tryton.cfg +++ b/modules/stock_package_shipping_dpd/tryton.cfg @@ -9,6 +9,10 @@ depends: stock_shipment_measurements stock_package stock_package_shipping +extras_depend: + customs + incoterm + stock_shipment_customs xml: carrier.xml stock.xml @@ -23,5 +27,9 @@ wizard: stock.CreateShipping stock.CreateDPDShipping +[register incoterm stock_shipment_customs] +wizard: + stock.CreateDPDShipping_Customs + [register_mixin] stock.ShippingDPDMixin: trytond.modules.stock_package_shipping.stock.ShippingMixin