Skip to content
Merged
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
51 changes: 51 additions & 0 deletions stock_lot_serial_no_default/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
===========================
Stock Lot/Serial No Default
===========================

This module modifies Odoo's default behavior for products tracked by serial numbers.

Problem
=======

In standard Odoo, when products are tracked by serial numbers, the system automatically:

* Picks an available serial number when reserving stock (clicking "Check Availability")
* Pre-fills this serial number in delivery orders, stock transfers, and manufacturing orders
* Proposes the serial number to staff without requiring conscious selection

This can lead to situations where staff processes transfers without verifying they have the correct physical item.

Solution
========

This module prevents automatic serial number selection while maintaining quantity reservation:

* **Quantities are still reserved** - inventory levels are correctly tracked
* **Serial numbers are NOT pre-filled** - fields remain empty after reservation
* **Staff must manually enter serial numbers** - ensures conscious selection of physical items
* **No validation added** - staff can still complete operations (following your workflow requirements)

Technical Details
=================

The module overrides the ``_update_reserved_quantity_vals`` method in ``stock.move`` model:

* For products with ``tracking='serial'``, the ``lot_id`` parameter is cleared
* Move lines are created without pre-assigned serial numbers
* Works for all stock operations: deliveries, transfers, and manufacturing orders

Configuration
=============

No configuration needed. The module works automatically for all products tracked by serial number.

Usage
=====

1. You may need to enable Settings -> inventory -> Lots & Serial Numbers
2. Create a product and set Track Inventory By Unique Serial Number
3. Create a delivery order / stock transfer / manufacturing order for serial-tracked products
4. Click "Check Availability" to reserve stock
5. The quantity will be reserved but serial number fields will be empty. In delivery orders you might need to enable the column "Serial Number" if it's not shown by default.
6. Staff must manually scan or enter the serial number for each item
7. Validate the operation as usual
1 change: 1 addition & 0 deletions stock_lot_serial_no_default/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
16 changes: 16 additions & 0 deletions stock_lot_serial_no_default/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "Stock Lot/Serial No Default",
"version": "18.0.1.0.0",
"category": "Inventory/Inventory",
"summary": "Prevent automatic serial number selection in stock operations",
"author": "Nitrokey GmbH",
"website": "https://www.github.com/nitrokey/odoo-modules/",
"license": "AGPL-3",
"depends": [
"stock",
],
"data": [],
"installable": True,
"application": False,
"auto_install": False,
}
1 change: 1 addition & 0 deletions stock_lot_serial_no_default/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import stock_move
38 changes: 38 additions & 0 deletions stock_lot_serial_no_default/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from odoo import models


class StockMove(models.Model):
_inherit = "stock.move"

def _update_reserved_quantity_vals(
self,
need,
location_id,
lot_id=None,
package_id=None,
owner_id=None,
strict=True,
):
"""Override to prevent automatic serial number assignment.

For products tracked by serial number, we still reserve the quantity
but do NOT assign a specific lot_id. This forces staff to manually
enter the serial number when processing the operation.
"""
# Get the move line vals from parent
move_line_vals, taken_quantity = super()._update_reserved_quantity_vals(
need,
location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)

# For serial-tracked products, clear the lot_id from the move line vals
if self.product_id.tracking == "serial":
for vals in move_line_vals:
vals["lot_id"] = False
vals["lot_name"] = False

return move_line_vals, taken_quantity
1 change: 1 addition & 0 deletions stock_lot_serial_no_default/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_serial_control
280 changes: 280 additions & 0 deletions stock_lot_serial_no_default/tests/test_serial_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestStockLotSerialNoDefault(TransactionCase):
"""Test that serial numbers are not automatically assigned during reservation."""

@classmethod
def setUpClass(cls):
super().setUpClass()

# Create locations
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.customer_location = cls.env.ref("stock.stock_location_customers")

# Create a product tracked by serial number
cls.product_serial = cls.env["product.product"].create(
{
"name": "Test Product Serial",
"type": "consu",
"is_storable": True,
"tracking": "serial",
"categ_id": cls.env.ref("product.product_category_all").id,
}
)

# Create a product tracked by lot
cls.product_lot = cls.env["product.product"].create(
{
"name": "Test Product Lot",
"type": "consu",
"is_storable": True,
"tracking": "lot",
"categ_id": cls.env.ref("product.product_category_all").id,
}
)

# Create a product without tracking
cls.product_none = cls.env["product.product"].create(
{
"name": "Test Product No Tracking",
"type": "consu",
"is_storable": True,
"tracking": "none",
"categ_id": cls.env.ref("product.product_category_all").id,
}
)

# Create serial numbers for the serial-tracked product
cls.serial_1 = cls.env["stock.lot"].create(
{
"name": "SERIAL-001",
"product_id": cls.product_serial.id,
"company_id": cls.env.company.id,
}
)
cls.serial_2 = cls.env["stock.lot"].create(
{
"name": "SERIAL-002",
"product_id": cls.product_serial.id,
"company_id": cls.env.company.id,
}
)

# Create lot for the lot-tracked product
cls.lot_1 = cls.env["stock.lot"].create(
{
"name": "LOT-001",
"product_id": cls.product_lot.id,
"company_id": cls.env.company.id,
}
)

# Add stock for all products
cls.env["stock.quant"]._update_available_quantity(
cls.product_serial,
cls.stock_location,
1.0,
lot_id=cls.serial_1,
)
cls.env["stock.quant"]._update_available_quantity(
cls.product_serial,
cls.stock_location,
1.0,
lot_id=cls.serial_2,
)
cls.env["stock.quant"]._update_available_quantity(
cls.product_lot,
cls.stock_location,
10.0,
lot_id=cls.lot_1,
)
cls.env["stock.quant"]._update_available_quantity(
cls.product_none,
cls.stock_location,
100.0,
)

def test_serial_no_automatic_assignment(self):
"""Test that serial numbers are NOT automatically assigned."""
# Create a picking for serial-tracked product
picking = self.env["stock.picking"].create(
{
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"picking_type_id": self.env.ref("stock.picking_type_out").id,
}
)

# Create a move for 1 unit of serial-tracked product
move = self.env["stock.move"].create(
{
"name": "Test Move Serial",
"product_id": self.product_serial.id,
"product_uom_qty": 1.0,
"product_uom": self.product_serial.uom_id.id,
"picking_id": picking.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)

# Confirm the picking
picking.action_confirm()

# Reserve stock (this is where automatic assignment would happen)
picking.action_assign()

# Verify the move is assigned (reserved)
self.assertEqual(
move.state,
"assigned",
"Move should be in assigned state after reservation",
)

# CRITICAL TEST: Verify that move lines exist but have NO lot_id
self.assertTrue(
move.move_line_ids,
"Move lines should be created after reservation",
)
for move_line in move.move_line_ids:
self.assertFalse(
move_line.lot_id,
"Serial number should NOT be automatically assigned to move line",
)
self.assertEqual(
move_line.quantity,
1.0,
"Reserved quantity should be 1.0 even without lot_id assignment",
)

def test_lot_tracking_still_works(self):
"""Test that lot-tracked products still get automatic assignment."""
# Create a picking for lot-tracked product
picking = self.env["stock.picking"].create(
{
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"picking_type_id": self.env.ref("stock.picking_type_out").id,
}
)

# Create a move for lot-tracked product
move = self.env["stock.move"].create(
{
"name": "Test Move Lot",
"product_id": self.product_lot.id,
"product_uom_qty": 5.0,
"product_uom": self.product_lot.uom_id.id,
"picking_id": picking.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)

# Confirm and reserve
picking.action_confirm()
picking.action_assign()

# Verify lot-tracked products still get automatic assignment
self.assertEqual(move.state, "assigned")
self.assertTrue(move.move_line_ids)
# For lot tracking, the lot_id should still be assigned
lot_assigned = any(ml.lot_id for ml in move.move_line_ids)
self.assertTrue(
lot_assigned,
"Lot-tracked products should still get automatic lot assignment",
)

def test_no_tracking_still_works(self):
"""Test that products without tracking still work normally."""
# Create a picking for non-tracked product
picking = self.env["stock.picking"].create(
{
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"picking_type_id": self.env.ref("stock.picking_type_out").id,
}
)

# Create a move
move = self.env["stock.move"].create(
{
"name": "Test Move No Tracking",
"product_id": self.product_none.id,
"product_uom_qty": 10.0,
"product_uom": self.product_none.uom_id.id,
"picking_id": picking.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)

# Confirm and reserve
picking.action_confirm()
picking.action_assign()

# Verify normal reservation works
self.assertEqual(move.state, "assigned")
self.assertTrue(move.move_line_ids)
self.assertEqual(
sum(move.move_line_ids.mapped("quantity")),
10.0,
"Full quantity should be reserved for non-tracked products",
)

def test_manual_serial_entry_still_possible(self):
"""Test that staff can manually enter serial numbers after reservation."""
# Create and reserve a picking
picking = self.env["stock.picking"].create(
{
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"picking_type_id": self.env.ref("stock.picking_type_out").id,
}
)

move = self.env["stock.move"].create(
{
"name": "Test Move Serial Manual",
"product_id": self.product_serial.id,
"product_uom_qty": 1.0,
"product_uom": self.product_serial.uom_id.id,
"picking_id": picking.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)

picking.action_confirm()
picking.action_assign()

# Manually assign a serial number to a move line
move_line = move.move_line_ids[0]
move_line.write(
{
"lot_id": self.serial_1.id,
"quantity": 1.0,
}
)

# Verify manual assignment works
self.assertEqual(
move_line.lot_id.id,
self.serial_1.id,
"Manual serial number assignment should work",
)
self.assertEqual(
move_line.quantity,
1.0,
"Manual quantity assignment should work",
)

# Verify picking can be validated with manual serial entry
picking.button_validate()
self.assertEqual(
picking.state,
"done",
"Picking should be completed after manual serial entry",
)