Support Sendcloud international shipping

This commit is contained in:
Cédric Krier
2025-02-15 19:27:34 +01:00
parent 791d649dde
commit 39a79c70ef
5 changed files with 361 additions and 2 deletions
@@ -1,3 +1,4 @@
* Support international shipping
Version 7.6.0 - 2025-04-28
--------------------------
@@ -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,
}
@@ -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))
@@ -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)
@@ -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