From 4cb10fa620843b6b4c6b9eac41fa2190f7d0fa13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 5 Nov 2025 17:18:15 +0100 Subject: [PATCH] Generate a default shipping description for the shipment Also for UPS, it fallbacks to "General Merchandise" if the shipping description is empty. Closes #14351 --- modules/stock_package_shipping/CHANGELOG | 1 + modules/stock_package_shipping/stock.py | 33 ++++++++++++- .../tests/test_module.py | 48 +++++++++++++++++++ modules/stock_package_shipping/tryton.cfg | 2 + modules/stock_package_shipping_ups/CHANGELOG | 1 + .../stock_package_shipping_ups/message.xml | 4 +- modules/stock_package_shipping_ups/stock.py | 13 ++--- 7 files changed, 90 insertions(+), 12 deletions(-) diff --git a/modules/stock_package_shipping/CHANGELOG b/modules/stock_package_shipping/CHANGELOG index f1161fa212..cee3fcfb10 100644 --- a/modules/stock_package_shipping/CHANGELOG +++ b/modules/stock_package_shipping/CHANGELOG @@ -1,3 +1,4 @@ +* Generate a default shipping description based on custom categories Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/stock_package_shipping/stock.py b/modules/stock_package_shipping/stock.py index 34651d9ef5..5df4aca503 100644 --- a/modules/stock_package_shipping/stock.py +++ b/modules/stock_package_shipping/stock.py @@ -96,6 +96,18 @@ class Package(metaclass=PoolMeta): pass +def lowest_common_root(paths): + min_length = min((len(p) for p in paths), default=0) + common = None + for i in range(min_length): + level_values = {p[i] for p in paths} + if len(level_values) == 1: + common = level_values.pop() + else: + break + return common + + class ShippingMixin: __slots__ = () @@ -107,7 +119,8 @@ class ShippingMixin: shipping_description = fields.Char('Shipping Description', states={ 'readonly': Eval('state').in_(['done', 'packed']) - }) + }, + help="Leave empty to use the generated description.") has_shipping_service = fields.Function( fields.Boolean("Has Shipping Service"), 'on_change_with_has_shipping_service') @@ -148,6 +161,24 @@ class ShippingMixin: [table.shipping_reference], [table.reference])) + @property + def shipping_description_used(self): + pool = Pool() + Product = pool.get('product.product') + description = self.shipping_description + if not description and hasattr(Product, 'customs_category'): + def parents(category): + if not category: + return + yield from parents(category.parent) + yield category + products = { + m.product for m in self.moves if m.product.customs_category} + paths = [list(parents(p.customs_category)) for p in products] + if category := lowest_common_root(paths): + description = category.name + return description + @fields.depends('carrier') def on_change_with_has_shipping_service(self, name=None): return bool(self.carrier and self.carrier.shipping_service) diff --git a/modules/stock_package_shipping/tests/test_module.py b/modules/stock_package_shipping/tests/test_module.py index b630aa1da0..a8433e7b93 100644 --- a/modules/stock_package_shipping/tests/test_module.py +++ b/modules/stock_package_shipping/tests/test_module.py @@ -1,6 +1,9 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import unittest + +from trytond.modules.stock_package_shipping.stock import lowest_common_root from trytond.tests.test_tryton import ModuleTestCase @@ -9,4 +12,49 @@ class StockPackageShippingTestCase(ModuleTestCase): module = 'stock_package_shipping' +class testLowestCommonRoot(unittest.TestCase): + + def test_simple_common_root(self): + "Test simple common root" + paths = [ + ["Apparel", "Men", "Shirts"], + ["Apparel", "Men", "Pants"] + ] + self.assertEqual(lowest_common_root(paths), "Men") + + def test_root_only_common(self): + "Test no common" + paths = [ + ["Root", "Apparel", "Men", "Shirts"], + ["Root", "Electronics", "Mobile"] + ] + self.assertEqual(lowest_common_root(paths), "Root") + + def test_identical_paths(self): + "Test identical paths" + paths = [ + ["Apparel", "Men", "Shirts"], + ["Apparel", "Men", "Shirts"] + ] + self.assertEqual(lowest_common_root(paths), "Shirts") + + def test_single_path(self): + "Test single path" + paths = [["Apparel", "Men", "Shirts"]] + self.assertEqual(lowest_common_root(paths), "Shirts") + + def test_empty_paths_list(self): + "Test empty paths list" + paths = [] + self.assertIsNone(lowest_common_root(paths)) + + def test_no_common_root(self): + "Test no common root" + paths = [ + ["Apparel", "Men"], + ["Electronics", "Mobile"] + ] + self.assertIsNone(lowest_common_root(paths)) + + del ModuleTestCase diff --git a/modules/stock_package_shipping/tryton.cfg b/modules/stock_package_shipping/tryton.cfg index e58489c163..67753d614d 100644 --- a/modules/stock_package_shipping/tryton.cfg +++ b/modules/stock_package_shipping/tryton.cfg @@ -10,6 +10,8 @@ depends: stock_shipment_measurements stock_shipment_cost product_measurements +extras_depend: + customs xml: carrier.xml stock.xml diff --git a/modules/stock_package_shipping_ups/CHANGELOG b/modules/stock_package_shipping_ups/CHANGELOG index 30e2922b6d..1f67b40d3c 100644 --- a/modules/stock_package_shipping_ups/CHANGELOG +++ b/modules/stock_package_shipping_ups/CHANGELOG @@ -1,3 +1,4 @@ +* Use "General Merchandise" as fallback shipping description Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/stock_package_shipping_ups/message.xml b/modules/stock_package_shipping_ups/message.xml index 10c844a4ca..c175b19f9f 100644 --- a/modules/stock_package_shipping_ups/message.xml +++ b/modules/stock_package_shipping_ups/message.xml @@ -16,8 +16,8 @@ this repository contains the full copyright notices and license terms. --> To validate shipment "%(shipment)s" you must add phone number for address "%(address)s". - - To validate shipment "%(shipment)s" you must fill its shipping description. + + General Merchandise You cannot create shipping for shipment "%(shipment)s" because it has already a reference number. diff --git a/modules/stock_package_shipping_ups/stock.py b/modules/stock_package_shipping_ups/stock.py index 5233e4e167..cb0addb5dd 100644 --- a/modules/stock_package_shipping_ups/stock.py +++ b/modules/stock_package_shipping_ups/stock.py @@ -105,14 +105,6 @@ class ShippingUPSMixin: '.msg_phone_required', shipment=self.rec_name, address=address.rec_name)) - if not self.shipping_description: - if (any(p.type.ups_code != '01' for p in self.root_packages) - and self.carrier.ups_service_type != '11'): - # TODO Should also test if a country is not in the EU - raise PackingValidationError( - gettext('stock_package_shipping_ups' - '.msg_shipping_description_required', - shipment=self.rec_name)) class CreateShipping(metaclass=PoolMeta): @@ -375,11 +367,14 @@ class CreateShippingUPS(Wizard): # despite what UPS documentation says for pkg in packages: pkg['ShipmentServiceOptions'] = options + description = ( + shipment.shipping_description_used + or gettext('stock_package_shipping_ups.msg_general_merchandise')) return { 'ShipmentRequest': { 'Request': self.get_request_container(shipment), 'Shipment': { - 'Description': (shipment.shipping_description or '')[:50], + 'Description': description[:50], 'Shipper': shipper, 'ShipTo': self.get_shipping_party( shipment.shipping_to, shipment.shipping_to_address),