diff --git a/modules/stock_package_shipping_sendcloud/CHANGELOG b/modules/stock_package_shipping_sendcloud/CHANGELOG index edbd24fda9..c4b43fa06d 100644 --- a/modules/stock_package_shipping_sendcloud/CHANGELOG +++ b/modules/stock_package_shipping_sendcloud/CHANGELOG @@ -1,3 +1,4 @@ +* Support international shipping Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/stock_package_shipping_sendcloud/stock.py b/modules/stock_package_shipping_sendcloud/stock.py index 1f351e30fa..fe604d0afc 100644 --- a/modules/stock_package_shipping_sendcloud/stock.py +++ b/modules/stock_package_shipping_sendcloud/stock.py @@ -1,5 +1,7 @@ # 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 decimal import Decimal from itertools import zip_longest from math import ceil @@ -205,3 +207,129 @@ class CreateShippingSendcloud(Wizard): else: parcel['apply_shipping_rules'] = True return parcel + + +class CreateShippingSendcloud_Customs(metaclass=PoolMeta): + __name__ = 'stock.shipment.create_shipping.sendcloud' + + def get_parcel(self, shipment, package, credential, usage=None): + parcel = super().get_parcel(shipment, package, credential, usage=usage) + if shipment.customs_international: + parcel['customs_invoice_nr'] = shipment.number + parcel['customs_shipment_type'] = self.get_customs_shipment_type( + shipment, credential) + parcel['export_type'] = self.get_export_type(shipment, credential) + if description := shipment.shipping_description_used: + parcel['general_notes'] = description[:500] + parcel['tax_numbers'] = { + 'sender': list(self.get_tax_numbers( + shipment, shipment.company.party, credential)), + 'receiver': list(self.get_tax_numbers( + shipment, shipment.shipping_to, credential)), + } + if shipment.customs_agent: + parcel['importer_of_record'] = self.get_importer_of_record( + shipment, shipment.customs_agent, credential) + parcel['tax_numbers']['importer_of_record'] = list( + self.get_tax_numbers( + shipment, shipment.customs_agent.party, credential)) + + parcel_items = [] + for k, v in shipment.customs_products.items(): + parcel_items.append(self.get_parcel_item(shipment, *k, **v)) + parcel['parcel_items'] = parcel_items + return parcel + + def get_customs_shipment_type(self, shipment, credential): + return { + 'stock.shipment.out': 2, + 'stock.shipment.in.return': 4, + }.get(shipment.__class__.__name__) + + def get_export_type(self, shipment, credential): + if shipment.shipping_to.tax_identifier: + return 'commercial_b2b' + else: + return 'commercial_b2c' + + def get_importer_of_record( + self, shipment, customs_agent, credential, usage=None): + address = customs_agent.address + phone = address.contact_mechanism_get( + {'phone', 'mobile'}, usage=usage) + email = address.contact_mechanism_get('email', usage=usage) + street_lines = (address.street or '').splitlines() + return { + 'name': customs_agent.party.full_name[:75], + 'address_1': street_lines[0] if street_lines else '', + 'address_2': street_lines[1] if len(street_lines) > 1 else '', + 'house_number': None, # TODO + 'city': address.city, + 'postal_code': address.postal_code, + 'country_code': address.country.code if address.country else None, + 'country_state': ( + address.subdivision.split('-', 1)[1] + if address.subdivision else None), + 'telephone': phone.value if phone else None, + 'email': email.value if email else None, + } + + def get_tax_numbers(self, shipment, party, credential): + for tax_identifier in party.identifiers: + if tax_identifier.type == 'br_vat': + yield { + 'name': 'CNP', + 'country_code': 'BR', + 'value': tax_identifier.code[:100], + } + elif tax_identifier.type == 'ru_vat': + yield { + 'name': 'INN', + 'country_code': 'RU', + 'value': tax_identifier.code[:100], + } + elif tax_identifier.type == 'eu_vat': + yield { + 'name': 'VAT', + 'country_code': tax_identifier.code[:2], + 'value': tax_identifier.code[2:][:100], + } + elif tax_identifier.type.endswith('_vat'): + yield { + 'name': 'VAT', + 'country_code': tax_identifier.type[:2].upper(), + 'value': tax_identifier.code[:100], + } + elif tax_identifier.type in {'us_ein', 'us_ssn'}: + country, name = tax_identifier.type.upper().split('_') + yield { + 'name': name, + 'country_code': country, + 'value': tax_identifier.code[:100], + } + + def get_parcel_item( + 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), + }) + if not quantity.is_integer(): + value = price * Decimal(str(quantity)) + quantity = 1 + else: + value = price + + return { + 'hs_code': tariff_code.code if tariff_code else None, + 'weight': weight, + 'quantity': quantity, + 'description': product.name[:255], + 'origin_country': ( + product.country_of_origin.code if product.country_of_origin + else None), + 'value': float(value.quantize(Decimal('.01'))), + 'product_id': product.code, + } diff --git a/modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst b/modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst new file mode 100644 index 0000000000..efab176381 --- /dev/null +++ b/modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst @@ -0,0 +1,221 @@ +======================================================= +Stock Package Shipping Sendcloud International Scenario +======================================================= + +Imports:: + + >>> import os + >>> from decimal import Decimal + >>> from random import randint + + >>> import requests + + >>> from proteus import Model + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.stock_package_shipping_sendcloud.carrier import ( + ... SENDCLOUD_API_URL) + >>> from trytond.tests.tools import activate_modules, assertEqual + +Activate modules:: + + >>> config = activate_modules( + ... ['stock_package_shipping_sendcloud', 'stock_shipment_customs'], + ... create_company) + + >>> Address = Model.get('party.address') + >>> Agent = Model.get('customs.agent') + >>> AgentSelection = Model.get('customs.agent.selection') + >>> Carrier = Model.get('carrier') + >>> CarrierAddress = Model.get('carrier.sendcloud.address') + >>> CarrierShippingMethod = Model.get('carrier.sendcloud.shipping_method') + >>> Country = Model.get('country.country') + >>> Credential = Model.get('carrier.credential.sendcloud') + >>> 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') + >>> StockConfiguration = Model.get('stock.configuration') + >>> TariffCode = Model.get('customs.tariff.code') + >>> UoM = Model.get('product.uom') + +Get company:: + + >>> company = get_company() + +Set random sequence:: + + >>> stock_config = StockConfiguration(1) + >>> stock_config.shipment_out_sequence.number_next = randint(1, 10**6) + >>> stock_config.shipment_out_sequence.save() + >>> stock_config.package_sequence.number_next = randint(1, 10**6) + >>> stock_config.package_sequence.save() + +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.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 tariff:: + + >>> tariff_code = TariffCode(code='170390') + >>> tariff_code.save() + +Create product:: + + >>> template = ProductTemplate() + >>> template.name = "Product" + >>> template.code = 'P001' + >>> 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=1, width_uom=cm) + >>> box.save() + +Create a Sendcloud Carrier and the related credentials:: + + >>> credential = Credential() + >>> credential.company = company + >>> credential.public_key = os.getenv('SENDCLOUD_PUBLIC_KEY') + >>> credential.secret_key = os.getenv('SENDCLOUD_SECRET_KEY') + >>> credential.save() + >>> address = credential.addresses.new() + >>> address.warehouse = warehouse + >>> address.address = CarrierAddress.get_addresses( + ... {'id': address.id, 'sendcloud': {'id': credential.id}}, + ... address._context)[-1][0] + >>> shipping_method = credential.shipping_methods.new() + >>> shipping_method.shipping_method, = [ + ... m[0] for m in CarrierShippingMethod.get_shipping_methods( + ... {'id': shipping_method.id, 'sendcloud': {'id': credential.id}}, + ... shipping_method._context) + ... if m[1] == "Unstamped letter"] + >>> credential.save() + + >>> carrier_product_template = ProductTemplate() + >>> carrier_product_template.name = "Sendcloud" + >>> 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 + + >>> sendcloud = Party(name="Sendcloud") + >>> sendcloud.save() + + >>> carrier = Carrier() + >>> carrier.party = sendcloud + >>> carrier.carrier_product = carrier_product + >>> carrier.shipping_service = 'sendcloud' + >>> 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 = 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 + +Clean up:: + + >>> _ = requests.post( + ... SENDCLOUD_API_URL + 'parcels/%s/cancel' % pack.sendcloud_shipping_id, + ... auth=(credential.public_key, credential.secret_key)) diff --git a/modules/stock_package_shipping_sendcloud/tests/test_scenario.py b/modules/stock_package_shipping_sendcloud/tests/test_scenario.py index 787e6aaccb..fa1564ba25 100644 --- a/modules/stock_package_shipping_sendcloud/tests/test_scenario.py +++ b/modules/stock_package_shipping_sendcloud/tests/test_scenario.py @@ -10,6 +10,8 @@ def load_tests(*args, **kwargs): if (not TEST_NETWORK or not (os.getenv('SENDCLOUD_PUBLIC_KEY') and os.getenv('SENDCLOUD_SECRET_KEY'))): - kwargs.setdefault('skips', set()).add( - 'scenario_stock_package_shipping_sendcloud.rst') + kwargs.setdefault('skips', set()).update([ + 'scenario_stock_package_shipping_sendcloud.rst', + 'scenario_stock_package_shipping_sendcloud_international.rst', + ]) return load_doc_tests(__name__, __file__, *args, **kwargs) diff --git a/modules/stock_package_shipping_sendcloud/tryton.cfg b/modules/stock_package_shipping_sendcloud/tryton.cfg index 938905d699..f4bd8d9475 100644 --- a/modules/stock_package_shipping_sendcloud/tryton.cfg +++ b/modules/stock_package_shipping_sendcloud/tryton.cfg @@ -10,6 +10,9 @@ depends: stock_shipment_measurements stock_package stock_package_shipping +extras_depend: + incoterm + stock_shipment_customs xml: carrier.xml stock.xml @@ -26,5 +29,9 @@ wizard: stock.CreateShipping stock.CreateShippingSendcloud +[register stock_shipment_customs] +wizard: + stock.CreateShippingSendcloud_Customs + [register_mixin] stock.ShippingSendcloudMixin: trytond.modules.stock_package_shipping.stock.ShippingMixin