diff --git a/modules/stock_split/CHANGELOG b/modules/stock_split/CHANGELOG index 11991d159a..201c67821b 100644 --- a/modules/stock_split/CHANGELOG +++ b/modules/stock_split/CHANGELOG @@ -1,3 +1,4 @@ +* Allow unsplitting moves Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/stock_split/doc/design.rst b/modules/stock_split/doc/design.rst index 5b5b221824..e67bb53e4e 100644 --- a/modules/stock_split/doc/design.rst +++ b/modules/stock_split/doc/design.rst @@ -11,7 +11,8 @@ Move When the *Stock Split Module* is activated, the `Move ` gain a :guilabel:`Split` button to launch the `Split Move -` wizard. +` wizard and another :guilabel:`Unsplit` button to +reverse the operation. .. seealso:: diff --git a/modules/stock_split/stock.py b/modules/stock_split/stock.py index 24b3502a49..69eb004660 100644 --- a/modules/stock_split/stock.py +++ b/modules/stock_split/stock.py @@ -18,6 +18,10 @@ class Move(metaclass=PoolMeta): 'readonly': ~Eval('state').in_(['draft', 'assigned']), 'depends': ['state'], }, + 'unsplit': { + 'readonly': ~Eval('state').in_(['draft', 'assigned']), + 'depends': ['state'], + }, }) @classmethod @@ -73,6 +77,51 @@ class Move(metaclass=PoolMeta): }) return moves + @classmethod + @ModelView.button + def unsplit(cls, moves, _exclude=None): + pool = Pool() + UoM = pool.get('product.uom') + + _exclude = _exclude.copy() if _exclude is not None else set() + _exclude.update({ + 'id', 'create_uid', 'create_date', 'write_uid', 'write_date', + 'quantity', 'unit', 'internal_quantity'}) + + fields_names = { + name for name, field in cls._fields.items() + if not isinstance(field, (fields.Function, fields.MultiValue))} + fields_names -= _exclude + values = cls.read([m.id for m in moves], fields_names=fields_names) + id2values = {v.pop('id'): v for v in values} + + groups = [] + for move in moves: + for group in groups: + if id2values[move.id] == id2values[group[0].id]: + group.append(move) + break + else: + groups.append([move]) + + def unit_precision(unit): + return unit.factor * unit.rounding + + to_save, to_clear = [], [] + for group in groups: + if len(group) <= 1: + continue + quantity = sum(m.internal_quantity for m in group) + unit = min((m.unit for m in group), key=unit_precision) + move, *others = group + move.quantity = UoM.compute_qty( + move.product.default_uom, quantity, unit) + move.unit = unit + to_save.append(move) + to_clear.extend(others) + cls.write(to_clear, {'quantity': 0}) + cls.save(to_save) + class SplitMoveStart(ModelView): __name__ = 'stock.move.split.start' diff --git a/modules/stock_split/stock.xml b/modules/stock_split/stock.xml index eaacb27e64..e35297a7b9 100644 --- a/modules/stock_split/stock.xml +++ b/modules/stock_split/stock.xml @@ -49,6 +49,12 @@ this repository contains the full copyright notices and license terms. --> Split + + stock.move + unsplit + Unsplit + + stock.shipment.in.return diff --git a/modules/stock_split/tests/test_module.py b/modules/stock_split/tests/test_module.py index fe5c735db2..ba3c248007 100644 --- a/modules/stock_split/tests/test_module.py +++ b/modules/stock_split/tests/test_module.py @@ -86,5 +86,93 @@ class StockSplitTestCase(CompanyTestMixin, ModuleTestCase): self.assertEqual([m.state for m in moves], ['assigned', 'assigned']) + @with_transaction() + def test_unsplit(self): + "Test unsplit" + pool = Pool() + Uom = pool.get('product.uom') + Template = pool.get('product.template') + Product = pool.get('product.product') + Location = pool.get('stock.location') + Move = pool.get('stock.move') + + meter, = Uom.search([('name', '=', "Meter")]) + cm, = Uom.search([('name', '=', "Centimeter")]) + template, = Template.create([{ + 'name': 'Test Split', + 'type': 'goods', + 'default_uom': meter.id, + }]) + product, = Product.create([{ + 'template': template.id, + }]) + input_, = Location.search([('code', '=', 'IN')]) + storage, = Location.search([('code', '=', 'STO')]) + company = create_company() + with set_company(company): + moves = Move.create([{ + 'product': product, + 'unit': meter, + 'quantity': 2, + 'from_location': input_.id, + 'to_location': storage.id, + 'company': company.id, + }, { + 'product': product, + 'unit': cm, + 'quantity': 25, + 'from_location': input_.id, + 'to_location': storage.id, + 'company': company.id, + }]) + Move.unsplit(moves) + + self.assertEqual(moves[0].quantity, 225) + self.assertEqual(moves[0].unit, cm) + self.assertEqual(moves[1].quantity, 0) + + @with_transaction() + def test_unsplit_different(self): + "Test unsplit different" + pool = Pool() + Uom = pool.get('product.uom') + Template = pool.get('product.template') + Product = pool.get('product.product') + Location = pool.get('stock.location') + Move = pool.get('stock.move') + + unit, = Uom.search([('name', '=', "Unit")]) + template, = Template.create([{ + 'name': 'Test Split', + 'type': 'goods', + 'default_uom': unit.id, + }]) + product, = Product.create([{ + 'template': template.id, + }]) + input_, = Location.search([('code', '=', 'IN')]) + storage, = Location.search([('code', '=', 'STO')]) + company = create_company() + with set_company(company): + moves = Move.create([{ + 'product': product, + 'unit': unit, + 'quantity': 2, + 'from_location': input_.id, + 'to_location': storage.id, + 'company': company.id, + }, { + 'product': product, + 'unit': unit, + 'quantity': 2, + 'from_location': storage.id, + 'to_location': input_.id, + 'company': company.id, + }]) + Move.unsplit(moves) + + self.assertEqual(moves[0].quantity, 2) + self.assertEqual(moves[1].quantity, 2) + del ModuleTestCase diff --git a/modules/stock_split/view/move_tree.xml b/modules/stock_split/view/move_tree.xml index e7d5b40807..36d4e5161c 100644 --- a/modules/stock_split/view/move_tree.xml +++ b/modules/stock_split/view/move_tree.xml @@ -4,5 +4,6 @@ this repository contains the full copyright notices and license terms. -->