diff --git a/stock_lot_serial_no_default/README.rst b/stock_lot_serial_no_default/README.rst new file mode 100644 index 00000000..92db91bd --- /dev/null +++ b/stock_lot_serial_no_default/README.rst @@ -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 diff --git a/stock_lot_serial_no_default/__init__.py b/stock_lot_serial_no_default/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/stock_lot_serial_no_default/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_lot_serial_no_default/__manifest__.py b/stock_lot_serial_no_default/__manifest__.py new file mode 100644 index 00000000..603d57e4 --- /dev/null +++ b/stock_lot_serial_no_default/__manifest__.py @@ -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, +} diff --git a/stock_lot_serial_no_default/models/__init__.py b/stock_lot_serial_no_default/models/__init__.py new file mode 100644 index 00000000..6bda2d24 --- /dev/null +++ b/stock_lot_serial_no_default/models/__init__.py @@ -0,0 +1 @@ +from . import stock_move diff --git a/stock_lot_serial_no_default/models/stock_move.py b/stock_lot_serial_no_default/models/stock_move.py new file mode 100644 index 00000000..935bc7c9 --- /dev/null +++ b/stock_lot_serial_no_default/models/stock_move.py @@ -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 diff --git a/stock_lot_serial_no_default/tests/__init__.py b/stock_lot_serial_no_default/tests/__init__.py new file mode 100644 index 00000000..3bff88bd --- /dev/null +++ b/stock_lot_serial_no_default/tests/__init__.py @@ -0,0 +1 @@ +from . import test_serial_control diff --git a/stock_lot_serial_no_default/tests/test_serial_control.py b/stock_lot_serial_no_default/tests/test_serial_control.py new file mode 100644 index 00000000..65c95e73 --- /dev/null +++ b/stock_lot_serial_no_default/tests/test_serial_control.py @@ -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", + )