addons-cm/stock_move_manual_quantity/tests/test_manual_quantity.py
GitHub Copilot d433e50f2f [ADD] stock_move_manual_quantity: a hand-typed quantity is the picked one
An operator weighing a basket types 0.87 kg where the member ordered 1 kg,
and a few hours later the line is back at 1 kg. Raising the quantity never
reverts. The asymmetry is the reservation engine: below the demand the move
goes back to `partially_available` (`_recompute_state`), which is exactly
the state `_action_assign` looks for, while above it stays `assigned` and
matches no domain.

Two things call `_action_assign` on their own: the `Procurement: run
scheduler` cron, and `_trigger_assign`, which runs every time another
transfer of the same product is validated -- the second explains the "a few
hours" better than a daily cron. Both respect one flag only, `picked`, and
nobody was setting it: the checkbox is `optional="hide"` on the transfer
form and absent from the detailed operation lists.

So an onchange on `quantity` marks the record as picked. Onchanges only run
from the interface, never from the reservation engine, so a manual edit is
told apart from a reservation without guessing at contexts, and if the
onchange ever stops firing the behaviour degrades to today's instead of
freezing reservations across the system. `picked` also keeps
`_free_reservation` from stealing the quantity when stock runs short
elsewhere. The three lists where a quantity can be typed now load the field,
since the client only sends back what the arch declares.

When the typed quantity is below the demand, the demand follows it down. The
move stays `assigned` and no backorder is asked for a weight that will never
be completed. Only downwards: above the demand nothing needs adjusting. A
line left at zero keeps its demand, so a transfer still offers its usual
backorder choice for what was not delivered, and chained moves are only
frozen -- lowering their demand would leave the next step of the route
asking for more than this one delivers.

`_pre_action_done_hook` needs the counterpart. Core auto-picks a transfer
only when *no* move is picked yet, so freezing one weighed line would push
the untouched ones to a backorder, or cancel them if the user answers "no
backorder", with the goods already in the basket. Picking everything that
carries a quantity keeps validation exactly as operators know it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:26:44 +02:00

184 lines
6.6 KiB
Python

# 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"])