[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>
This commit is contained in:
GitHub Copilot 2026-08-27 13:26:44 +02:00
parent cb32fb6c0d
commit d433e50f2f
15 changed files with 436 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from . import stock_move
from . import stock_move_line
from . import stock_picking

View file

@ -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

View file

@ -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()

View file

@ -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()