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