[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:
parent
cb32fb6c0d
commit
d433e50f2f
15 changed files with 436 additions and 0 deletions
1
stock_move_manual_quantity/__init__.py
Normal file
1
stock_move_manual_quantity/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
16
stock_move_manual_quantity/__manifest__.py
Normal file
16
stock_move_manual_quantity/__manifest__.py
Normal file
|
|
@ -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",
|
||||||
|
],
|
||||||
|
}
|
||||||
3
stock_move_manual_quantity/models/__init__.py
Normal file
3
stock_move_manual_quantity/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import stock_move
|
||||||
|
from . import stock_move_line
|
||||||
|
from . import stock_picking
|
||||||
60
stock_move_manual_quantity/models/stock_move.py
Normal file
60
stock_move_manual_quantity/models/stock_move.py
Normal 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
|
||||||
50
stock_move_manual_quantity/models/stock_move_line.py
Normal file
50
stock_move_manual_quantity/models/stock_move_line.py
Normal 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()
|
||||||
31
stock_move_manual_quantity/models/stock_picking.py
Normal file
31
stock_move_manual_quantity/models/stock_picking.py
Normal 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()
|
||||||
2
stock_move_manual_quantity/readme/CONFIGURE.rst
Normal file
2
stock_move_manual_quantity/readme/CONFIGURE.rst
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
No configuration is required: the behaviour applies to every transfer as soon
|
||||||
|
as the module is installed.
|
||||||
3
stock_move_manual_quantity/readme/CONTRIBUTORS.rst
Normal file
3
stock_move_manual_quantity/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
* `Criptomart <https://criptomart.net>`_:
|
||||||
|
|
||||||
|
* Analysis of the reservation engine and implementation
|
||||||
3
stock_move_manual_quantity/readme/CREDITS.rst
Normal file
3
stock_move_manual_quantity/readme/CREDITS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
**Authors:**
|
||||||
|
|
||||||
|
* Criptomart
|
||||||
22
stock_move_manual_quantity/readme/DESCRIPTION.rst
Normal file
22
stock_move_manual_quantity/readme/DESCRIPTION.rst
Normal file
|
|
@ -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.
|
||||||
8
stock_move_manual_quantity/readme/INSTALL.rst
Normal file
8
stock_move_manual_quantity/readme/INSTALL.rst
Normal file
|
|
@ -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
|
||||||
23
stock_move_manual_quantity/readme/USAGE.rst
Normal file
23
stock_move_manual_quantity/readme/USAGE.rst
Normal file
|
|
@ -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.
|
||||||
1
stock_move_manual_quantity/tests/__init__.py
Normal file
1
stock_move_manual_quantity/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_manual_quantity
|
||||||
184
stock_move_manual_quantity/tests/test_manual_quantity.py
Normal file
184
stock_move_manual_quantity/tests/test_manual_quantity.py
Normal file
|
|
@ -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"])
|
||||||
29
stock_move_manual_quantity/views/stock_move_line_views.xml
Normal file
29
stock_move_manual_quantity/views/stock_move_line_views.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!--
|
||||||
|
The web client only saves the fields present in the arch, so `picked`
|
||||||
|
has to be loaded wherever a quantity can be typed for
|
||||||
|
`_onchange_quantity_manual` to reach the server.
|
||||||
|
-->
|
||||||
|
<record id="view_stock_move_line_operation_tree_manual_quantity" model="ir.ui.view">
|
||||||
|
<field name="name">stock.move.line.operations.manual.quantity</field>
|
||||||
|
<field name="model">stock.move.line</field>
|
||||||
|
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='quantity']" position="after">
|
||||||
|
<field name="picked" column_invisible="True"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_stock_move_line_detailed_operation_tree_manual_quantity" model="ir.ui.view">
|
||||||
|
<field name="name">stock.move.line.detailed.operations.manual.quantity</field>
|
||||||
|
<field name="model">stock.move.line</field>
|
||||||
|
<field name="inherit_id" ref="stock.view_stock_move_line_detailed_operation_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='quantity']" position="after">
|
||||||
|
<field name="picked" column_invisible="True"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue