diff --git a/stock_move_manual_quantity/__init__.py b/stock_move_manual_quantity/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/stock_move_manual_quantity/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_move_manual_quantity/__manifest__.py b/stock_move_manual_quantity/__manifest__.py new file mode 100644 index 0000000..43bf599 --- /dev/null +++ b/stock_move_manual_quantity/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ # noqa: B018 + "name": "Stock Move Manual Quantity", + "version": "18.0.1.0.0", + "category": "Warehouse", + "summary": "A hand-typed quantity is final: never re-reserved automatically", + "license": "AGPL-3", + "author": "Odoo Community Association (OCA), Criptomart", + "maintainers": ["Criptomart"], + "website": "https://git.criptomart.net/criptomart/addons-cm", + "depends": ["stock"], + "data": [ + "views/stock_move_line_views.xml", + ], +} diff --git a/stock_move_manual_quantity/models/__init__.py b/stock_move_manual_quantity/models/__init__.py new file mode 100644 index 0000000..d7b52af --- /dev/null +++ b/stock_move_manual_quantity/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_move_line +from . import stock_picking diff --git a/stock_move_manual_quantity/models/stock_move.py b/stock_move_manual_quantity/models/stock_move.py new file mode 100644 index 0000000..9a82907 --- /dev/null +++ b/stock_move_manual_quantity/models/stock_move.py @@ -0,0 +1,60 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api +from odoo import models +from odoo.tools import float_compare +from odoo.tools import float_is_zero + + +class StockMove(models.Model): + _inherit = "stock.move" + + @api.onchange("quantity") + def _onchange_quantity_manual(self): + """Mark the move as picked when a user types its quantity. + + Covers the ``Operations`` tab of a transfer, where the quantity is + edited on the move and ``_set_quantity`` spreads it to the lines. + """ + for move in self: + if move.state not in ("done", "cancel"): + move.picked = True + + def write(self, vals): + res = super().write(vals) + if "quantity" in vals and vals.get("picked"): + self._align_demand_to_manual_quantity() + return res + + def _align_demand_to_manual_quantity(self): + """Lower the demand of a move to the quantity a user typed by hand. + + A move whose quantity is below its demand goes back to + ``partially_available`` (``_recompute_state``), and that is precisely + what makes the scheduler reserve the difference again. Aligning the + demand keeps the move ``assigned`` and drops the backorder question for + a quantity that is final: a weighed product is never completed later. + + The demand is only ever lowered. Above it the move is already + ``assigned``, so nothing has to be touched. + """ + for move in self: + if move.state in ("draft", "done", "cancel") or move.is_inventory: + continue + if move.move_dest_ids: + # Chained moves: lowering the demand here would leave the next + # step of the route asking for more than this one delivers. + continue + rounding = move.product_uom.rounding + if float_is_zero(move.quantity, precision_rounding=rounding): + # Nothing was picked: leave the demand alone so the transfer + # still offers its usual backorder choice. + continue + if ( + float_compare( + move.quantity, move.product_uom_qty, precision_rounding=rounding + ) + < 0 + ): + move.product_uom_qty = move.quantity diff --git a/stock_move_manual_quantity/models/stock_move_line.py b/stock_move_manual_quantity/models/stock_move_line.py new file mode 100644 index 0000000..b7c74d4 --- /dev/null +++ b/stock_move_manual_quantity/models/stock_move_line.py @@ -0,0 +1,50 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api +from odoo import models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + @api.onchange("quantity") + def _onchange_quantity_manual(self): + """A quantity typed by a user is the quantity physically handled. + + Onchanges only run from the user interface, never from the reservation + engine, so this marks the lines a person edited without freezing the + ones ``_action_assign`` reserves on its own. + """ + for line in self: + if line.state not in ("done", "cancel"): + line.picked = True + + def write(self, vals): + res = super().write(vals) + # `_onchange_quantity_manual` makes the interface send both fields in + # the same write, which is what tells a manual edit from a reservation. + if "quantity" in vals and vals.get("picked"): + self._freeze_manual_quantity() + return res + + def _freeze_manual_quantity(self, align_demand=True): + """Protect a hand-typed quantity from the reservation engine. + + ``_action_assign`` (run by the ``Procurement: run scheduler`` cron and + by ``_trigger_assign`` whenever another transfer of the same product is + validated) tops up every move that is not picked until it reaches its + demand, and ``_free_reservation`` steals quantities from move lines + that are not picked. Marking the whole move keeps both away from it. + """ + lines = self.filtered(lambda ml: ml.state not in ("done", "cancel")) + if not lines: + return + siblings = (lines.move_id.move_line_ids - lines).filtered( + lambda ml: not ml.picked and ml.state not in ("done", "cancel") + ) + if siblings: + # `_action_done` unlinks the non picked lines of a picked move. + siblings.picked = True + if align_demand: + lines.move_id._align_demand_to_manual_quantity() diff --git a/stock_move_manual_quantity/models/stock_picking.py b/stock_move_manual_quantity/models/stock_picking.py new file mode 100644 index 0000000..d0641e3 --- /dev/null +++ b/stock_move_manual_quantity/models/stock_picking.py @@ -0,0 +1,31 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_is_zero + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def _pre_action_done_hook(self): + """Validate every line holding a quantity, picked by hand or not. + + Core only auto-picks a transfer when *no* move is picked yet, so as + soon as an operator freezes one weighed line the untouched ones would + be pushed to a backorder -- or cancelled, if the user answers "no + backorder" -- even though their goods are in the basket. Picking + everything that carries a quantity keeps the behaviour operators know. + """ + for picking in self.filtered(lambda p: p.state not in ("done", "cancel")): + moves = picking.move_ids.filtered( + lambda move: not move.picked + and not move.scrapped + and move.state not in ("done", "cancel") + and not float_is_zero( + move.quantity, precision_rounding=move.product_uom.rounding + ) + ) + if moves: + moves.picked = True + return super()._pre_action_done_hook() diff --git a/stock_move_manual_quantity/readme/CONFIGURE.rst b/stock_move_manual_quantity/readme/CONFIGURE.rst new file mode 100644 index 0000000..6a053eb --- /dev/null +++ b/stock_move_manual_quantity/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +No configuration is required: the behaviour applies to every transfer as soon +as the module is installed. diff --git a/stock_move_manual_quantity/readme/CONTRIBUTORS.rst b/stock_move_manual_quantity/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..ecd0958 --- /dev/null +++ b/stock_move_manual_quantity/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Criptomart `_: + + * Analysis of the reservation engine and implementation diff --git a/stock_move_manual_quantity/readme/CREDITS.rst b/stock_move_manual_quantity/readme/CREDITS.rst new file mode 100644 index 0000000..d1ba675 --- /dev/null +++ b/stock_move_manual_quantity/readme/CREDITS.rst @@ -0,0 +1,3 @@ +**Authors:** + +* Criptomart diff --git a/stock_move_manual_quantity/readme/DESCRIPTION.rst b/stock_move_manual_quantity/readme/DESCRIPTION.rst new file mode 100644 index 0000000..db47fe0 --- /dev/null +++ b/stock_move_manual_quantity/readme/DESCRIPTION.rst @@ -0,0 +1,22 @@ +When an operator types a quantity on a transfer line, that quantity is the one +that was physically handled: a bag of apples ordered as 1 kg that weighs +0.87 kg on the scale. + +Odoo, however, treats a quantity below the demand as an incomplete +*reservation*. The move goes back to ``partially_available`` and +``_action_assign`` tops it up again to the demanded quantity, either from the +``Procurement: run scheduler`` cron or from ``_trigger_assign``, which runs +every time another transfer of the same product is validated. A few hours +later the weighed 0.87 kg is back to 1 kg. Raising the quantity is never +reverted, because the move stays ``assigned`` and no longer matches those +domains. + +This module makes a hand-typed quantity final: + +* Editing a quantity from the interface marks the move as **picked**, which is + what keeps ``_action_assign`` and ``_free_reservation`` away from it. +* When the typed quantity is below the demand, the demand is lowered to match, + so the move stays ``assigned`` and no backorder is asked for a quantity that + will never be completed. +* Transfers still validate every line that carries a quantity, picked by hand + or not, so freezing one line never pushes the untouched ones to a backorder. diff --git a/stock_move_manual_quantity/readme/INSTALL.rst b/stock_move_manual_quantity/readme/INSTALL.rst new file mode 100644 index 0000000..5e1e1b9 --- /dev/null +++ b/stock_move_manual_quantity/readme/INSTALL.rst @@ -0,0 +1,8 @@ +To install this module, you need to: + +#. Update the Apps list +#. Install *Stock Move Manual Quantity* + +:: + + docker-compose run --rm odoo odoo -d odoo --stop-after-init -i stock_move_manual_quantity diff --git a/stock_move_manual_quantity/readme/USAGE.rst b/stock_move_manual_quantity/readme/USAGE.rst new file mode 100644 index 0000000..eddf61f --- /dev/null +++ b/stock_move_manual_quantity/readme/USAGE.rst @@ -0,0 +1,23 @@ +Nothing to configure: the behaviour applies to every transfer as soon as the +module is installed. + +**Weighing a line** + +#. Open the transfer (or the batch operator view) and type the real quantity, + for example 0.87 instead of the demanded 1. +#. The move is marked as picked and, because 0.87 is below the demand, the + demand becomes 0.87 too. The move stays *Ready*. +#. Neither the scheduler nor the validation of another transfer of the same + product will change that quantity again. + +**Notes** + +* The demand is only ever lowered, never raised: above the demand the move is + already fully reserved and nothing needs to be adjusted. +* A line left at zero keeps the standard behaviour, so the transfer still + offers its usual backorder choice for what was not delivered. +* Chained moves (multi-step routes) are only frozen, their demand is left + untouched so the next step of the route stays consistent. +* Lowering the demand is logged in the transfer chatter, and it is what the + sale order uses to know how much is still to deliver while the transfer is + not validated. diff --git a/stock_move_manual_quantity/tests/__init__.py b/stock_move_manual_quantity/tests/__init__.py new file mode 100644 index 0000000..9a3ccd6 --- /dev/null +++ b/stock_move_manual_quantity/tests/__init__.py @@ -0,0 +1 @@ +from . import test_manual_quantity diff --git a/stock_move_manual_quantity/tests/test_manual_quantity.py b/stock_move_manual_quantity/tests/test_manual_quantity.py new file mode 100644 index 0000000..fe2d6a8 --- /dev/null +++ b/stock_move_manual_quantity/tests/test_manual_quantity.py @@ -0,0 +1,184 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests import Form +from odoo.tests import TransactionCase +from odoo.tests import tagged + + +@tagged("-at_install", "post_install") +class TestManualQuantity(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_kg = cls.env.ref("uom.product_uom_kgm") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + + cls.apples = cls.env["product.product"].create( + { + "name": "Apples", + "is_storable": True, + "uom_id": cls.uom_kg.id, + "uom_po_id": cls.uom_kg.id, + } + ) + cls.bread = cls.env["product.product"].create( + { + "name": "Bread", + "is_storable": True, + "uom_id": cls.uom_kg.id, + "uom_po_id": cls.uom_kg.id, + } + ) + for product in cls.apples | cls.bread: + cls.env["stock.quant"]._update_available_quantity( + product, cls.stock_location, 100.0 + ) + + # Helpers + + def _create_picking(self, products_qty): + """Create a confirmed and reserved delivery for ``{product: demand}``.""" + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_out.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "move_ids": [ + Command.create( + { + "name": product.name, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": qty, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + } + ) + for product, qty in products_qty.items() + ], + } + ) + picking.action_confirm() + picking.action_assign() + return picking + + def _type_quantity(self, line, quantity): + """Reproduce what the web client sends when a user types a quantity. + + The onchange marks the line as picked, so both fields travel together + in the same write. + """ + edited = line.new(origin=line) + edited.quantity = quantity + edited._onchange_quantity_manual() + line.write({"quantity": quantity, "picked": edited.picked}) + + # Tests + + def test_onchange_marks_the_line_as_picked(self): + picking = self._create_picking({self.apples: 1.0}) + line = picking.move_ids.move_line_ids + + edited = line.new(origin=line) + edited.quantity = 0.87 + edited._onchange_quantity_manual() + + self.assertTrue(edited.picked) + + def test_reservation_does_not_restore_a_lowered_quantity(self): + picking = self._create_picking({self.apples: 1.0}) + move = picking.move_ids + self.assertEqual(move.quantity, 1.0) + + self._type_quantity(move.move_line_ids, 0.87) + + self.assertTrue(move.picked) + self.assertEqual(move.product_uom_qty, 0.87) + self.assertEqual(move.state, "assigned") + + # The two entry points that used to top the move back up to its demand. + move._action_assign() + self.env["procurement.group"].run_scheduler() + + self.assertEqual(move.quantity, 0.87) + self.assertEqual(move.move_line_ids.quantity, 0.87) + + def test_a_raised_quantity_keeps_its_demand(self): + picking = self._create_picking({self.apples: 1.0}) + move = picking.move_ids + + self._type_quantity(move.move_line_ids, 1.2) + + self.assertEqual(move.product_uom_qty, 1.0) + self.assertEqual(move.state, "assigned") + + move._action_assign() + + self.assertEqual(move.quantity, 1.2) + + def test_an_empty_line_keeps_its_demand(self): + """A line left at zero still offers the standard backorder choice.""" + picking = self._create_picking({self.apples: 1.0}) + move = picking.move_ids + + self._type_quantity(move.move_line_ids, 0.0) + + self.assertEqual(move.product_uom_qty, 1.0) + + def test_reserving_does_not_mark_moves_as_picked(self): + """Only a person marks a move as picked, never the reservation.""" + picking = self._create_picking({self.apples: 1.0}) + + picking.move_ids._action_assign() + self.env["procurement.group"].run_scheduler() + + self.assertFalse(picking.move_ids.picked) + self.assertFalse(picking.move_ids.move_line_ids.picked) + + def test_editing_the_move_quantity_freezes_it_too(self): + """The Operations tab of a transfer edits the quantity on the move.""" + picking = self._create_picking({self.apples: 1.0}) + move = picking.move_ids + + with Form(picking) as picking_form: + with picking_form.move_ids_without_package.edit(0) as move_form: + move_form.quantity = 0.87 + + self.assertTrue(move.picked) + self.assertEqual(move.product_uom_qty, 0.87) + + move._action_assign() + + self.assertEqual(move.quantity, 0.87) + + def test_untouched_lines_are_still_validated(self): + """Freezing one line must not push the others to a backorder.""" + picking = self._create_picking({self.apples: 1.0, self.bread: 2.0}) + apples_move = picking.move_ids.filtered( + lambda move: move.product_id == self.apples + ) + bread_move = picking.move_ids - apples_move + + self._type_quantity(apples_move.move_line_ids, 0.87) + picking.button_validate() + + self.assertEqual(picking.state, "done") + self.assertEqual(apples_move.quantity, 0.87) + self.assertEqual(bread_move.state, "done") + self.assertEqual(bread_move.quantity, 2.0) + self.assertFalse(picking.backorder_ids) + + def test_operation_views_load_the_picked_field(self): + """The client only sends back the fields present in the arch.""" + move_line = self.env["stock.move.line"] + for xml_id in ( + "stock.view_stock_move_line_operation_tree", + "stock.view_stock_move_line_detailed_operation_tree", + ): + with self.subTest(view=xml_id): + view = move_line.get_view(self.env.ref(xml_id).id, "list") + self.assertIn('name="picked"', view["arch"]) diff --git a/stock_move_manual_quantity/views/stock_move_line_views.xml b/stock_move_manual_quantity/views/stock_move_line_views.xml new file mode 100644 index 0000000..91b50b9 --- /dev/null +++ b/stock_move_manual_quantity/views/stock_move_line_views.xml @@ -0,0 +1,29 @@ + + + + + stock.move.line.operations.manual.quantity + stock.move.line + + + + + + + + + + stock.move.line.detailed.operations.manual.quantity + stock.move.line + + + + + + + +