Support Sendcloud international shipping
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
+221
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user