Skip to content

[16.0][ADD] stock_available_immediately_mrp: fix kit immediately_usable_qty#74

Open
alvaro-gmz wants to merge 1 commit into
OCA:16.0from
factorlibre:16.0-add-stock_available_immediately_mrp
Open

[16.0][ADD] stock_available_immediately_mrp: fix kit immediately_usable_qty#74
alvaro-gmz wants to merge 1 commit into
OCA:16.0from
factorlibre:16.0-add-stock_available_immediately_mrp

Conversation

@alvaro-gmz

Copy link
Copy Markdown

Summary

Adds a new glue module stock_available_immediately_mrp that fixes immediately_usable_qty on products with a phantom BoM (kits) when both stock_available_immediately and mrp are installed. The module auto-installs whenever both parents are present.

The bug

For products with a phantom BoM, stock_available_immediately applies the formula immediately_usable_qty = virtual_available - incoming_qty to the aggregated kit fields. Each aggregated kit field (virtual_available, incoming_qty, ...) is computed by Odoo MRP as an independent min over the components and may be limited by different components. Subtracting two such mins is not equivalent to qty_available - outgoing_qty for kits, even though that algebraic cancellation does hold for plain products.

Concrete example

Kit with two components, need=2 each:

Component qty in out virtual immediately
A 8 133 14 127 -6
B 4258 60 168 4150 4090
Forecasted (kit) = min(127/2, 4150/2) = 63    ← limited by A
Incoming   (kit) = min(133/2,   60/2) = 30    ← limited by B
immediately_usable_qty (current bug) = 63 - 30 = 33    ← inflated
immediately_usable_qty (expected)    = min(-6/2, 4090/2) = -3

The component-level cancellation (qty - out per component) is correct, but the kit-level subtraction of two independent mins is not.

The fix

Recompute immediately_usable_qty for products with a phantom BoM directly from the components and aggregate the result with min(component / qty_per_kit) * bom.product_qty, rounded DOWN to the kit UoM rounding (consistently with stock_available_mrp.potential_qty).

The other kit fields (qty_available, virtual_available, incoming_qty, outgoing_qty, free_qty) are intentionally left untouched and keep the standard Odoo MRP aggregation as min(component / need) per field.

Why a glue module

The bug only manifests at the intersection of stock_available_immediately and mrp. Putting the fix in either parent would either bundle unrelated semantics or rely on a soft dependency that prevents using the bom_ids.* paths in @api.depends. A glue with auto_install=True expresses the contract honestly: it appears only when both parents are present and stays out of the way otherwise.

Interaction with stock_available_mrp

The module is independent of stock_available_mrp and does not depend on it. They compose cleanly when both are installed:

  • This module fixes the immediately_usable_qty base value for kits.
  • stock_available_mrp then increments it with potential_qty (the quantity that can be manufactured from currently-available components).

The end result for kits with both modules installed is therefore correct_kit_immediately + potential_qty instead of the previous buggy_kit_immediately + potential_qty.

Implementation notes

  • The component value is read from the res dict already produced by super() in the same call, instead of recursing through the attribute. This keeps the kit and its components consistent under contexts that other modules may install during _compute_available_quantities_dict (e.g. location/batch context wrappers), where a recursive attribute access could resolve under a different context than the batch.
  • When the BoM line UoM and the component UoM live in different UoM categories, _compute_quantity(raise_if_failure=False) returns the original value unchanged, which would produce an inconsistent ratio. An explicit category check skips the component with a warning. A separate guard skips components whose BoM line declares zero quantity.
  • The @api.depends of the compute tracks bom_ids.bom_line_ids, bom_ids.bom_line_ids.product_id and bom_ids.bom_line_ids.product_qty (plus the variant-specific paths) so the kit cache is invalidated when a component is replaced or its quantity changes.

Tests

12 tests covering:

  • Unbalanced components reproducing the bug (negative result).
  • Balanced components — no change vs. previous behaviour.
  • All-positive components — standard healthy case.
  • Oversold components — propagation to the kit value.
  • Plain products without BoM — not affected (regression).
  • Manufacturing BoM (non-phantom) — not affected (regression).
  • Consumable components — skipped in the ratio.
  • Batch read of kit and components together — value consistent with super() output, regardless of context overlays.
  • BoM line with zero quantity — component skipped with warning.
  • BoM line with UoM in a different category than the component — skipped.
  • bom.product_qty != 1 — scaling factor correctly applied.
  • UoM rounding contract — DOWN to kit UoM rounding.

Related

This bug surfaced in production when exporting kit stock to a marketplace aggregator: kits with components in different states reported phantom positive availability instead of the real (often negative) figure, leading to oversells.

@OCA-git-bot OCA-git-bot added series:16.0 mod:stock_available_immediately_mrp Module stock_available_immediately_mrp labels Apr 28, 2026
@alvaro-gmz alvaro-gmz force-pushed the 16.0-add-stock_available_immediately_mrp branch 3 times, most recently from bd4fd48 to 77bf2ca Compare April 28, 2026 17:50

@AdrianaSaiz AdrianaSaiz left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM good job @alvaro-gmz

…able_qty

When ``stock_available_immediately`` and ``mrp`` are both installed, the
formula ``immediately_usable_qty = virtual_available - incoming_qty``
applied to the aggregated kit fields produces inconsistent results, because
each aggregated kit field (``virtual_available``, ``incoming_qty``, ...) is
computed by Odoo MRP as an independent ``min`` over the components and may
be limited by different components. Subtracting two such mins is not
equivalent to ``qty_available - outgoing_qty`` for kits, even though that
algebraic cancellation does hold for plain products.

Example
-------

Kit with two components, ``need=2`` each:

  Component A: qty=8,    in=133, out=14   →  virtual=127,  immediately=-6
  Component B: qty=4258, in=60,  out=168  →  virtual=4150, immediately=4090

  Forecasted (kit) = min(127/2, 4150/2)   = 63    ← limited by A
  Incoming   (kit) = min(133/2, 60/2)     = 30    ← limited by B
  immediately_usable_qty (current bug) = 63 - 30  = 33    ← inflated
  immediately_usable_qty (expected)    = min(-6/2, 4090/2) = -3

Fix
---

Recompute ``immediately_usable_qty`` for products with a phantom BoM
directly from the components and aggregate the result with
``min(component / qty_per_kit) * bom.product_qty``, rounded DOWN to the
kit UoM rounding (consistently with ``stock_available_mrp.potential_qty``).

The other kit fields (``qty_available``, ``virtual_available``,
``incoming_qty``, ``outgoing_qty``, ``free_qty``) are intentionally left
untouched and keep the standard Odoo MRP aggregation as
``min(component / need)`` per field.

Why a glue module
-----------------

The bug only manifests at the intersection of ``stock_available_immediately``
and ``mrp``: without ``stock_available_immediately`` the broken subtraction
is not applied at all, and without ``mrp`` there are no phantom BoMs to
aggregate. Putting the fix in either parent module would either bundle
unrelated semantics or rely on a soft dependency that prevents using the
``bom_ids.*`` paths in ``@api.depends``. A glue with ``auto_install=True``
expresses the contract honestly: it appears only when both parents are
present and stays out of the way otherwise.

Interaction with stock_available_mrp
------------------------------------

This module is independent of ``stock_available_mrp`` and does not depend
on it. They compose cleanly when both are installed:

* This module fixes the ``immediately_usable_qty`` base value for kits.
* ``stock_available_mrp`` then increments it with ``potential_qty`` (the
  quantity that can be manufactured from currently-available components).

The end result for kits with both modules installed is therefore
``correct_kit_immediately + potential_qty`` instead of the previous
``buggy_kit_immediately + potential_qty``.

Implementation notes
--------------------

The component value is read from the ``res`` dict already produced by
``super()`` in the same call, instead of recursing through the attribute.
This keeps the kit and its components consistent under contexts that other
modules may install during ``_compute_available_quantities_dict`` (e.g.
location/batch context wrappers), where a recursive attribute access could
resolve under a different context than the batch.

When the BoM line UoM and the component UoM live in different UoM
categories, ``_compute_quantity(raise_if_failure=False)`` returns the
original value unchanged, which would produce an inconsistent ratio. An
explicit category check skips the component with a warning. A separate
guard skips components whose BoM line declares zero quantity.

The ``@api.depends`` of the compute tracks ``bom_ids.bom_line_ids``,
``bom_ids.bom_line_ids.product_id`` and ``bom_ids.bom_line_ids.product_qty``
(plus the variant-specific paths) so the kit cache is invalidated when a
component is replaced or its quantity changes.

Tests
-----

12 tests covering:

- Unbalanced components reproducing the bug (negative result).
- Balanced components — no change vs. previous behaviour.
- All-positive components — standard healthy case.
- Oversold components — propagation to the kit value.
- Plain products without BoM — not affected (regression).
- Manufacturing BoM (non-phantom) — not affected (regression).
- Consumable components — skipped in the ratio.
- Batch read of kit and components together — value consistent with
  ``super()`` output, regardless of context overlays.
- BoM line with zero quantity — component skipped with warning.
- BoM line with UoM in a different category than the component — skipped.
- ``bom.product_qty != 1`` — scaling factor correctly applied.
- UoM rounding contract — DOWN to kit UoM rounding.
@alvaro-gmz alvaro-gmz force-pushed the 16.0-add-stock_available_immediately_mrp branch from 77bf2ca to 5c0e2f3 Compare April 29, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mod:stock_available_immediately_mrp Module stock_available_immediately_mrp series:16.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants