details:   https://code.tryton.org/tryton/commit/28626f2e2d49
branch:    default
user:      Cédric Krier <[email protected]>
date:      Sat Feb 15 19:27:34 2025 +0100
description:
        Support Sendcloud international shipping
diffstat:

 modules/stock_package_shipping_sendcloud/CHANGELOG                             
                            |    1 +
 modules/stock_package_shipping_sendcloud/stock.py                              
                            |  128 +++++
 
modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst
 |  221 ++++++++++
 modules/stock_package_shipping_sendcloud/tests/test_scenario.py                
                            |    6 +-
 modules/stock_package_shipping_sendcloud/tryton.cfg                            
                            |    7 +
 5 files changed, 361 insertions(+), 2 deletions(-)

diffs (411 lines):

diff -r 225fbc061f26 -r 28626f2e2d49 
modules/stock_package_shipping_sendcloud/CHANGELOG
--- a/modules/stock_package_shipping_sendcloud/CHANGELOG        Mon Feb 17 
19:01:11 2025 +0100
+++ b/modules/stock_package_shipping_sendcloud/CHANGELOG        Sat Feb 15 
19:27:34 2025 +0100
@@ -1,3 +1,4 @@
+* Support international shipping
 
 Version 7.6.0 - 2025-04-28
 --------------------------
diff -r 225fbc061f26 -r 28626f2e2d49 
modules/stock_package_shipping_sendcloud/stock.py
--- a/modules/stock_package_shipping_sendcloud/stock.py Mon Feb 17 19:01:11 
2025 +0100
+++ b/modules/stock_package_shipping_sendcloud/stock.py Sat Feb 15 19:27:34 
2025 +0100
@@ -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 @@
         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 -r 225fbc061f26 -r 28626f2e2d49 
modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ 
b/modules/stock_package_shipping_sendcloud/tests/scenario_stock_package_shipping_sendcloud_international.rst
        Sat Feb 15 19:27:34 2025 +0100
@@ -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 -r 225fbc061f26 -r 28626f2e2d49 
modules/stock_package_shipping_sendcloud/tests/test_scenario.py
--- a/modules/stock_package_shipping_sendcloud/tests/test_scenario.py   Mon Feb 
17 19:01:11 2025 +0100
+++ b/modules/stock_package_shipping_sendcloud/tests/test_scenario.py   Sat Feb 
15 19:27:34 2025 +0100
@@ -10,6 +10,8 @@
     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 -r 225fbc061f26 -r 28626f2e2d49 
modules/stock_package_shipping_sendcloud/tryton.cfg
--- a/modules/stock_package_shipping_sendcloud/tryton.cfg       Mon Feb 17 
19:01:11 2025 +0100
+++ b/modules/stock_package_shipping_sendcloud/tryton.cfg       Sat Feb 15 
19:27:34 2025 +0100
@@ -10,6 +10,9 @@
     stock_shipment_measurements
     stock_package
     stock_package_shipping
+extras_depend:
+    incoterm
+    stock_shipment_customs
 xml:
     carrier.xml
     stock.xml
@@ -26,5 +29,9 @@
     stock.CreateShipping
     stock.CreateShippingSendcloud
 
+[register stock_shipment_customs]
+wizard:
+    stock.CreateShippingSendcloud_Customs
+
 [register_mixin]
 stock.ShippingSendcloudMixin: 
trytond.modules.stock_package_shipping.stock.ShippingMixin

Reply via email to