Skip to content

Commit 423d327

Browse files
authored
add stock_lot_serial_no_default (#231)
* add stock_lot_serial_no_default * Update README with serial number column instructions Added note about enabling 'Serial Number' column in delivery orders. * Update website URL in manifest file
1 parent 85f2fd7 commit 423d327

File tree

7 files changed

+388
-0
lines changed

7 files changed

+388
-0
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
===========================
2+
Stock Lot/Serial No Default
3+
===========================
4+
5+
This module modifies Odoo's default behavior for products tracked by serial numbers.
6+
7+
Problem
8+
=======
9+
10+
In standard Odoo, when products are tracked by serial numbers, the system automatically:
11+
12+
* Picks an available serial number when reserving stock (clicking "Check Availability")
13+
* Pre-fills this serial number in delivery orders, stock transfers, and manufacturing orders
14+
* Proposes the serial number to staff without requiring conscious selection
15+
16+
This can lead to situations where staff processes transfers without verifying they have the correct physical item.
17+
18+
Solution
19+
========
20+
21+
This module prevents automatic serial number selection while maintaining quantity reservation:
22+
23+
* **Quantities are still reserved** - inventory levels are correctly tracked
24+
* **Serial numbers are NOT pre-filled** - fields remain empty after reservation
25+
* **Staff must manually enter serial numbers** - ensures conscious selection of physical items
26+
* **No validation added** - staff can still complete operations (following your workflow requirements)
27+
28+
Technical Details
29+
=================
30+
31+
The module overrides the ``_update_reserved_quantity_vals`` method in ``stock.move`` model:
32+
33+
* For products with ``tracking='serial'``, the ``lot_id`` parameter is cleared
34+
* Move lines are created without pre-assigned serial numbers
35+
* Works for all stock operations: deliveries, transfers, and manufacturing orders
36+
37+
Configuration
38+
=============
39+
40+
No configuration needed. The module works automatically for all products tracked by serial number.
41+
42+
Usage
43+
=====
44+
45+
1. You may need to enable Settings -> inventory -> Lots & Serial Numbers
46+
2. Create a product and set Track Inventory By Unique Serial Number
47+
3. Create a delivery order / stock transfer / manufacturing order for serial-tracked products
48+
4. Click "Check Availability" to reserve stock
49+
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.
50+
6. Staff must manually scan or enter the serial number for each item
51+
7. Validate the operation as usual
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "Stock Lot/Serial No Default",
3+
"version": "18.0.1.0.0",
4+
"category": "Inventory/Inventory",
5+
"summary": "Prevent automatic serial number selection in stock operations",
6+
"author": "Nitrokey GmbH",
7+
"website": "https://www.github.com/nitrokey/odoo-modules/",
8+
"license": "AGPL-3",
9+
"depends": [
10+
"stock",
11+
],
12+
"data": [],
13+
"installable": True,
14+
"application": False,
15+
"auto_install": False,
16+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import stock_move
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from odoo import models
2+
3+
4+
class StockMove(models.Model):
5+
_inherit = "stock.move"
6+
7+
def _update_reserved_quantity_vals(
8+
self,
9+
need,
10+
location_id,
11+
lot_id=None,
12+
package_id=None,
13+
owner_id=None,
14+
strict=True,
15+
):
16+
"""Override to prevent automatic serial number assignment.
17+
18+
For products tracked by serial number, we still reserve the quantity
19+
but do NOT assign a specific lot_id. This forces staff to manually
20+
enter the serial number when processing the operation.
21+
"""
22+
# Get the move line vals from parent
23+
move_line_vals, taken_quantity = super()._update_reserved_quantity_vals(
24+
need,
25+
location_id,
26+
lot_id=lot_id,
27+
package_id=package_id,
28+
owner_id=owner_id,
29+
strict=strict,
30+
)
31+
32+
# For serial-tracked products, clear the lot_id from the move line vals
33+
if self.product_id.tracking == "serial":
34+
for vals in move_line_vals:
35+
vals["lot_id"] = False
36+
vals["lot_name"] = False
37+
38+
return move_line_vals, taken_quantity
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_serial_control
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
from odoo.tests import TransactionCase, tagged
2+
3+
4+
@tagged("post_install", "-at_install")
5+
class TestStockLotSerialNoDefault(TransactionCase):
6+
"""Test that serial numbers are not automatically assigned during reservation."""
7+
8+
@classmethod
9+
def setUpClass(cls):
10+
super().setUpClass()
11+
12+
# Create locations
13+
cls.stock_location = cls.env.ref("stock.stock_location_stock")
14+
cls.customer_location = cls.env.ref("stock.stock_location_customers")
15+
16+
# Create a product tracked by serial number
17+
cls.product_serial = cls.env["product.product"].create(
18+
{
19+
"name": "Test Product Serial",
20+
"type": "consu",
21+
"is_storable": True,
22+
"tracking": "serial",
23+
"categ_id": cls.env.ref("product.product_category_all").id,
24+
}
25+
)
26+
27+
# Create a product tracked by lot
28+
cls.product_lot = cls.env["product.product"].create(
29+
{
30+
"name": "Test Product Lot",
31+
"type": "consu",
32+
"is_storable": True,
33+
"tracking": "lot",
34+
"categ_id": cls.env.ref("product.product_category_all").id,
35+
}
36+
)
37+
38+
# Create a product without tracking
39+
cls.product_none = cls.env["product.product"].create(
40+
{
41+
"name": "Test Product No Tracking",
42+
"type": "consu",
43+
"is_storable": True,
44+
"tracking": "none",
45+
"categ_id": cls.env.ref("product.product_category_all").id,
46+
}
47+
)
48+
49+
# Create serial numbers for the serial-tracked product
50+
cls.serial_1 = cls.env["stock.lot"].create(
51+
{
52+
"name": "SERIAL-001",
53+
"product_id": cls.product_serial.id,
54+
"company_id": cls.env.company.id,
55+
}
56+
)
57+
cls.serial_2 = cls.env["stock.lot"].create(
58+
{
59+
"name": "SERIAL-002",
60+
"product_id": cls.product_serial.id,
61+
"company_id": cls.env.company.id,
62+
}
63+
)
64+
65+
# Create lot for the lot-tracked product
66+
cls.lot_1 = cls.env["stock.lot"].create(
67+
{
68+
"name": "LOT-001",
69+
"product_id": cls.product_lot.id,
70+
"company_id": cls.env.company.id,
71+
}
72+
)
73+
74+
# Add stock for all products
75+
cls.env["stock.quant"]._update_available_quantity(
76+
cls.product_serial,
77+
cls.stock_location,
78+
1.0,
79+
lot_id=cls.serial_1,
80+
)
81+
cls.env["stock.quant"]._update_available_quantity(
82+
cls.product_serial,
83+
cls.stock_location,
84+
1.0,
85+
lot_id=cls.serial_2,
86+
)
87+
cls.env["stock.quant"]._update_available_quantity(
88+
cls.product_lot,
89+
cls.stock_location,
90+
10.0,
91+
lot_id=cls.lot_1,
92+
)
93+
cls.env["stock.quant"]._update_available_quantity(
94+
cls.product_none,
95+
cls.stock_location,
96+
100.0,
97+
)
98+
99+
def test_serial_no_automatic_assignment(self):
100+
"""Test that serial numbers are NOT automatically assigned."""
101+
# Create a picking for serial-tracked product
102+
picking = self.env["stock.picking"].create(
103+
{
104+
"location_id": self.stock_location.id,
105+
"location_dest_id": self.customer_location.id,
106+
"picking_type_id": self.env.ref("stock.picking_type_out").id,
107+
}
108+
)
109+
110+
# Create a move for 1 unit of serial-tracked product
111+
move = self.env["stock.move"].create(
112+
{
113+
"name": "Test Move Serial",
114+
"product_id": self.product_serial.id,
115+
"product_uom_qty": 1.0,
116+
"product_uom": self.product_serial.uom_id.id,
117+
"picking_id": picking.id,
118+
"location_id": self.stock_location.id,
119+
"location_dest_id": self.customer_location.id,
120+
}
121+
)
122+
123+
# Confirm the picking
124+
picking.action_confirm()
125+
126+
# Reserve stock (this is where automatic assignment would happen)
127+
picking.action_assign()
128+
129+
# Verify the move is assigned (reserved)
130+
self.assertEqual(
131+
move.state,
132+
"assigned",
133+
"Move should be in assigned state after reservation",
134+
)
135+
136+
# CRITICAL TEST: Verify that move lines exist but have NO lot_id
137+
self.assertTrue(
138+
move.move_line_ids,
139+
"Move lines should be created after reservation",
140+
)
141+
for move_line in move.move_line_ids:
142+
self.assertFalse(
143+
move_line.lot_id,
144+
"Serial number should NOT be automatically assigned to move line",
145+
)
146+
self.assertEqual(
147+
move_line.quantity,
148+
1.0,
149+
"Reserved quantity should be 1.0 even without lot_id assignment",
150+
)
151+
152+
def test_lot_tracking_still_works(self):
153+
"""Test that lot-tracked products still get automatic assignment."""
154+
# Create a picking for lot-tracked product
155+
picking = self.env["stock.picking"].create(
156+
{
157+
"location_id": self.stock_location.id,
158+
"location_dest_id": self.customer_location.id,
159+
"picking_type_id": self.env.ref("stock.picking_type_out").id,
160+
}
161+
)
162+
163+
# Create a move for lot-tracked product
164+
move = self.env["stock.move"].create(
165+
{
166+
"name": "Test Move Lot",
167+
"product_id": self.product_lot.id,
168+
"product_uom_qty": 5.0,
169+
"product_uom": self.product_lot.uom_id.id,
170+
"picking_id": picking.id,
171+
"location_id": self.stock_location.id,
172+
"location_dest_id": self.customer_location.id,
173+
}
174+
)
175+
176+
# Confirm and reserve
177+
picking.action_confirm()
178+
picking.action_assign()
179+
180+
# Verify lot-tracked products still get automatic assignment
181+
self.assertEqual(move.state, "assigned")
182+
self.assertTrue(move.move_line_ids)
183+
# For lot tracking, the lot_id should still be assigned
184+
lot_assigned = any(ml.lot_id for ml in move.move_line_ids)
185+
self.assertTrue(
186+
lot_assigned,
187+
"Lot-tracked products should still get automatic lot assignment",
188+
)
189+
190+
def test_no_tracking_still_works(self):
191+
"""Test that products without tracking still work normally."""
192+
# Create a picking for non-tracked product
193+
picking = self.env["stock.picking"].create(
194+
{
195+
"location_id": self.stock_location.id,
196+
"location_dest_id": self.customer_location.id,
197+
"picking_type_id": self.env.ref("stock.picking_type_out").id,
198+
}
199+
)
200+
201+
# Create a move
202+
move = self.env["stock.move"].create(
203+
{
204+
"name": "Test Move No Tracking",
205+
"product_id": self.product_none.id,
206+
"product_uom_qty": 10.0,
207+
"product_uom": self.product_none.uom_id.id,
208+
"picking_id": picking.id,
209+
"location_id": self.stock_location.id,
210+
"location_dest_id": self.customer_location.id,
211+
}
212+
)
213+
214+
# Confirm and reserve
215+
picking.action_confirm()
216+
picking.action_assign()
217+
218+
# Verify normal reservation works
219+
self.assertEqual(move.state, "assigned")
220+
self.assertTrue(move.move_line_ids)
221+
self.assertEqual(
222+
sum(move.move_line_ids.mapped("quantity")),
223+
10.0,
224+
"Full quantity should be reserved for non-tracked products",
225+
)
226+
227+
def test_manual_serial_entry_still_possible(self):
228+
"""Test that staff can manually enter serial numbers after reservation."""
229+
# Create and reserve a picking
230+
picking = self.env["stock.picking"].create(
231+
{
232+
"location_id": self.stock_location.id,
233+
"location_dest_id": self.customer_location.id,
234+
"picking_type_id": self.env.ref("stock.picking_type_out").id,
235+
}
236+
)
237+
238+
move = self.env["stock.move"].create(
239+
{
240+
"name": "Test Move Serial Manual",
241+
"product_id": self.product_serial.id,
242+
"product_uom_qty": 1.0,
243+
"product_uom": self.product_serial.uom_id.id,
244+
"picking_id": picking.id,
245+
"location_id": self.stock_location.id,
246+
"location_dest_id": self.customer_location.id,
247+
}
248+
)
249+
250+
picking.action_confirm()
251+
picking.action_assign()
252+
253+
# Manually assign a serial number to a move line
254+
move_line = move.move_line_ids[0]
255+
move_line.write(
256+
{
257+
"lot_id": self.serial_1.id,
258+
"quantity": 1.0,
259+
}
260+
)
261+
262+
# Verify manual assignment works
263+
self.assertEqual(
264+
move_line.lot_id.id,
265+
self.serial_1.id,
266+
"Manual serial number assignment should work",
267+
)
268+
self.assertEqual(
269+
move_line.quantity,
270+
1.0,
271+
"Manual quantity assignment should work",
272+
)
273+
274+
# Verify picking can be validated with manual serial entry
275+
picking.button_validate()
276+
self.assertEqual(
277+
picking.state,
278+
"done",
279+
"Picking should be completed after manual serial entry",
280+
)

0 commit comments

Comments
 (0)