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),