From 3e4e9198a30b212af8d3ad778c8506b22d74abcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 19 Mar 2026 15:20:46 +0100 Subject: [PATCH] Add pack wizard on shipments and package Closes #14586 --- modules/stock_package/CHANGELOG | 1 + modules/stock_package/stock.py | 155 +++++++++++++++++- modules/stock_package/stock.xml | 51 ++++++ .../tests/scenario_stock_package.rst | 63 ++++--- modules/stock_package/tryton.cfg | 4 + modules/stock_package/view/package_form.xml | 1 + .../stock_package/view/package_form_pack.xml | 39 +++++ .../view/package_pack_move_form.xml | 10 ++ .../view/shipment_in_return_form.xml | 8 +- .../view/shipment_internal_form.xml | 7 +- .../stock_package/view/shipment_out_form.xml | 8 +- 11 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 modules/stock_package/view/package_form_pack.xml create mode 100644 modules/stock_package/view/package_pack_move_form.xml diff --git a/modules/stock_package/CHANGELOG b/modules/stock_package/CHANGELOG index 310bebb5e2..046c66f1b3 100644 --- a/modules/stock_package/CHANGELOG +++ b/modules/stock_package/CHANGELOG @@ -1,3 +1,4 @@ +* Add pack wizard * Add support for Python 3.14 * Remove support for Python 3.9 diff --git a/modules/stock_package/stock.py b/modules/stock_package/stock.py index 1d2e062d12..6bed33e208 100644 --- a/modules/stock_package/stock.py +++ b/modules/stock_package/stock.py @@ -8,6 +8,8 @@ from trytond.model import ( from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval, Id, If from trytond.transaction import Transaction +from trytond.wizard import ( + Button, StateAction, StateTransition, StateView, Wizard) from .exceptions import PackageError, PackageValidationError @@ -200,16 +202,21 @@ class Package(tree(), MeasurementsMixin, ModelSQL, ModelView): ], states={ 'readonly': Eval('state') == 'closed', - }) + }, + help="The package that contains this package.") children = fields.One2Many( 'stock.package', 'parent', 'Children', domain=[ ('company', '=', Eval('company', -1)), ('shipment', '=', Eval('shipment')), ], + add_remove=[ + ('parent', '=', None), + ], states={ 'readonly': Eval('state') == 'closed', - }) + }, + help="The packages contained in this package.") state = fields.Function(fields.Selection([ ('open', "Open"), ('closed', "Closed"), @@ -229,6 +236,12 @@ class Package(tree(), MeasurementsMixin, ModelSQL, ModelView): field.states = { 'readonly': Eval('state') == 'closed', } + cls._buttons.update( + fill={ + 'invisible': Eval('state') != 'open', + 'icon': 'tryton-launch', + 'depends': ['state'], + }) @classmethod def __register__(cls, module): @@ -286,6 +299,11 @@ class Package(tree(), MeasurementsMixin, ModelSQL, ModelView): if isinstance(getattr(MeasurementsMixin, name), fields.Field): setattr(self, name, getattr(self.type, name)) + @classmethod + @ModelView.button_action('stock_package.wizard_package_pack') + def fill(cls, packages): + pass + @classmethod def validate(cls, packages): super().validate(packages) @@ -393,6 +411,13 @@ class PackageMixin(object): ('parent', '=', None), ]) + @classmethod + def __setup__(cls): + super().__setup__() + cls._buttons.update(pack_wizard={ + 'icon': 'tryton-launch', + }) + @classmethod def check_packages(cls, shipments): for shipment in shipments: @@ -408,6 +433,11 @@ class PackageMixin(object): def packages_moves(self): raise NotImplementedError + @classmethod + @ModelView.button_action('stock_package.wizard_shipment_pack') + def pack_wizard(cls, shipments): + pass + @classmethod def copy(cls, shipments, default=None): default = default.copy() if default is not None else {} @@ -428,6 +458,9 @@ class ShipmentOut(PackageMixin, object, metaclass=PoolMeta): Eval('state') != 'picked') for field in [cls.packages, cls.root_packages]: field.states['readonly'] = packages_readonly + cls._buttons['pack_wizard']['invisible'] = packages_readonly + cls._buttons['pack_wizard']['depends'] = [ + 'warehouse_storage', 'warehouse_output', 'state'] @classmethod @ModelView.button @@ -482,6 +515,8 @@ class ShipmentInReturn(PackageMixin, object, metaclass=PoolMeta): packages_readonly = ~Eval('state').in_(['waiting', 'assigned']) for field in [cls.packages, cls.root_packages]: field.states['readonly'] = packages_readonly + cls._buttons['pack_wizard']['invisible'] = packages_readonly + cls._buttons['pack_wizard']['depends'] = ['state'] @classmethod @ModelView.button @@ -508,6 +543,8 @@ class ShipmentInternal(PackageMixin, object, metaclass=PoolMeta): for field in [cls.packages, cls.root_packages]: field.states['readonly'] = packages_readonly field.states['invisible'] = packages_invisible + cls._buttons['pack_wizard']['invisible'] = packages_invisible + cls._buttons['pack_wizard']['depends'] = ['state', 'transit_location'] @classmethod @ModelView.button @@ -521,3 +558,117 @@ class ShipmentInternal(PackageMixin, object, metaclass=PoolMeta): return [ m for m in self.outgoing_moves if m.state != 'cancelled' and m.quantity] + + +class ShipmentPack(Wizard): + __name__ = 'stock.shipment.pack' + start_state = 'package' + package = StateView( + 'stock.package', + 'stock_package.package_view_form_pack', [ + Button("End", 'end', 'tryton-cancel'), + Button("Add Package", 'add_package', 'tryton-ok'), + Button( + "Add Package and Fill", 'add_fill_package', 'tryton-add', + default=True), + ]) + add_package = StateTransition() + add_fill_package = StateAction('stock_package.wizard_package_pack') + + def default_package(self, fields): + return { + 'company': self.record.company, + 'shipment': self.record, + } + + def transition_add_package(self): + self.package.save() + return 'package' + + def do_add_fill_package(self, action): + self.package.save() + return action, { + 'id': self.package.id, + 'ids': [self.package.id], + 'model': self.package.__name__, + } + + def transition_add_fill_package(self): + return 'package' + + +class PackagePack(Wizard): + __name__ = 'stock.package.pack' + start_state = 'next_' + next_ = StateTransition() + move = StateView( + 'stock.package.pack.move', + 'stock_package.package_pack_move_view_form', [ + Button("End", 'end', 'tryton-ok'), + Button("Add", 'add_move', 'tryton-forward', default=True), + ]) + add_move = StateTransition() + + def transition_next_(self): + if any(not m.package for m in self.record.allowed_moves): + return 'move' + else: + return 'end' + + def default_move(self, fields): + return { + 'allowed_moves': [ + m for m in self.record.allowed_moves if not m.package], + } + + def transition_add_move(self): + pool = Pool() + Move = pool.get('stock.move') + move = self.move.source + if self.move.quantity and self.move.quantity != move.quantity: + with Transaction().set_context(_stock_move_split=True): + Move.copy([move], { + 'quantity': move.quantity - self.move.quantity, + }) + move.quantity = self.move.quantity + move.package = self.record + move.save() + return 'next_' + + +class PackagePackMove(ModelView): + __name__ = 'stock.package.pack.move' + + source = fields.Many2One( + 'stock.move', "Source", required=True, + domain=[ + ('id', 'in', Eval('allowed_moves', [])), + ('package', '=', None), + ]) + quantity = fields.Float( + "Quantity", digits='unit', + domain=['OR', + ('quantity', '=', None), + [ + ('quantity', '>', 0), + ('quantity', '<=', Eval('move_quantity', 0)), + ], + ], + help="The quantity to pack from the move.\n" + "Leave empty for the full quantity of the move.") + unit = fields.Function( + fields.Many2One('product.uom', "Unit"), + 'on_change_with_unit') + move_quantity = fields.Function( + fields.Float("Move Quantity"), + 'on_change_with_move_quantity') + allowed_moves = fields.Many2Many( + 'stock.move', None, None, "Allowed Moves", readonly=True) + + @fields.depends('source') + def on_change_with_unit(self, name=None): + return self.source.unit if self.source else None + + @fields.depends('source') + def on_change_with_move_quantity(self, name=None): + return self.source.quantity if self.source else None diff --git a/modules/stock_package/stock.xml b/modules/stock_package/stock.xml index b243e1c076..4ee7633f5c 100644 --- a/modules/stock_package/stock.xml +++ b/modules/stock_package/stock.xml @@ -31,22 +31,38 @@ this repository contains the full copyright notices and license terms. --> stock.package form + package_form + + stock.package + form + + package_form_pack + + stock.package tree children + package_tree stock.package tree + package_list + + stock.package + fill + Fill + + User in companies stock.package @@ -137,17 +153,52 @@ this repository contains the full copyright notices and license terms. --> shipment_out_form + + stock.shipment.out + pack_wizard + Pack + + stock.shipment.in.return shipment_in_return_form + + stock.shipment.in.return + pack_wizard + Fill + + stock.shipment.internal shipment_internal_form + + stock.shipment.internal + pack_wizard + Add Package + + + + Pack Shipment + stock.shipment.pack + + + + Pack Package + stock.package.pack + stock.package + + + + stock.package.pack.move + form + package_pack_move_form + + diff --git a/modules/stock_package/tests/scenario_stock_package.rst b/modules/stock_package/tests/scenario_stock_package.rst index 39775425c3..8d8aa3e383 100644 --- a/modules/stock_package/tests/scenario_stock_package.rst +++ b/modules/stock_package/tests/scenario_stock_package.rst @@ -7,7 +7,7 @@ Imports:: >>> import datetime as dt >>> from decimal import Decimal - >>> from proteus import Model + >>> from proteus import Model, Wizard >>> from trytond.modules.company.tests.tools import create_company >>> from trytond.modules.currency.tests.tools import get_currency >>> from trytond.tests.tools import activate_modules @@ -18,6 +18,8 @@ Activate modules:: >>> config = activate_modules('stock_package', create_company) + >>> Package = Model.get('stock.package') + Get currency:: >>> currency = get_currency() @@ -66,7 +68,7 @@ Add two shipment lines of same product:: >>> for move in shipment_out.outgoing_moves: ... move.product = product ... move.unit = unit - ... move.quantity = 1 + ... move.quantity = 2 ... move.from_location = output_loc ... move.to_location = customer_loc ... move.unit_price = Decimal('1') @@ -91,37 +93,60 @@ Package products:: >>> box.packaging_volume >>> box.packaging_volume_uom, = ProductUom.find([('name', '=', "Cubic meter")]) >>> box.save() - >>> package1 = shipment_out.packages.new(type=box) - >>> package1.length - 80.0 - >>> package1.height = 50 - >>> package1.packaging_volume - 0.4 - >>> package_child = package1.children.new(shipment=shipment_out, type=box) - >>> package_child.height = 100 - >>> moves = package_child.moves.find() + + >>> shipment_pack = Wizard('stock.shipment.pack', [shipment_out]) + + >>> shipment_pack.form.type = box + >>> shipment_pack.form.height = 100 + >>> shipment_pack.execute('add_fill_package') + + >>> package_pack, = shipment_pack.actions + >>> moves = package_pack.form.allowed_moves >>> len(moves) 2 - >>> package_child.moves.append(moves[0]) + >>> package_pack.form.source = moves[0] + >>> package_pack.execute('add_move') + >>> package_pack.execute('end') - >>> shipment_out.save() + >>> shipment_out.reload() + >>> package, = shipment_out.root_packages + + >>> shipment_pack.form.type = box + >>> shipment_pack.form.children.append(Package(package.id)) + >>> shipment_pack.form.length + 80.0 + >>> shipment_pack.form.height = 50 + >>> shipment_pack.form.packaging_volume + 0.4 + + >>> shipment_pack.execute('add_package') Traceback (most recent call last): ... PackageValidationError: ... - >>> package1.height = 120 - >>> package1.packaging_volume + >>> shipment_pack.form.height = 120 + >>> shipment_pack.form.packaging_volume 0.96 + >>> shipment_pack.execute('add_package') + >>> shipment_out.reload() >>> shipment_out.click('pack') Traceback (most recent call last): ... PackageError: ... >>> package2 = shipment_out.packages.new(type=box) - >>> moves = package2.moves.find() - >>> len(moves) - 1 - >>> package2.moves.append(moves[0]) + >>> shipment_pack.form.type = box + >>> shipment_pack.execute('add_fill_package') + >>> package_pack, = shipment_pack.actions + >>> package_pack.form.source, = package_pack.form.allowed_moves + >>> package_pack.form.quantity = 1 + >>> package_pack.execute('add_move') + >>> package_pack.form.source, = package_pack.form.allowed_moves + >>> package_pack.execute('add_move') + >>> package_pack.execute('end') + >>> shipment_pack.execute('end') >>> shipment_out.click('pack') + >>> shipment_out.state + 'packed' diff --git a/modules/stock_package/tryton.cfg b/modules/stock_package/tryton.cfg index f601e4d717..1ea88eaa39 100644 --- a/modules/stock_package/tryton.cfg +++ b/modules/stock_package/tryton.cfg @@ -22,3 +22,7 @@ model: stock.ShipmentOut stock.ShipmentInReturn stock.ShipmentInternal + stock.PackagePackMove +wizard: + stock.ShipmentPack + stock.PackagePack diff --git a/modules/stock_package/view/package_form.xml b/modules/stock_package/view/package_form.xml index d2627bfc27..27109a098c 100644 --- a/modules/stock_package/view/package_form.xml +++ b/modules/stock_package/view/package_form.xml @@ -15,6 +15,7 @@ this repository contains the full copyright notices and license terms. --> +