Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 152 additions & 14 deletions stock_secondary_unit/README.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

====================
Stock Secondary Unit
====================
Expand All @@ -17,7 +13,7 @@ Stock Secondary Unit
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png
:target: https://odoo-community.org/page/development-status
:alt: Production/Stable
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github
Expand All @@ -32,8 +28,85 @@ Stock Secondary Unit

|badge1| |badge2| |badge3| |badge4| |badge5|

This module extends the functionality of stock module to allow define
other units with their conversion factor.
This module lets a product be tracked, throughout the whole stock flow,
in a **secondary unit of measure** alongside its normal one - typically
a count (pieces, boxes) next to a product that is actually measured by
weight or volume. A classic case: fish sold by weight, but an order is
placed for "40 pieces" because each of a fixed number of guests gets
exactly one - the real weight per piece varies, but the piece count must
travel through the whole warehouse route exactly as ordered.

It builds on ``product_secondary_unit``'s per-product configuration (the
conversion factor, and the ``dependency_type`` that decides how the two
quantities relate to each other) and extends it to ``stock.move`` and
``stock.move.line``, so a secondary quantity can be demanded on a
transfer, counted on its operations, and shown on delivery documents -
not just converted once on the product form.

**Two independent secondary-unit settings live on the product:**

- ``secondary_uom_ids`` / the sale-facing secondary unit (from
``product_secondary_unit``) - the one that ends up on ``stock.move`` /
``stock.move.line`` records for an actual transfer.
- ``stock_secondary_uom_id`` ("Second unit for inventory", added by this
module) - purely for the **on-hand quantity display**: pick one of the
product's secondary units and a "Secondary Unit" smart button appears
on the product form next to "On Hand", showing the current stock
quantity converted into it. This is read-only, informational, and
independent of whatever secondary unit an individual move ends up
using.

**Why ``dependency_type`` matters here in particular:** a stock move's
``product_uom_qty`` (demand) and a move line's ``quantity`` (what was
actually done) are two different fields with different needs:

- ``dependent``: the secondary quantity is always a factor conversion of
the primary one, in both directions - fine for informational units
where nothing needs to survive an inexact conversion.
- ``independent``: the secondary quantity is entered on its own and
never recomputed from the primary one, and vice versa - used when the
two units don't relate to each other at all.
- ``secondary_priority``: the primary quantity is *estimated* from the
secondary one (like ``dependent``), but the secondary one is *never*
recomputed back from the primary (like ``independent``) - this is the
one built for the fish-by-weight/pieces-by-count case: pieces are the
real order, weight is only ever a convenience estimate derived from
them, and must never silently drift because of how much something
happened to weigh.

For ``independent`` and ``secondary_priority`` (together, the
"count-preserving" types), the module makes sure the secondary quantity
survives every stock operation exactly, instead of being silently
re-derived from the primary quantity and losing precision or meaning:

- **Splitting a move for a backorder** carries over exactly what's left
of the secondary demand (e.g. 40 pieces demanded, 30 actually picked
-> the backorder is for exactly 10 pieces, never a weight-based
guess), and keeps the original move's own secondary quantity in sync
with what was actually processed.
- **Merging moves back together** (e.g. two operations for the same
product ending up as one line, or a backorder's pushed leg merging
back into an already-existing move) sums the secondary quantities the
same way core sums the primary one - core has no notion of this field
at all, so without this the count would silently be lost on every
merge.
- **A returned (negative) move absorbed into - or absorbing - an
existing one** rebalances the secondary quantity the same way core
rebalances the primary one for that specific code path, which is
separate from the ordinary merge above.
- **A pull-chain procurement** (e.g. a 2-step reception, or an internal
make-to-order replenishment) carries the secondary unit forward into
the next move it creates, the same way the primary quantity already
does.
- **``secondary_uom_qty_done``**, a new aggregate field on
``stock.move``, mirrors what core's own ``quantity`` field does for
the primary unit: it sums what was actually counted across the move's
lines, so other code needing "how many pieces were actually done"
doesn't have to re-derive that sum ad hoc every time.
- The **delivery slip report** (and any other place aggregating several
move lines under one row, e.g. different lots of the same product)
accumulates the secondary quantity across them instead of only showing
the last line processed.

**Table of contents**

Expand All @@ -43,14 +116,79 @@ other units with their conversion factor.
Usage
=====

To use this module you need to:
Displaying stock on hand in a secondary unit
--------------------------------------------

1. On a product, go to *Inventory tab > Secondary unit* and choose which
of the product's configured secondary units should be used for the
on-hand display ("Second unit for inventory").
2. A "Secondary Unit" smart button appears on the product form (next to
"On Hand"), showing the current stock quantity converted into that
unit. This is purely informational: it does not affect any transfer.

Demanding and counting a secondary quantity on a transfer
---------------------------------------------------------

1. On a transfer's operations (the move lines list, or a move's own
detail form when *Detailed Operations* is enabled), the *Secondary
Qty* and its unit are editable columns/fields (needs the "Units of
Measure" feature enabled, ``uom.group_uom``).
2. What happens when the secondary quantity is entered depends on the
product's secondary unit ``dependency_type``:

- ``dependent``: entering either quantity recomputes the other
through the conversion factor.
- ``independent`` / ``secondary_priority``: the secondary quantity is
entered on its own; for ``secondary_priority`` the primary quantity
is estimated from it, but never the other way round once a
secondary quantity has actually been set.

Validating a transfer that doesn't match the secondary demand exactly
---------------------------------------------------------------------

For a count-preserving secondary unit (``independent`` or
``secondary_priority``), if what was actually counted is less than
demanded and a backorder gets created:

- The backorder is created for **exactly** what's left of the secondary
demand (e.g. 40 pieces demanded, 30 counted -> the backorder demands
exactly 10 pieces) - never a proportional estimate derived from
whatever the primary quantity (e.g. weight) happened to be.
- The original, now-done transfer keeps its secondary quantity in sync
with what was actually counted, not the original demand.

If instead more was counted than demanded, the move's own demand is left
untouched by this module alone - see the receiving module/process (e.g.
a reception-discrepancy or over-processing reconciliation feature) for
how a peixospalamos-style deployment handles that case; it is out of
scope for this module by itself.

Returns
-------

1. Go to a *Product > General Information tab*.
2. Create any record in "Secondary unit of measure".
3. Set the conversion factor.
4. Go to *Inventory tab* and set a second unit of measure.
5. Push button 'On hand' and set quantities in stock for this product.
6. Go to product list and you can see the secondary unit value.
When a return (a move with a negative quantity) gets merged back into an
existing move - or absorbs one, if the return is larger - the secondary
quantity is rebalanced the same way the primary one is: the surviving
move's secondary quantity reflects the net effect of the return, not
just whatever it already had before the return was processed.

Chained transfers (multi-step routes, make-to-order replenishment)
------------------------------------------------------------------

When a transfer is part of a pull chain (e.g. the "Input -> Stock" leg
of a 2-step reception, or an internal make-to-order move), whatever
secondary unit and quantity is set on it is carried forward into the
next transfer created for that chain - the destination move doesn't
start without one.

Delivery documents
------------------

The delivery slip report shows the secondary quantity and unit next to
the primary one for every line, both for a picking still being processed
and once it is done. When several move lines end up aggregated onto a
single report row (e.g. different lots of the same product), their
secondary quantities are summed, not just the last one read.

Bug Tracker
===========
Expand Down
2 changes: 1 addition & 1 deletion stock_secondary_unit/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"name": "Stock Secondary Unit",
"summary": "Get product quantities in a secondary unit",
"version": "18.0.1.1.0",
"version": "18.0.1.2.0",
"development_status": "Production/Stable",
"category": "stock",
"website": "https://github.com/OCA/stock-logistics-warehouse",
Expand Down
31 changes: 31 additions & 0 deletions stock_secondary_unit/migrations/18.0.1.2.0/pre-migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 Tecnativa - Carlos Dauden
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from openupgradelib import openupgrade


@openupgrade.migrate()
def migrate(env, version):
"""Create and backfill secondary_uom_qty_done via SQL.

Pre-migration, not post: creating the column here means Odoo's own
schema sync finds it already in place and skips the automatic
per-record Python recompute it would otherwise trigger for every
existing stock.move.
"""
openupgrade.add_columns(
env,
[("stock.move", "secondary_uom_qty_done", "float")],
)
openupgrade.logged_query(
env.cr,
"""
UPDATE stock_move sm
SET secondary_uom_qty_done = sub.total
FROM (
SELECT move_id, SUM(secondary_uom_qty) AS total
FROM stock_move_line
GROUP BY move_id
) sub
WHERE sub.move_id = sm.id
""",
)
1 change: 1 addition & 0 deletions stock_secondary_unit/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
from . import product_product
from . import product_template
from . import stock_move
from . import stock_rule
Loading
Loading