commit 086eb52c835ba4de4d1ee641cb9a78ae8a99ae0a Author: admin.suherdy Date: Thu Nov 27 10:00:38 2025 +0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b6bffa --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Quality Check Lot Preserve + +## Overview + +This module modifies the standard Odoo 18 quality check behavior for receipt operations to preserve quality check states when lot numbers are assigned after quality checks have been completed. + +## Problem Statement + +In standard Odoo, when a quality check is completed before a lot number is assigned, the quality check automatically resets to "Todo" state when the lot number is later assigned. This creates inefficiency in warehouse operations where: + +1. Quality checks are performed immediately upon receipt +2. Lot numbers are generated or assigned later in the process +3. Quality checks must be re-done unnecessarily + +## Solution + +This module provides the following functionality: + +### 1. State Preservation +- Quality check states (Pass, Fail, In Progress) are preserved when lot numbers are assigned +- Applies only to receipt operations (incoming shipments) +- Other operation types (manufacturing, internal transfers, delivery) continue with standard Odoo behavior + +### 2. Automatic Lot Number Synchronization +- Lot numbers assigned to stock move lines are automatically copied to related quality checks +- Lot number changes are synchronized in real-time +- Maintains traceability between inventory and quality records + +### 3. Selective Application +- Custom behavior applies only to receipt operations +- Standard Odoo workflows remain unchanged for other operation types +- Seamless integration with existing quality control processes + +## Installation + +1. Copy this module to your Odoo addons directory (e.g., `customaddons/quality_check_lot_preserve`) +2. Update the apps list in Odoo +3. Install the "Quality Check Lot Preserve" module + +## Dependencies + +- `stock` - Odoo Inventory Management +- `quality_control` - Odoo Quality Control (Enterprise module) + +**Note:** This module requires Odoo Enterprise edition as it depends on the `quality_control` module. + +## Usage + +### Typical Workflow + +1. **Receive Products**: Create a purchase order and validate the receipt +2. **Perform Quality Checks**: Complete quality checks immediately upon receipt (before lot assignment) +3. **Assign Lot Numbers**: Generate or manually assign lot numbers to the stock move lines +4. **Result**: Quality check states remain unchanged, and lot numbers are automatically copied to quality checks + +### Example Scenario + +**Before this module:** +- Receive 100 units of Product A +- Complete quality check → Status: "Pass" +- Assign lot number "LOT001" +- Quality check resets → Status: "Todo" ❌ +- Must re-do quality check + +**With this module:** +- Receive 100 units of Product A +- Complete quality check → Status: "Pass" +- Assign lot number "LOT001" +- Quality check remains → Status: "Pass" ✓ +- Lot number automatically copied to quality check ✓ + +## Technical Details + +### Extended Models + +#### stock.move.line +- Monitors `lot_id` and `lot_name` field changes +- Identifies related quality checks for receipt operations +- Triggers quality check lot number updates + +#### quality.check +- Prevents state resets during lot assignment for receipt operations +- Accepts lot number updates from stock move lines +- Maintains standard behavior for non-receipt operations + +### Operation Type Detection + +The module identifies receipt operations by checking: +- `picking_type_code == 'incoming'` +- Fallback to `picking_id.picking_type_id.code` + +### Data Flow + +``` +Stock Move Line (Lot Assigned) + ↓ +Detect Change (write method) + ↓ +Find Related Quality Checks + ↓ +Check Operation Type (Receipt?) + ↓ Yes +Copy Lot Number + Preserve State + ↓ +Update Quality Check +``` + +### Security and Access Rights + +This module extends existing Odoo models (`stock.move.line` and `quality.check`) without creating new models or data structures. Therefore: + +- **No new access rights are required** +- Access rights are inherited from the base modules (`stock` and `quality_control`) +- Users with existing quality control and inventory permissions can use this module +- The `security/ir.model.access.csv` file is present but empty (only contains the header row) + +The module respects all existing Odoo security rules and group permissions. + +## Configuration + +No additional configuration is required. The module works automatically once installed. + +## Compatibility + +- **Odoo Version**: 18.0 +- **Edition**: Enterprise (requires quality_control module) +- **Database**: PostgreSQL + +## Limitations + +- Only applies to receipt operations (incoming shipments) +- Requires the quality_control Enterprise module +- Does not modify historical quality check records + +## Support + +For issues, questions, or feature requests, please contact your Odoo implementation partner or system administrator. + +## License + +LGPL-3 + +## Author + +Your Company + +## Version History + +### 1.0.0 (Initial Release) +- State preservation for quality checks during lot assignment +- Automatic lot number synchronization +- Receipt operation filtering +- Full integration with standard Odoo quality control workflows diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..f73c6af --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Quality Check Lot Preserve', + 'version': '18.0.1.0.0', + 'category': 'Inventory/Quality', + 'summary': 'Preserve quality check states when lot numbers are assigned during receipt operations', + 'description': """ +Quality Check Lot Preservation +=============================== + +This module modifies the standard Odoo 18 quality check behavior for receipt operations. + +Key Features: +------------- +* Preserves quality check states when lot numbers are assigned after quality checks are completed +* Automatically copies lot numbers from stock move lines to related quality checks +* Maintains synchronization when lot numbers are changed +* Applies only to receipt operations, leaving other workflows unchanged + +Use Case: +--------- +In standard Odoo, when a quality check is completed before a lot number is assigned, +the quality check resets to "Todo" state when the lot number is later assigned. +This module prevents that reset and automatically updates the quality check with the lot number. + +Technical Details: +------------------ +* Extends stock.move.line to detect lot number changes +* Extends quality.check to prevent state resets for receipt operations +* Maintains full compatibility with standard Odoo quality control workflows + """, + 'author': 'Your Company', + 'website': 'https://www.yourcompany.com', + 'license': 'LGPL-3', + 'depends': [ + 'stock', + 'quality_control', + ], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..61d7d61 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/__manifest__.cpython-312.pyc b/__pycache__/__manifest__.cpython-312.pyc new file mode 100644 index 0000000..2c68d33 Binary files /dev/null and b/__pycache__/__manifest__.cpython-312.pyc differ diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..1bd1be7 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import stock_move_line +from . import quality_check diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..160066d Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/quality_check.cpython-312.pyc b/models/__pycache__/quality_check.cpython-312.pyc new file mode 100644 index 0000000..07996a6 Binary files /dev/null and b/models/__pycache__/quality_check.cpython-312.pyc differ diff --git a/models/__pycache__/stock_move_line.cpython-312.pyc b/models/__pycache__/stock_move_line.cpython-312.pyc new file mode 100644 index 0000000..6d95667 Binary files /dev/null and b/models/__pycache__/stock_move_line.cpython-312.pyc differ diff --git a/models/quality_check.py b/models/quality_check.py new file mode 100644 index 0000000..2d4eafa --- /dev/null +++ b/models/quality_check.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +from odoo import models, api, fields + + +class QualityCheck(models.Model): + _inherit = 'quality.check' + + # Add a context flag to track when we're preserving state + _preserve_state_context_key = 'quality_check_preserve_state' + + def _is_receipt_operation(self): + """ + Check if the current quality check is part of a receipt operation. + Receipt operations are identified by picking_type_code == 'incoming'. + + This method implements robust operation type identification with multiple + fallback paths to ensure reliable detection across different scenarios. + + Returns: + bool: True if this is a receipt operation, False otherwise + """ + self.ensure_one() + + # Primary check: picking_type_code through the picking + if self.picking_id and self.picking_id.picking_type_id: + return self.picking_id.picking_type_id.code == 'incoming' + + # Fallback 1: check through move_line_id if picking_id is not available + if self.move_line_id: + if self.move_line_id.picking_id and self.move_line_id.picking_id.picking_type_id: + return self.move_line_id.picking_id.picking_type_id.code == 'incoming' + + # Fallback 2: check through move_id.picking_type_id + if self.move_line_id.move_id and self.move_line_id.move_id.picking_type_id: + return self.move_line_id.move_id.picking_type_id.code == 'incoming' + + # Fallback 3: check through move_id if available + if hasattr(self, 'move_id') and self.move_id and self.move_id.picking_type_id: + return self.move_id.picking_type_id.code == 'incoming' + + # Default: not a receipt operation if we can't determine the type + return False + + def _should_preserve_state(self): + """ + Determine if the quality check state should be preserved during lot assignment. + State preservation applies only to receipt operations. + + Returns: + bool: True if state should be preserved, False otherwise + """ + self.ensure_one() + return self._is_receipt_operation() + + def write(self, vals): + """ + Override write to prevent state reset when lot_id is updated on receipt operations. + """ + # Store current states before write for receipt operations + state_data = {} + for record in self: + if record._should_preserve_state() and record.quality_state != 'none': + # Only preserve if we're not explicitly changing the quality_state + if 'quality_state' not in vals: + state_data[record.id] = { + 'quality_state': record.quality_state, + 'user_id': record.user_id.id if record.user_id else False, + 'control_date': record.control_date, + } + + # Perform the write + result = super(QualityCheck, self).write(vals) + + # Restore states if they were changed + if state_data: + for record in self: + if record.id in state_data: + stored = state_data[record.id] + if record.quality_state != stored['quality_state']: + # Use SQL to restore state without triggering write again + self.env.cr.execute( + """ + UPDATE quality_check + SET quality_state = %s, user_id = %s, control_date = %s + WHERE id = %s + """, + (stored['quality_state'], stored['user_id'], + stored['control_date'], record.id) + ) + record.invalidate_recordset(['quality_state', 'user_id', 'control_date']) + + return result + + @api.depends('move_line_id.lot_id') + def _compute_lot_line_id(self): + """ + Override the compute method to preserve quality check state during lot updates. + This prevents the state from being reset when lot numbers are assigned. + """ + for qc in self: + # Always update lot_line_id + qc.lot_line_id = qc.move_line_id.lot_id + + # Check if we should update lot_id + if qc.lot_line_id and qc._update_lot_from_lot_line(): + # For receipt operations, preserve the state + if qc._should_preserve_state(): + # Store current state before updating lot_id + current_state = qc.quality_state + + # Directly update lot_id field in database without triggering ORM + if current_state != 'none': + # Use SQL to update lot_id while preserving state + self.env.cr.execute( + """ + UPDATE quality_check + SET lot_id = %s + WHERE id = %s AND quality_state = %s + """, + (qc.lot_line_id.id if qc.lot_line_id else None, qc.id, current_state) + ) + # Invalidate cache for lot_id only + qc.invalidate_recordset(['lot_id']) + else: + # If state is 'none', use normal assignment + qc.lot_id = qc.lot_line_id + else: + # For non-receipt operations, use standard behavior + qc.lot_id = qc.lot_line_id + + def _update_lot_from_lot_line(self): + """ + Override the standard method from quality_control module. + This method is called by _compute_lot_line_id to determine if the lot_id + should be automatically updated from the move_line_id.lot_id. + + For receipt operations, we return True to allow the update, but we handle + state preservation in the overridden _compute_lot_line_id method. + + For non-receipt operations, we preserve standard Odoo behavior. + + Returns: + bool: True to allow lot update (state preservation handled separately) + """ + self.ensure_one() + + # Always return True to allow lot updates + # State preservation is handled in _compute_lot_line_id + return super(QualityCheck, self)._update_lot_from_lot_line() + + def _check_to_unlink(self): + """ + Override to prevent deletion of completed quality checks on receipt operations. + """ + self.ensure_one() + + # For receipt operations with completed quality checks, prevent deletion + if self._should_preserve_state() and self.quality_state != 'none': + return False + + # For other cases, use standard behavior + return super(QualityCheck, self)._check_to_unlink() + + def _update_lot_from_move_line_manual(self, lot_id): + """ + Manually update the lot_id field while preserving the current quality check state. + This method is called from stock.move.line when a lot number is assigned or changed + on receipt operations. + + Args: + lot_id: The ID of the lot to assign (or False to clear) + """ + self.ensure_one() + + # Store the current quality state before updating lot_id + current_state = self.quality_state + current_user = self.user_id + current_control_date = self.control_date + + # Use context flag to preserve state during write + self.with_context(quality_check_preserve_state=True).sudo().write({'lot_id': lot_id}) + + # Force restore the quality state if it was changed during the write + if current_state != 'none': + # Use direct SQL update to avoid triggering any compute methods + self.env.cr.execute( + """ + UPDATE quality_check + SET quality_state = %s, user_id = %s, control_date = %s + WHERE id = %s + """, + (current_state, current_user.id if current_user else None, + current_control_date, self.id) + ) + # Invalidate cache to ensure the updated values are reflected + self.invalidate_recordset(['quality_state', 'user_id', 'control_date']) diff --git a/models/stock_move_line.py b/models/stock_move_line.py new file mode 100644 index 0000000..0a46d92 --- /dev/null +++ b/models/stock_move_line.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api + + +class StockMoveLine(models.Model): + _inherit = 'stock.move.line' + + def _create_check(self): + """ + Override to prevent creating duplicate quality checks when move lines are recreated. + For receipt operations with existing completed quality checks, relink them instead. + """ + # Check each move line + move_lines_to_process = self.env['stock.move.line'] + + for ml in self: + if ml._is_receipt_operation() and ml.picking_id: + # Search for existing quality checks (including orphaned ones) + existing_checks = self.env['quality.check'].search([ + ('picking_id', '=', ml.picking_id.id), + ('product_id', '=', ml.product_id.id), + ('quality_state', '!=', 'none'), + '|', + ('move_line_id', '=', False), + ('move_line_id', '=', ml.id) + ]) + + # If there are completed quality checks, relink them instead of creating new ones + if existing_checks: + # Relink to this move line + existing_checks.write({'move_line_id': ml.id}) + # Skip creating new checks for this move line + continue + + # Add to list for standard processing + move_lines_to_process |= ml + + # Call parent method only for move lines that need new checks + if move_lines_to_process: + return super(StockMoveLine, move_lines_to_process)._create_check() + + return self.env['quality.check'] + + def write(self, vals): + """ + Override write method to detect lot_id and lot_name changes. + When a lot number is assigned or changed on a receipt operation, + update related quality checks with the new lot number. + """ + # Call parent write first to ensure the changes are saved + result = super(StockMoveLine, self).write(vals) + + # Check if lot_id or lot_name was changed + if 'lot_id' in vals or 'lot_name' in vals: + # Process each move line that was updated + for move_line in self: + # Check if this is a receipt operation + if move_line._is_receipt_operation(): + # Get the lot_id to propagate to quality checks + lot_id = move_line.lot_id.id if move_line.lot_id else False + # Update related quality checks + move_line._update_quality_check_lot(lot_id) + + return result + + def _is_receipt_operation(self): + """ + Check if the current move line is part of a receipt operation. + Receipt operations are identified by picking_type_code == 'incoming'. + + This method implements robust operation type identification with multiple + fallback paths to ensure reliable detection across different scenarios. + + Returns: + bool: True if this is a receipt operation, False otherwise + """ + self.ensure_one() + + # Primary check: picking_type_code through the picking + if self.picking_id and self.picking_id.picking_type_id: + return self.picking_id.picking_type_id.code == 'incoming' + + # Fallback 1: check through move_id.picking_type_id + if self.move_id and self.move_id.picking_type_id: + return self.move_id.picking_type_id.code == 'incoming' + + # Fallback 2: check through move_id.picking_id (in case picking_type_id is not directly on move) + if self.move_id and self.move_id.picking_id and self.move_id.picking_id.picking_type_id: + return self.move_id.picking_id.picking_type_id.code == 'incoming' + + # Default: not a receipt operation if we can't determine the type + return False + + def _update_quality_check_lot(self, lot_id): + """ + Update related quality checks with the new lot number. + This method searches for quality checks linked to this move line + and updates their lot_id field without triggering state reset. + + Also handles orphaned quality checks (where move_line_id was cleared). + + Args: + lot_id: The ID of the lot to assign to quality checks (or False) + """ + self.ensure_one() + + QualityCheck = self.env['quality.check'] + + # Search for quality checks related to this move line + quality_checks = QualityCheck.search([ + ('move_line_id', '=', self.id) + ]) + + # Also search for orphaned quality checks from the same picking + # that might have been unlinked from their move_line + if self.picking_id: + orphaned_checks = QualityCheck.search([ + ('picking_id', '=', self.picking_id.id), + ('move_line_id', '=', False), + ('product_id', '=', self.product_id.id), + ('quality_state', '!=', 'none') + ]) + + # Relink orphaned checks to this move line + if orphaned_checks: + orphaned_checks.write({'move_line_id': self.id}) + quality_checks |= orphaned_checks + + # Filter to ensure we only update quality checks for receipt operations + receipt_quality_checks = quality_checks.filtered( + lambda qc: qc._is_receipt_operation() + ) + + # Update the lot_id on each quality check without triggering state reset + for quality_check in receipt_quality_checks: + # Use the quality check's own method to update lot without state reset + quality_check._update_lot_from_move_line_manual(lot_id) diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2714b4f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +from . import test_basic_functionality +from . import test_property_lot_propagation +from . import test_property_lot_update +from . import test_property_state_preservation +from . import test_property_relationship_integrity +from . import test_property_receipt_operation +from . import test_property_non_receipt_operation +from . import test_edge_cases +from . import test_integration diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a3e73b5 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/test_basic_functionality.cpython-312.pyc b/tests/__pycache__/test_basic_functionality.cpython-312.pyc new file mode 100644 index 0000000..f948bdd Binary files /dev/null and b/tests/__pycache__/test_basic_functionality.cpython-312.pyc differ diff --git a/tests/__pycache__/test_edge_cases.cpython-312.pyc b/tests/__pycache__/test_edge_cases.cpython-312.pyc new file mode 100644 index 0000000..0f72b85 Binary files /dev/null and b/tests/__pycache__/test_edge_cases.cpython-312.pyc differ diff --git a/tests/__pycache__/test_integration.cpython-312.pyc b/tests/__pycache__/test_integration.cpython-312.pyc new file mode 100644 index 0000000..91168a7 Binary files /dev/null and b/tests/__pycache__/test_integration.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_lot_propagation.cpython-312.pyc b/tests/__pycache__/test_property_lot_propagation.cpython-312.pyc new file mode 100644 index 0000000..0421402 Binary files /dev/null and b/tests/__pycache__/test_property_lot_propagation.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_lot_update.cpython-312.pyc b/tests/__pycache__/test_property_lot_update.cpython-312.pyc new file mode 100644 index 0000000..bc26966 Binary files /dev/null and b/tests/__pycache__/test_property_lot_update.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_non_receipt_operation.cpython-312.pyc b/tests/__pycache__/test_property_non_receipt_operation.cpython-312.pyc new file mode 100644 index 0000000..5619805 Binary files /dev/null and b/tests/__pycache__/test_property_non_receipt_operation.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_receipt_operation.cpython-312.pyc b/tests/__pycache__/test_property_receipt_operation.cpython-312.pyc new file mode 100644 index 0000000..1212dc6 Binary files /dev/null and b/tests/__pycache__/test_property_receipt_operation.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_relationship_integrity.cpython-312.pyc b/tests/__pycache__/test_property_relationship_integrity.cpython-312.pyc new file mode 100644 index 0000000..74f33c3 Binary files /dev/null and b/tests/__pycache__/test_property_relationship_integrity.cpython-312.pyc differ diff --git a/tests/__pycache__/test_property_state_preservation.cpython-312.pyc b/tests/__pycache__/test_property_state_preservation.cpython-312.pyc new file mode 100644 index 0000000..c111a9f Binary files /dev/null and b/tests/__pycache__/test_property_state_preservation.cpython-312.pyc differ diff --git a/tests/test_basic_functionality.py b/tests/test_basic_functionality.py new file mode 100644 index 0000000..c589d75 --- /dev/null +++ b/tests/test_basic_functionality.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase + + +class TestBasicFunctionality(TransactionCase): + """Basic smoke tests to verify the module loads and core methods exist""" + + def setUp(self): + super(TestBasicFunctionality, self).setUp() + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + + def test_stock_move_line_model_exists(self): + """Test that stock.move.line model can be accessed""" + self.assertIsNotNone(self.StockMoveLine, "stock.move.line model should be accessible") + self.assertEqual(self.StockMoveLine._name, 'stock.move.line') + + def test_quality_check_model_exists(self): + """Test that quality.check model can be accessed""" + self.assertIsNotNone(self.QualityCheck, "quality.check model should be accessible") + self.assertEqual(self.QualityCheck._name, 'quality.check') + + def test_stock_move_line_has_custom_methods(self): + """Test that custom methods exist on stock.move.line""" + # Create a dummy move line to test methods exist + move_line = self.StockMoveLine.new({}) + self.assertTrue(hasattr(move_line, '_is_receipt_operation'), + "stock.move.line should have _is_receipt_operation method") + self.assertTrue(hasattr(move_line, '_update_quality_check_lot'), + "stock.move.line should have _update_quality_check_lot method") + + def test_quality_check_has_custom_methods(self): + """Test that custom methods exist on quality.check""" + # Create a dummy quality check to test methods exist + quality_check = self.QualityCheck.new({}) + self.assertTrue(hasattr(quality_check, '_is_receipt_operation'), + "quality.check should have _is_receipt_operation method") + self.assertTrue(hasattr(quality_check, '_should_preserve_state'), + "quality.check should have _should_preserve_state method") + self.assertTrue(hasattr(quality_check, '_update_lot_from_move_line_manual'), + "quality.check should have _update_lot_from_move_line_manual method") diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..8efa520 --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase + + +class TestEdgeCases(TransactionCase): + """Unit tests for edge cases in quality check lot preservation""" + + def setUp(self): + super(TestEdgeCases, self).setUp() + + # Get model references + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.Product = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPicking = self.env['stock.picking'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockMove = self.env['stock.move'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Create test product + self.product = self.Product.create({ + 'name': 'Test Product', + 'type': 'consu', # Use 'consu' for consumable/storable product + 'tracking': 'lot', + }) + + # Get or create locations + self.location_supplier = self.env.ref('stock.stock_location_suppliers') + self.location_stock = self.env.ref('stock.stock_location_stock') + self.location_customer = self.env.ref('stock.stock_location_customers') + + # Get picking types + self.picking_type_in = self.env.ref('stock.picking_type_in') + self.picking_type_out = self.env.ref('stock.picking_type_out') + self.picking_type_internal = self.env.ref('stock.picking_type_internal') + + # Try to get manufacturing picking type if available + try: + self.picking_type_mrp = self.env.ref('mrp.picking_type_manufacturing') + except ValueError: + # Create a mock manufacturing picking type if MRP is not installed + warehouse = self.picking_type_in.warehouse_id + self.picking_type_mrp = self.StockPickingType.create({ + 'name': 'Manufacturing', + 'code': 'mrp_operation', + 'sequence_code': 'MRP', + 'warehouse_id': warehouse.id, + 'default_location_src_id': warehouse.lot_stock_id.id, + 'default_location_dest_id': warehouse.lot_stock_id.id, + }) + + # Get or create a quality team + self.quality_team = self.env['quality.alert.team'].search([], limit=1) + if not self.quality_team: + self.quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Get test type + self.test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not self.test_type: + self.test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + # Create quality point for the product + self.quality_point = self.QualityPoint.create({ + 'product_ids': [(4, self.product.id)], + 'picking_type_ids': [(4, self.picking_type_in.id)], + 'test_type_id': self.test_type.id, + 'team_id': self.quality_team.id, + }) + + def _create_receipt_picking(self): + """Helper to create a receipt picking""" + return self.StockPicking.create({ + 'picking_type_id': self.picking_type_in.id, + 'location_id': self.location_supplier.id, + 'location_dest_id': self.location_stock.id, + }) + + def _create_stock_move(self, picking): + """Helper to create a stock move""" + return self.StockMove.create({ + 'name': 'Test Move', + 'product_id': self.product.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product.uom_id.id, + 'picking_id': picking.id, + 'location_id': picking.location_id.id, + 'location_dest_id': picking.location_dest_id.id, + }) + + def _create_move_line(self, move, lot_id=False): + """Helper to create a stock move line""" + return self.StockMoveLine.create({ + 'move_id': move.id, + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'location_id': move.location_id.id, + 'location_dest_id': move.location_dest_id.id, + 'picking_id': move.picking_id.id, + 'lot_id': lot_id, + 'quantity': 1.0, + }) + + def _create_lot(self, name): + """Helper to create a lot""" + return self.StockLot.create({ + 'name': name, + 'product_id': self.product.id, + 'company_id': self.env.company.id, + }) + + # Subtask 7.1: Test missing quality check scenario + def test_lot_assignment_without_quality_check(self): + """ + Test lot assignment when no quality check exists. + Verify no errors occur and standard behavior continues. + Requirements: 2.1 + """ + # Create receipt picking and move + picking = self._create_receipt_picking() + move = self._create_stock_move(picking) + move_line = self._create_move_line(move) + + # Verify no quality check exists + quality_checks = self.QualityCheck.search([ + ('move_line_id', '=', move_line.id) + ]) + self.assertEqual(len(quality_checks), 0, + "No quality check should exist initially") + + # Assign lot number - should not raise error + lot = self._create_lot('LOT-NO-QC-001') + try: + move_line.write({'lot_id': lot.id}) + success = True + except Exception as e: + success = False + error_msg = str(e) + + self.assertTrue(success, + "Lot assignment should succeed even without quality check") + self.assertEqual(move_line.lot_id.id, lot.id, + "Lot should be assigned to move line") + + # Subtask 7.2: Test multiple quality checks scenario + def test_multiple_quality_checks_update(self): + """ + Test that multiple quality checks for same move line are all updated. + Verify unrelated quality checks are not affected. + Requirements: 2.3 + """ + # Create receipt picking and move + picking = self._create_receipt_picking() + move = self._create_stock_move(picking) + move_line = self._create_move_line(move) + + # Create multiple quality checks for the same move line + qc1 = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': move_line.id, + 'test_type': 'passfail', + 'quality_state': 'pass', + }) + + qc2 = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': move_line.id, + 'test_type': 'passfail', + 'quality_state': 'fail', + }) + + # Create an unrelated quality check (different move line) + other_move_line = self._create_move_line(move) + qc_unrelated = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': other_move_line.id, + 'test_type': 'passfail', + 'quality_state': 'none', + }) + + # Assign lot number to first move line + lot = self._create_lot('LOT-MULTI-001') + move_line.write({'lot_id': lot.id}) + + # Verify both related quality checks are updated + self.assertEqual(qc1.lot_id.id, lot.id, + "First quality check should have lot assigned") + self.assertEqual(qc2.lot_id.id, lot.id, + "Second quality check should have lot assigned") + + # Verify states are preserved + self.assertEqual(qc1.quality_state, 'pass', + "First quality check state should be preserved") + self.assertEqual(qc2.quality_state, 'fail', + "Second quality check state should be preserved") + + # Verify unrelated quality check is not affected + self.assertFalse(qc_unrelated.lot_id, + "Unrelated quality check should not have lot assigned") + + # Subtask 7.3: Test quality check without move line + def test_quality_check_without_move_line(self): + """ + Test quality check without move_line_id. + Verify no automatic updates occur. + Requirements: 3.3 + """ + # Create receipt picking + picking = self._create_receipt_picking() + + # Create quality check without move_line_id + qc = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'test_type': 'passfail', + 'quality_state': 'pass', + }) + + # Create a move line with lot and assign it + move = self._create_stock_move(picking) + lot = self._create_lot('LOT-NO-LINK-001') + move_line = self._create_move_line(move, lot_id=lot.id) + + # Verify quality check is not automatically updated + self.assertFalse(qc.lot_id, + "Quality check without move_line_id should not be auto-updated") + self.assertEqual(qc.quality_state, 'pass', + "Quality check state should remain unchanged") + + # Subtask 7.4: Test each quality check state preservation + def test_pass_state_preservation(self): + """ + Test "Pass" state preservation during lot assignment. + Requirements: 1.2 + """ + picking = self._create_receipt_picking() + move = self._create_stock_move(picking) + move_line = self._create_move_line(move) + + qc = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': move_line.id, + 'test_type': 'passfail', + 'quality_state': 'pass', + }) + + lot = self._create_lot('LOT-PASS-001') + move_line.write({'lot_id': lot.id}) + + self.assertEqual(qc.quality_state, 'pass', + "Pass state should be preserved") + self.assertEqual(qc.lot_id.id, lot.id, + "Lot should be assigned") + + def test_fail_state_preservation(self): + """ + Test "Fail" state preservation during lot assignment. + Requirements: 1.2 + """ + picking = self._create_receipt_picking() + move = self._create_stock_move(picking) + move_line = self._create_move_line(move) + + qc = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': move_line.id, + 'test_type': 'passfail', + 'quality_state': 'fail', + }) + + lot = self._create_lot('LOT-FAIL-001') + move_line.write({'lot_id': lot.id}) + + self.assertEqual(qc.quality_state, 'fail', + "Fail state should be preserved") + self.assertEqual(qc.lot_id.id, lot.id, + "Lot should be assigned") + + def test_none_state_preservation(self): + """ + Test "In Progress" (none) state preservation during lot assignment. + Requirements: 1.2 + """ + picking = self._create_receipt_picking() + move = self._create_stock_move(picking) + move_line = self._create_move_line(move) + + qc = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': picking.id, + 'move_line_id': move_line.id, + 'test_type': 'passfail', + 'quality_state': 'none', + }) + + lot = self._create_lot('LOT-NONE-001') + move_line.write({'lot_id': lot.id}) + + self.assertEqual(qc.quality_state, 'none', + "None/In Progress state should be preserved") + self.assertEqual(qc.lot_id.id, lot.id, + "Lot should be assigned") + + # Subtask 7.5: Test different operation types + def test_manufacturing_operation_standard_behavior(self): + """ + Test manufacturing operation uses standard behavior. + Requirements: 4.2 + """ + # Create manufacturing picking + picking = self.StockPicking.create({ + 'picking_type_id': self.picking_type_mrp.id, + 'location_id': self.location_stock.id, + 'location_dest_id': self.location_stock.id, + }) + + move = self.StockMove.create({ + 'name': 'Test Move', + 'product_id': self.product.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product.uom_id.id, + 'picking_id': picking.id, + 'location_id': picking.location_id.id, + 'location_dest_id': picking.location_dest_id.id, + }) + + move_line = self._create_move_line(move) + + # Verify it's not a receipt operation + self.assertFalse(move_line._is_receipt_operation(), + "Manufacturing operation should not be receipt") + + def test_internal_transfer_standard_behavior(self): + """ + Test internal transfer uses standard behavior. + Requirements: 4.2 + """ + # Create internal transfer picking + picking = self.StockPicking.create({ + 'picking_type_id': self.picking_type_internal.id, + 'location_id': self.location_stock.id, + 'location_dest_id': self.location_stock.id, + }) + + move = self.StockMove.create({ + 'name': 'Test Move', + 'product_id': self.product.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product.uom_id.id, + 'picking_id': picking.id, + 'location_id': picking.location_id.id, + 'location_dest_id': picking.location_dest_id.id, + }) + + move_line = self._create_move_line(move) + + # Verify it's not a receipt operation + self.assertFalse(move_line._is_receipt_operation(), + "Internal transfer should not be receipt") + + def test_delivery_operation_standard_behavior(self): + """ + Test delivery operation uses standard behavior. + Requirements: 4.2 + """ + # Create delivery picking + picking = self.StockPicking.create({ + 'picking_type_id': self.picking_type_out.id, + 'location_id': self.location_stock.id, + 'location_dest_id': self.location_customer.id, + }) + + move = self.StockMove.create({ + 'name': 'Test Move', + 'product_id': self.product.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product.uom_id.id, + 'picking_id': picking.id, + 'location_id': picking.location_id.id, + 'location_dest_id': picking.location_dest_id.id, + }) + + move_line = self._create_move_line(move) + + # Verify it's not a receipt operation + self.assertFalse(move_line._is_receipt_operation(), + "Delivery operation should not be receipt") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..df2d6a8 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase + + +class TestIntegration(TransactionCase): + """Integration tests for quality check lot preservation end-to-end workflows""" + + def setUp(self): + super(TestIntegration, self).setUp() + + # Get model references + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.Product = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPicking = self.env['stock.picking'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockMove = self.env['stock.move'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + self.Partner = self.env['res.partner'] + + # Create test product + self.product = self.Product.create({ + 'name': 'Test Product for Integration', + 'type': 'consu', + 'tracking': 'lot', + }) + + # Get or create locations + self.location_supplier = self.env.ref('stock.stock_location_suppliers') + self.location_stock = self.env.ref('stock.stock_location_stock') + + # Get picking types + self.picking_type_in = self.env.ref('stock.picking_type_in') + + # Create test supplier + self.supplier = self.Partner.create({ + 'name': 'Test Supplier', + }) + + # Get or create a quality team + self.quality_team = self.env['quality.alert.team'].search([], limit=1) + if not self.quality_team: + self.quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Get test type + self.test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not self.test_type: + self.test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + # Create quality point for the product + self.quality_point = self.QualityPoint.create({ + 'product_ids': [(4, self.product.id)], + 'picking_type_ids': [(4, self.picking_type_in.id)], + 'test_type_id': self.test_type.id, + 'team_id': self.quality_team.id, + }) + + def _create_receipt_picking(self): + """Helper to create a receipt picking""" + return self.StockPicking.create({ + 'picking_type_id': self.picking_type_in.id, + 'location_id': self.location_supplier.id, + 'location_dest_id': self.location_stock.id, + 'partner_id': self.supplier.id, + }) + + def _create_stock_move(self, picking, qty=1.0): + """Helper to create a stock move""" + return self.StockMove.create({ + 'name': 'Test Move', + 'product_id': self.product.id, + 'product_uom_qty': qty, + 'product_uom': self.product.uom_id.id, + 'picking_id': picking.id, + 'location_id': picking.location_id.id, + 'location_dest_id': picking.location_dest_id.id, + }) + + def _create_move_line(self, move, lot_id=False): + """Helper to create a stock move line""" + return self.StockMoveLine.create({ + 'move_id': move.id, + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'location_id': move.location_id.id, + 'location_dest_id': move.location_dest_id.id, + 'picking_id': move.picking_id.id, + 'lot_id': lot_id, + 'quantity': 1.0, + }) + + def _create_lot(self, name): + """Helper to create a lot""" + return self.StockLot.create({ + 'name': name, + 'product_id': self.product.id, + 'company_id': self.env.company.id, + }) + + # Subtask 8.1: Create end-to-end receipt workflow test + def test_end_to_end_receipt_workflow(self): + """ + End-to-end test: Create receipt, generate quality checks, + complete quality checks before lot assignment, assign lot numbers, + verify quality check states are preserved and lot numbers are copied. + Requirements: 1.1, 2.1, 3.1 + """ + # Step 1: Create receipt picking + receipt = self._create_receipt_picking() + self.assertEqual(receipt.picking_type_id.code, 'incoming', + "Picking should be a receipt operation") + + # Create stock move + move = self._create_stock_move(receipt, qty=5.0) + + # Step 2: Confirm the picking and create move line + receipt.action_confirm() + + # Create move line + move_line = self._create_move_line(move) + + # Create quality check manually + quality_check = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': receipt.id, + 'move_line_id': move_line.id, + 'test_type_id': self.test_type.id, + 'quality_state': 'none', + 'team_id': self.quality_team.id, + }) + + # Step 3: Complete quality checks before lot assignment + initial_state = 'pass' + quality_check.write({'quality_state': initial_state}) + + self.assertEqual(quality_check.quality_state, initial_state, + "Quality check should be marked as pass") + self.assertFalse(quality_check.lot_id, + "Quality check should not have lot assigned yet") + self.assertFalse(move_line.lot_id, + "Move line should not have lot assigned yet") + + # Step 4: Assign lot numbers to stock move lines + lot = self._create_lot('LOT-E2E-001') + move_line.write({'lot_id': lot.id}) + + # Step 5: Verify quality check states are preserved + self.assertEqual(quality_check.quality_state, initial_state, + "Quality check state should be preserved after lot assignment") + + # Step 6: Verify lot numbers are copied to quality checks + self.assertEqual(quality_check.lot_id.id, lot.id, + "Lot number should be copied to quality check") + + # Additional verification: Ensure the relationship is intact + self.assertEqual(quality_check.move_line_id.id, move_line.id, + "Quality check should still be linked to move line") + + def test_end_to_end_receipt_workflow_with_fail_state(self): + """ + End-to-end test with quality check in 'fail' state. + Verify fail state is preserved after lot assignment. + Requirements: 1.1, 1.2, 2.1 + """ + # Create receipt picking + receipt = self._create_receipt_picking() + move = self._create_stock_move(receipt, qty=3.0) + receipt.action_confirm() + + # Create move line + move_line = self._create_move_line(move) + + # Create quality check and mark as fail + quality_check = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': receipt.id, + 'move_line_id': move_line.id, + 'test_type_id': self.test_type.id, + 'quality_state': 'fail', + 'team_id': self.quality_team.id, + }) + + # Assign lot number + lot = self._create_lot('LOT-E2E-FAIL-001') + move_line.write({'lot_id': lot.id}) + + # Verify fail state is preserved + self.assertEqual(quality_check.quality_state, 'fail', + "Fail state should be preserved after lot assignment") + self.assertEqual(quality_check.lot_id.id, lot.id, + "Lot should be assigned to quality check") + + # Subtask 8.2: Create lot number change workflow test + def test_lot_number_change_workflow(self): + """ + Test lot number change workflow: Create receipt with quality check and initial lot, + change lot number on stock move line, verify quality check is updated with new lot, + verify quality check state remains unchanged. + Requirements: 3.1, 3.2 + """ + # Step 1: Create receipt with quality check and initial lot number + receipt = self._create_receipt_picking() + move = self._create_stock_move(receipt, qty=2.0) + receipt.action_confirm() + + # Create move line + move_line = self._create_move_line(move) + + # Create initial lot and assign it + initial_lot = self._create_lot('LOT-CHANGE-INITIAL') + move_line.write({'lot_id': initial_lot.id}) + + # Create quality check with pass state + quality_check = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': receipt.id, + 'move_line_id': move_line.id, + 'test_type_id': self.test_type.id, + 'quality_state': 'pass', + 'lot_id': initial_lot.id, + 'team_id': self.quality_team.id, + }) + + initial_state = quality_check.quality_state + self.assertEqual(initial_state, 'pass', + "Quality check should be in pass state") + self.assertEqual(quality_check.lot_id.id, initial_lot.id, + "Quality check should have initial lot") + + # Step 2: Change lot number on stock move line + new_lot = self._create_lot('LOT-CHANGE-NEW') + move_line.write({'lot_id': new_lot.id}) + + # Step 3: Verify quality check is updated with new lot number + self.assertEqual(quality_check.lot_id.id, new_lot.id, + "Quality check should be updated with new lot number") + + # Step 4: Verify quality check state remains unchanged + self.assertEqual(quality_check.quality_state, initial_state, + "Quality check state should remain unchanged after lot change") + + # Additional verification: Ensure the relationship is intact + self.assertEqual(quality_check.move_line_id.id, move_line.id, + "Quality check should still be linked to move line") + + def test_lot_number_change_workflow_multiple_changes(self): + """ + Test multiple lot number changes in sequence. + Verify quality check is updated each time and state is preserved. + Requirements: 3.1, 3.2 + """ + # Create receipt with quality check + receipt = self._create_receipt_picking() + move = self._create_stock_move(receipt, qty=1.0) + receipt.action_confirm() + + # Create move line + move_line = self._create_move_line(move) + + # Create quality check with fail state + quality_check = self.QualityCheck.create({ + 'product_id': self.product.id, + 'picking_id': receipt.id, + 'move_line_id': move_line.id, + 'test_type_id': self.test_type.id, + 'quality_state': 'fail', + 'team_id': self.quality_team.id, + }) + + initial_state = 'fail' + + # First lot assignment + lot1 = self._create_lot('LOT-MULTI-CHANGE-001') + move_line.write({'lot_id': lot1.id}) + + self.assertEqual(quality_check.lot_id.id, lot1.id, + "Quality check should have first lot") + self.assertEqual(quality_check.quality_state, initial_state, + "State should be preserved after first change") + + # Second lot change + lot2 = self._create_lot('LOT-MULTI-CHANGE-002') + move_line.write({'lot_id': lot2.id}) + + self.assertEqual(quality_check.lot_id.id, lot2.id, + "Quality check should have second lot") + self.assertEqual(quality_check.quality_state, initial_state, + "State should be preserved after second change") + + # Third lot change + lot3 = self._create_lot('LOT-MULTI-CHANGE-003') + move_line.write({'lot_id': lot3.id}) + + self.assertEqual(quality_check.lot_id.id, lot3.id, + "Quality check should have third lot") + self.assertEqual(quality_check.quality_state, initial_state, + "State should be preserved after third change") diff --git a/tests/test_property_lot_propagation.py b/tests/test_property_lot_propagation.py new file mode 100644 index 0000000..a1c5f83 --- /dev/null +++ b/tests/test_property_lot_propagation.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings, assume +from hypothesis.stateful import Bundle, RuleBasedStateMachine, rule, invariant + + +class TestLotNumberPropagation(TransactionCase): + """ + Property-based tests for lot number propagation to quality checks. + + Feature: quality-check-lot-preserve, Property 2: Lot number propagation to correct quality check + + Property: For any stock move line on a receipt operation with an assigned lot number, + the lot number should be copied to the related quality check for that specific product + and move line, and not to unrelated quality checks. + + Validates: Requirements 2.1, 2.3 + """ + + def setUp(self): + super(TestLotNumberPropagation, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + + # Get or create receipt picking type + self.receipt_picking_type = self.env['stock.picking.type'].search([ + ('code', '=', 'incoming') + ], limit=1) + + if not self.receipt_picking_type: + self.receipt_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Receipts', + 'code': 'incoming', + 'sequence_code': 'IN', + 'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id, + }) + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', # 'consu' for consumable/storable product in Odoo 18 + 'tracking': 'lot', + }) + + def _create_receipt_picking(self, products): + """Helper to create a receipt picking with move lines for given products""" + picking = self.StockPicking.create({ + 'picking_type_id': self.receipt_picking_type.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + moves = [] + for product in products: + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + moves.append(move) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, moves + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [self.receipt_picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, self.receipt_picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + num_products=st.integers(min_value=1, max_value=3), + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_lot_propagation_to_correct_check(self, num_products, lot_name_seed): + """ + Property: For any stock move line on a receipt operation with an assigned lot number, + the lot number should be copied to the related quality check for that specific product + and move line, and not to unrelated quality checks. + + This test verifies Requirements 2.1 and 2.3: + - 2.1: Lot number is copied to the related quality check + - 2.3: Lot number is copied only to the correct quality check, not unrelated ones + """ + # Create products + products = [] + for i in range(num_products): + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}_{i}') + products.append(product) + + # Create receipt picking with move lines + picking, moves = self._create_receipt_picking(products) + + # Get the move lines + move_lines = picking.move_line_ids + assume(len(move_lines) > 0) + + # Create quality checks for each move line + quality_checks = [] + for move_line in move_lines: + qc = self._create_quality_check_for_move_line(move_line, state='pass') + quality_checks.append(qc) + + # Select the first move line to assign a lot number + target_move_line = move_lines[0] + target_quality_check = quality_checks[0] + + # Create and assign a lot number to the target move line + lot = self._create_lot(target_move_line.product_id, f'LOT-{lot_name_seed}') + + # Record initial lot_id values for all quality checks + initial_lot_ids = {qc.id: qc.lot_id.id for qc in quality_checks} + + # Assign the lot to the target move line + target_move_line.write({'lot_id': lot.id}) + + # Refresh quality checks from database + for qc in quality_checks: + qc.invalidate_recordset() + + # PROPERTY VERIFICATION: + # 1. The target quality check should have the lot number assigned (Requirement 2.1) + target_quality_check.invalidate_recordset() + self.assertEqual( + target_quality_check.lot_id.id, + lot.id, + f"Quality check {target_quality_check.id} should have lot {lot.name} assigned" + ) + + # 2. Other quality checks should NOT have this lot number (Requirement 2.3) + for i, qc in enumerate(quality_checks[1:], start=1): + qc.invalidate_recordset() + # The lot_id should remain unchanged (either False or the initial value) + self.assertEqual( + qc.lot_id.id, + initial_lot_ids[qc.id], + f"Unrelated quality check {qc.id} (for product {qc.product_id.name}) " + f"should not have lot {lot.name} assigned" + ) + + # Additionally, if a lot was assigned, it should not be the target lot + if qc.lot_id: + self.assertNotEqual( + qc.lot_id.id, + lot.id, + f"Unrelated quality check {qc.id} should not have the target lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_lot_propagation_single_check(self, lot_name_seed, quality_state): + """ + Simplified property test: For any single quality check on a receipt operation, + when a lot number is assigned to its move line, the lot number should be + copied to the quality check. + + Validates: Requirement 2.1 + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, moves = self._create_receipt_picking([product]) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Create and assign a lot number + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + + # Assign the lot to the move line + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: The quality check should have the lot number assigned + self.assertEqual( + quality_check.lot_id.id, + lot.id, + f"Quality check should have lot {lot.name} assigned" + ) diff --git a/tests/test_property_lot_update.py b/tests/test_property_lot_update.py new file mode 100644 index 0000000..23e9a8b --- /dev/null +++ b/tests/test_property_lot_update.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings, assume + + +class TestLotNumberUpdateSynchronization(TransactionCase): + """ + Property-based tests for lot number update synchronization. + + Feature: quality-check-lot-preserve, Property 3: Lot number update synchronization + + Property: For any quality check with an assigned lot number, when the lot number + on the related stock move line is changed, the quality check should be updated + with the new lot number. + + Validates: Requirements 3.1 + """ + + def setUp(self): + super(TestLotNumberUpdateSynchronization, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + + # Get or create receipt picking type + self.receipt_picking_type = self.env['stock.picking.type'].search([ + ('code', '=', 'incoming') + ], limit=1) + + if not self.receipt_picking_type: + self.receipt_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Receipts', + 'code': 'incoming', + 'sequence_code': 'IN', + 'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id, + }) + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', + 'tracking': 'lot', + }) + + def _create_receipt_picking(self, product): + """Helper to create a receipt picking with a move line for the given product""" + picking = self.StockPicking.create({ + 'picking_type_id': self.receipt_picking_type.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, move + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [self.receipt_picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, self.receipt_picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + initial_lot_seed=st.integers(min_value=1, max_value=1000000), + updated_lot_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_lot_update_synchronization(self, initial_lot_seed, updated_lot_seed, quality_state): + """ + Property: For any quality check with an assigned lot number, when the lot number + on the related stock move line is changed, the quality check should be updated + with the new lot number. + + This test verifies Requirement 3.1: + - When a lot number on a stock move line is changed after initial assignment, + the related quality check is updated with the new lot number + + Test strategy: + 1. Create a receipt with a quality check + 2. Assign an initial lot number to the move line + 3. Verify the quality check receives the initial lot number + 4. Change the lot number on the move line to a different lot + 5. Verify the quality check is updated with the new lot number + """ + # Ensure the two lot numbers are different + assume(initial_lot_seed != updated_lot_seed) + + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {initial_lot_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check with the specified state + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Record the initial quality check state + initial_state = quality_check.quality_state + + # Create and assign the initial lot number + initial_lot = self._create_lot(product, f'LOT-INITIAL-{initial_lot_seed}') + move_line.write({'lot_id': initial_lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # Verify the quality check received the initial lot number + self.assertEqual( + quality_check.lot_id.id, + initial_lot.id, + f"Quality check should have initial lot {initial_lot.name} assigned" + ) + + # Create a new lot number + updated_lot = self._create_lot(product, f'LOT-UPDATED-{updated_lot_seed}') + + # Change the lot number on the move line + move_line.write({'lot_id': updated_lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # 1. The quality check should be updated with the new lot number (Requirement 3.1) + self.assertEqual( + quality_check.lot_id.id, + updated_lot.id, + f"Quality check should be updated with new lot {updated_lot.name} " + f"(was {initial_lot.name})" + ) + + # 2. The quality check state should remain unchanged (Requirement 3.2) + self.assertEqual( + quality_check.quality_state, + initial_state, + f"Quality check state should remain {initial_state} after lot update" + ) + + # 3. The relationship between quality check and move line should remain intact (Requirement 3.3) + self.assertEqual( + quality_check.move_line_id.id, + move_line.id, + "Quality check should still be linked to the same move line" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_seed_1=st.integers(min_value=1, max_value=1000000), + lot_seed_2=st.integers(min_value=1, max_value=1000000), + lot_seed_3=st.integers(min_value=1, max_value=1000000), + ) + def test_property_multiple_lot_updates(self, lot_seed_1, lot_seed_2, lot_seed_3): + """ + Property: For any quality check, multiple consecutive lot number changes + on the move line should result in the quality check always reflecting + the most recent lot number. + + This test verifies that the synchronization works correctly even with + multiple updates in sequence. + """ + # Ensure all lot numbers are different + assume(lot_seed_1 != lot_seed_2) + assume(lot_seed_2 != lot_seed_3) + assume(lot_seed_1 != lot_seed_3) + + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_seed_1}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # Create three different lots + lot_1 = self._create_lot(product, f'LOT-1-{lot_seed_1}') + lot_2 = self._create_lot(product, f'LOT-2-{lot_seed_2}') + lot_3 = self._create_lot(product, f'LOT-3-{lot_seed_3}') + + # First update: assign lot_1 + move_line.write({'lot_id': lot_1.id}) + quality_check.invalidate_recordset() + self.assertEqual(quality_check.lot_id.id, lot_1.id) + + # Second update: change to lot_2 + move_line.write({'lot_id': lot_2.id}) + quality_check.invalidate_recordset() + self.assertEqual(quality_check.lot_id.id, lot_2.id) + + # Third update: change to lot_3 + move_line.write({'lot_id': lot_3.id}) + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: Quality check should have the final lot number + self.assertEqual( + quality_check.lot_id.id, + lot_3.id, + f"Quality check should have the final lot {lot_3.name} after multiple updates" + ) + + # State should still be 'pass' + self.assertEqual( + quality_check.quality_state, + 'pass', + "Quality check state should remain 'pass' after multiple lot updates" + ) diff --git a/tests/test_property_non_receipt_operation.py b/tests/test_property_non_receipt_operation.py new file mode 100644 index 0000000..e002040 --- /dev/null +++ b/tests/test_property_non_receipt_operation.py @@ -0,0 +1,500 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings + + +class TestNonReceiptOperationBehavior(TransactionCase): + """ + Property-based tests for non-receipt operation standard behavior. + + Feature: quality-check-lot-preserve, Property 6: Non-receipt operation standard behavior + + Property: For any quality check associated with non-receipt operations (manufacturing, internal, outgoing), + the standard Odoo behavior (state reset on lot assignment) should occur. + + Validates: Requirements 4.2 + """ + + def setUp(self): + super(TestNonReceiptOperationBehavior, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.stock_location = self.env.ref('stock.stock_location_stock') + self.customer_location = self.env.ref('stock.stock_location_customers') + + # Get or create different picking types for non-receipt operations + self.internal_picking_type = self._get_or_create_picking_type('internal', 'Internal Transfers') + self.outgoing_picking_type = self._get_or_create_picking_type('outgoing', 'Delivery Orders') + + def _get_or_create_picking_type(self, code, name): + """Helper to get or create a picking type""" + picking_type = self.env['stock.picking.type'].search([ + ('code', '=', code) + ], limit=1) + + if not picking_type: + warehouse = self.env['stock.warehouse'].search([], limit=1) + if not warehouse: + # Create a minimal warehouse if none exists + warehouse = self.env['stock.warehouse'].create({ + 'name': 'Test Warehouse', + 'code': 'TEST', + }) + + picking_type = self.env['stock.picking.type'].create({ + 'name': name, + 'code': code, + 'sequence_code': code.upper()[:5], + 'warehouse_id': warehouse.id, + }) + + return picking_type + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', # 'consu' for consumable/storable product in Odoo 18 + 'tracking': 'lot', + }) + + def _create_picking(self, picking_type, product, location_id, location_dest_id): + """Helper to create a picking with a move line for the given product""" + picking = self.StockPicking.create({ + 'picking_type_id': picking_type.id, + 'location_id': location_id.id, + 'location_dest_id': location_dest_id.id, + }) + + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': location_id.id, + 'location_dest_id': location_dest_id.id, + }) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, move + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, picking_type, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + operation_type=st.sampled_from(['internal', 'outgoing']), + ) + def test_property_non_receipt_operation_not_identified_as_receipt(self, lot_name_seed, operation_type): + """ + Property: For any quality check associated with non-receipt operations, + the system should correctly identify it as NOT a receipt operation. + + This test verifies Requirement 4.2: + - When a quality check is associated with non-receipt operations (manufacturing, internal transfers, delivery), + the system should follow standard Odoo quality check behavior + + Test strategy: + 1. Create a non-receipt operation (internal or outgoing) + 2. Create a quality check for that operation + 3. Verify that: + a) The quality check is NOT identified as a receipt operation + b) The preservation behavior is NOT activated (_should_preserve_state returns False) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Select the appropriate picking type based on operation_type + if operation_type == 'internal': + picking_type = self.internal_picking_type + location_id = self.stock_location + location_dest_id = self.stock_location # Internal transfer within same location + else: # outgoing + picking_type = self.outgoing_picking_type + location_id = self.stock_location + location_dest_id = self.customer_location + + # Create non-receipt picking + picking, move = self._create_picking(picking_type, product, location_id, location_dest_id) + + # Verify this is NOT a receipt operation + self.assertNotEqual( + picking.picking_type_id.code, + 'incoming', + f"Picking should NOT be a receipt operation (code is '{picking.picking_type_id.code}')" + ) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Verify the move line is NOT identified as a receipt operation + self.assertFalse( + move_line._is_receipt_operation(), + f"Move line should NOT be identified as a receipt operation for {operation_type} operations" + ) + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, picking_type, state='pass') + + # PROPERTY VERIFICATION PART 1: Non-receipt operation detection + # The quality check should NOT be identified as a receipt operation + self.assertFalse( + quality_check._is_receipt_operation(), + f"Quality check should NOT be identified as a receipt operation for {operation_type} operations" + ) + + # PROPERTY VERIFICATION PART 2: Preservation behavior NOT activated + # The quality check should indicate that state should NOT be preserved + self.assertFalse( + quality_check._should_preserve_state(), + f"Quality check should indicate that state preservation is NOT active for {operation_type} operations" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + operation_type=st.sampled_from(['internal', 'outgoing']), + ) + def test_property_non_receipt_operation_no_automatic_lot_propagation(self, lot_name_seed, operation_type): + """ + Property: For any non-receipt operation, when a lot is assigned to a stock move line, + the lot should NOT be automatically propagated to quality checks by our module. + + This verifies that our custom lot propagation logic only applies to receipt operations. + + Validates: Requirement 4.2 (standard behavior for non-receipt operations) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Select the appropriate picking type based on operation_type + if operation_type == 'internal': + picking_type = self.internal_picking_type + location_id = self.stock_location + location_dest_id = self.stock_location + else: # outgoing + picking_type = self.outgoing_picking_type + location_id = self.stock_location + location_dest_id = self.customer_location + + # Create non-receipt picking + picking, move = self._create_picking(picking_type, product, location_id, location_dest_id) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check WITHOUT a lot assigned + quality_check = self._create_quality_check_for_move_line(move_line, picking_type, state='pass') + + # Verify no lot is assigned initially + self.assertFalse( + quality_check.lot_id, + "Quality check should not have a lot assigned initially" + ) + + # Create and assign a lot to the move line + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: Our custom lot propagation should NOT occur for non-receipt operations + # The quality check's lot_id should remain unset (or be set by standard Odoo behavior, not our module) + # We verify that our module's _update_quality_check_lot method was NOT triggered + # by checking that the quality check is correctly identified as non-receipt + self.assertFalse( + quality_check._is_receipt_operation(), + f"Quality check should be identified as non-receipt operation for {operation_type}" + ) + + # Our module should not have propagated the lot for non-receipt operations + # Note: Standard Odoo may or may not propagate the lot depending on its own logic, + # but our module should not interfere with non-receipt operations + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + operation_type=st.sampled_from(['internal', 'outgoing']), + ) + def test_property_non_receipt_operation_standard_behavior_preserved(self, lot_name_seed, operation_type): + """ + Property: For any non-receipt operation, the module should not interfere with + standard Odoo quality check behavior. + + This test verifies that our module's overrides correctly delegate to standard + behavior for non-receipt operations. + + Validates: Requirement 4.2 + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Select the appropriate picking type based on operation_type + if operation_type == 'internal': + picking_type = self.internal_picking_type + location_id = self.stock_location + location_dest_id = self.stock_location + else: # outgoing + picking_type = self.outgoing_picking_type + location_id = self.stock_location + location_dest_id = self.customer_location + + # Create non-receipt picking + picking, move = self._create_picking(picking_type, product, location_id, location_dest_id) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, picking_type, state='pass') + + # PROPERTY VERIFICATION: The module should correctly identify this as non-receipt + # and delegate to standard behavior + self.assertFalse( + quality_check._is_receipt_operation(), + f"Quality check should be identified as non-receipt for {operation_type}" + ) + + # Verify that _should_preserve_state returns False for non-receipt operations + self.assertFalse( + quality_check._should_preserve_state(), + f"State preservation should NOT be active for {operation_type} operations" + ) + + # Verify that _update_lot_from_lot_line delegates to parent for non-receipt operations + # This method should return the result of super() for non-receipt operations + # We can't easily test the return value, but we can verify the operation type detection works + self.assertEqual( + picking.picking_type_id.code, + operation_type, + f"Picking type code should be '{operation_type}'" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_non_receipt_internal_operation_detection(self, lot_name_seed): + """ + Property: For any internal transfer operation, the system should correctly + identify it as a non-receipt operation and not apply lot preservation behavior. + + Validates: Requirement 4.2 (internal transfers use standard behavior) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create internal transfer + picking, move = self._create_picking( + self.internal_picking_type, + product, + self.stock_location, + self.stock_location + ) + + # Verify this is an internal operation + self.assertEqual( + picking.picking_type_id.code, + 'internal', + "Picking should be an internal transfer" + ) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line( + move_line, + self.internal_picking_type, + state='pass' + ) + + # PROPERTY VERIFICATION: Internal operations should not trigger preservation behavior + self.assertFalse( + quality_check._is_receipt_operation(), + "Internal transfer should NOT be identified as receipt operation" + ) + + self.assertFalse( + quality_check._should_preserve_state(), + "State preservation should NOT be active for internal transfers" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_non_receipt_outgoing_operation_detection(self, lot_name_seed): + """ + Property: For any outgoing/delivery operation, the system should correctly + identify it as a non-receipt operation and not apply lot preservation behavior. + + Validates: Requirement 4.2 (delivery operations use standard behavior) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create outgoing/delivery operation + picking, move = self._create_picking( + self.outgoing_picking_type, + product, + self.stock_location, + self.customer_location + ) + + # Verify this is an outgoing operation + self.assertEqual( + picking.picking_type_id.code, + 'outgoing', + "Picking should be an outgoing/delivery operation" + ) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line( + move_line, + self.outgoing_picking_type, + state='pass' + ) + + # PROPERTY VERIFICATION: Outgoing operations should not trigger preservation behavior + self.assertFalse( + quality_check._is_receipt_operation(), + "Outgoing/delivery operation should NOT be identified as receipt operation" + ) + + self.assertFalse( + quality_check._should_preserve_state(), + "State preservation should NOT be active for outgoing/delivery operations" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + operation_type=st.sampled_from(['internal', 'outgoing']), + ) + def test_property_non_receipt_operation_type_consistency(self, lot_name_seed, operation_type): + """ + Property: For any non-receipt operation, the operation type detection should be + consistent across multiple checks and throughout the quality check lifecycle. + + Validates: Requirement 4.2 + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Select the appropriate picking type + if operation_type == 'internal': + picking_type = self.internal_picking_type + location_id = self.stock_location + location_dest_id = self.stock_location + else: # outgoing + picking_type = self.outgoing_picking_type + location_id = self.stock_location + location_dest_id = self.customer_location + + # Create non-receipt picking + picking, move = self._create_picking(picking_type, product, location_id, location_dest_id) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, picking_type, state='pass') + + # PROPERTY VERIFICATION: Operation type detection should be consistent + + # Check 1: Initial detection + is_receipt_1 = quality_check._is_receipt_operation() + self.assertFalse(is_receipt_1, f"Should not be receipt operation (check 1) for {operation_type}") + + # Check 2: After invalidating cache + quality_check.invalidate_recordset() + is_receipt_2 = quality_check._is_receipt_operation() + self.assertFalse(is_receipt_2, f"Should not be receipt operation (check 2) for {operation_type}") + + # Check 3: Consistency between checks + self.assertEqual( + is_receipt_1, + is_receipt_2, + "Operation type detection should be consistent across multiple checks" + ) + + # Check 4: _should_preserve_state should also be consistent + should_preserve_1 = quality_check._should_preserve_state() + quality_check.invalidate_recordset() + should_preserve_2 = quality_check._should_preserve_state() + + self.assertFalse(should_preserve_1, "Should not preserve state for non-receipt operations") + self.assertFalse(should_preserve_2, "Should not preserve state for non-receipt operations") + self.assertEqual( + should_preserve_1, + should_preserve_2, + "State preservation detection should be consistent" + ) diff --git a/tests/test_property_receipt_operation.py b/tests/test_property_receipt_operation.py new file mode 100644 index 0000000..596cc95 --- /dev/null +++ b/tests/test_property_receipt_operation.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings, assume + + +class TestReceiptOperationBehavior(TransactionCase): + """ + Property-based tests for receipt operation behavior activation. + + Feature: quality-check-lot-preserve, Property 5: Receipt operation behavior activation + + Property: For any quality check associated with a receipt operation (picking type code = 'incoming'), + the lot preservation behavior should be applied when lot numbers are assigned. + + Validates: Requirements 4.1 + """ + + def setUp(self): + super(TestReceiptOperationBehavior, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + + # Get or create receipt picking type + self.receipt_picking_type = self.env['stock.picking.type'].search([ + ('code', '=', 'incoming') + ], limit=1) + + if not self.receipt_picking_type: + self.receipt_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Receipts', + 'code': 'incoming', + 'sequence_code': 'IN', + 'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id, + }) + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', # 'consu' for consumable/storable product in Odoo 18 + 'tracking': 'lot', + }) + + def _create_receipt_picking(self, product): + """Helper to create a receipt picking with a move line for the given product""" + picking = self.StockPicking.create({ + 'picking_type_id': self.receipt_picking_type.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, move + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [self.receipt_picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, self.receipt_picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_receipt_operation_activates_preservation(self, lot_name_seed, quality_state): + """ + Property: For any quality check associated with a receipt operation (picking type code = 'incoming'), + the lot preservation behavior should be applied when lot numbers are assigned. + + This test verifies Requirement 4.1: + - When a quality check is associated with a receipt operation (incoming shipment), + the system applies the lot preservation behavior + + Test strategy: + 1. Create a receipt operation (picking_type_code = 'incoming') + 2. Create a quality check with a specific state + 3. Assign a lot number to the stock move line + 4. Verify that: + a) The quality check is correctly identified as a receipt operation + b) The lot preservation behavior is activated (_should_preserve_state returns True) + c) The quality check state is preserved (demonstrating the behavior is active) + d) The lot number is propagated to the quality check + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking (this is the key: picking_type_code = 'incoming') + picking, move = self._create_receipt_picking(product) + + # Verify this is indeed a receipt operation + self.assertEqual( + picking.picking_type_id.code, + 'incoming', + "Picking should be a receipt operation (incoming)" + ) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Verify the move line is identified as a receipt operation + self.assertTrue( + move_line._is_receipt_operation(), + "Move line should be identified as a receipt operation" + ) + + # Create quality check with the specified state + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # PROPERTY VERIFICATION PART 1: Receipt operation detection + # The quality check should be correctly identified as a receipt operation + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should be identified as a receipt operation" + ) + + # PROPERTY VERIFICATION PART 2: Preservation behavior activation + # The quality check should indicate that state should be preserved + self.assertTrue( + quality_check._should_preserve_state(), + "Quality check should indicate that state preservation is active for receipt operations" + ) + + # Record the initial quality check state + initial_state = quality_check.quality_state + + # Create and assign a lot number to the move line + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION PART 3: State preservation (demonstrating behavior is active) + # The quality check state should remain unchanged, proving the preservation behavior is active + self.assertEqual( + quality_check.quality_state, + initial_state, + f"Quality check state should remain '{initial_state}' after lot assignment, " + f"demonstrating that lot preservation behavior is active for receipt operations" + ) + + # PROPERTY VERIFICATION PART 4: Lot propagation (demonstrating behavior is active) + # The lot should be propagated to the quality check + self.assertEqual( + quality_check.lot_id.id, + lot.id, + f"Quality check should have lot {lot.name} assigned, " + f"demonstrating that lot propagation is active for receipt operations" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_receipt_operation_detection_robustness(self, lot_name_seed): + """ + Property: For any receipt operation, the system should correctly identify it + as a receipt operation through multiple detection paths. + + This test verifies that the receipt operation detection is robust and works + through different access paths (picking_id, move_id, etc.). + + Validates: Requirement 4.1 (operation type identification) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # PROPERTY VERIFICATION: Multiple detection paths should all identify this as a receipt operation + + # Path 1: Through picking_id.picking_type_id.code + self.assertEqual( + picking.picking_type_id.code, + 'incoming', + "Picking type code should be 'incoming'" + ) + + # Path 2: Through move_line._is_receipt_operation() + self.assertTrue( + move_line._is_receipt_operation(), + "Move line should be identified as receipt operation" + ) + + # Path 3: Through quality_check._is_receipt_operation() + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should be identified as receipt operation" + ) + + # Path 4: Through quality_check.picking_id.picking_type_id.code + if quality_check.picking_id: + self.assertEqual( + quality_check.picking_id.picking_type_id.code, + 'incoming', + "Quality check's picking should have 'incoming' code" + ) + + # Path 5: Through quality_check.move_line_id.picking_id.picking_type_id.code + if quality_check.move_line_id and quality_check.move_line_id.picking_id: + self.assertEqual( + quality_check.move_line_id.picking_id.picking_type_id.code, + 'incoming', + "Quality check's move line's picking should have 'incoming' code" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + initial_state=st.sampled_from(['pass', 'fail']), + ) + def test_property_receipt_operation_preserves_completed_checks(self, lot_name_seed, initial_state): + """ + Property: For any completed quality check (pass or fail) on a receipt operation, + the lot preservation behavior should prevent state reset when lot is assigned. + + This is a critical test that demonstrates the core value of the module: + quality checks completed before lot assignment should not be reset. + + Validates: Requirement 4.1 (receipt operation behavior application) + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line (no lot assigned yet) + move_line = picking.move_line_ids[0] + + # Create quality check with completed state (pass or fail) + quality_check = self._create_quality_check_for_move_line(move_line, state=initial_state) + + # Verify this is a receipt operation + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should be for a receipt operation" + ) + + # Verify the quality check is in the expected completed state + self.assertEqual( + quality_check.quality_state, + initial_state, + f"Quality check should be in '{initial_state}' state before lot assignment" + ) + + # Now assign a lot number (this is when standard Odoo would reset the state) + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: The completed quality check state should be preserved + # This demonstrates that the receipt operation behavior is active and working + self.assertEqual( + quality_check.quality_state, + initial_state, + f"Quality check should remain in '{initial_state}' state after lot assignment. " + f"This demonstrates that the lot preservation behavior is active for receipt operations." + ) + + # Additionally verify the lot was assigned + self.assertEqual( + quality_check.lot_id.id, + lot.id, + "Quality check should have the lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_receipt_operation_behavior_consistency(self, lot_name_seed): + """ + Property: For any receipt operation, the behavior should be consistent + across multiple lot assignments and updates. + + This test verifies that the receipt operation behavior remains active + throughout the lifecycle of the quality check. + + Validates: Requirement 4.1 + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check with 'pass' state + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # Verify receipt operation detection + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should be identified as receipt operation" + ) + + # First lot assignment + lot1 = self._create_lot(product, f'LOT-1-{lot_name_seed}') + move_line.write({'lot_id': lot1.id}) + quality_check.invalidate_recordset() + + # Verify state preserved after first assignment + self.assertEqual( + quality_check.quality_state, + 'pass', + "State should be preserved after first lot assignment" + ) + + # Verify receipt operation detection still works + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should still be identified as receipt operation after lot assignment" + ) + + # Second lot assignment (lot change) + lot2 = self._create_lot(product, f'LOT-2-{lot_name_seed}') + move_line.write({'lot_id': lot2.id}) + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: State should still be preserved after lot change + self.assertEqual( + quality_check.quality_state, + 'pass', + "State should be preserved after lot change, demonstrating consistent behavior" + ) + + # Verify the new lot is assigned + self.assertEqual( + quality_check.lot_id.id, + lot2.id, + "Quality check should have the new lot assigned" + ) + + # Verify receipt operation detection still works after multiple updates + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should still be identified as receipt operation after multiple updates" + ) diff --git a/tests/test_property_relationship_integrity.py b/tests/test_property_relationship_integrity.py new file mode 100644 index 0000000..90e585e --- /dev/null +++ b/tests/test_property_relationship_integrity.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings, assume + + +class TestRelationshipIntegrity(TransactionCase): + """ + Property-based tests for relationship integrity during lot number updates. + + Feature: quality-check-lot-preserve, Property 4: Relationship integrity during updates + + Property: For any quality check linked to a stock move line, when lot number updates + occur, the link between the quality check and stock move line should remain intact + (move_line_id unchanged). + + Validates: Requirements 3.3 + """ + + def setUp(self): + super(TestRelationshipIntegrity, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + + # Get or create receipt picking type + self.receipt_picking_type = self.env['stock.picking.type'].search([ + ('code', '=', 'incoming') + ], limit=1) + + if not self.receipt_picking_type: + self.receipt_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Receipts', + 'code': 'incoming', + 'sequence_code': 'IN', + 'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id, + }) + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', + 'tracking': 'lot', + }) + + def _create_receipt_picking(self, product): + """Helper to create a receipt picking with a move line for the given product""" + picking = self.StockPicking.create({ + 'picking_type_id': self.receipt_picking_type.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, move + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [self.receipt_picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, self.receipt_picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_relationship_integrity_on_initial_lot_assignment(self, lot_name_seed, quality_state): + """ + Property: For any quality check linked to a stock move line, when a lot number + is initially assigned to the move line, the link between the quality check and + stock move line should remain intact. + + This test verifies Requirement 3.3: + - When a lot number update occurs, the system maintains the link between + the quality check and the stock move line + + Test strategy: + 1. Create a receipt with a quality check linked to a move line + 2. Record the initial move_line_id relationship + 3. Assign a lot number to the move line + 4. Verify the move_line_id relationship remains unchanged + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check linked to the move line + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Record the initial move_line_id relationship + initial_move_line_id = quality_check.move_line_id.id + + # Verify the relationship is established + self.assertEqual( + initial_move_line_id, + move_line.id, + "Quality check should be linked to the move line initially" + ) + + # Create and assign a lot number to the move line + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # The move_line_id relationship should remain unchanged after lot assignment + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + f"Quality check should still be linked to move line {initial_move_line_id} " + f"after lot assignment" + ) + + # Additionally verify the lot was actually assigned + self.assertEqual( + quality_check.lot_id.id, + lot.id, + "Quality check should have the lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + initial_lot_seed=st.integers(min_value=1, max_value=1000000), + updated_lot_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_relationship_integrity_on_lot_update(self, initial_lot_seed, updated_lot_seed, quality_state): + """ + Property: For any quality check linked to a stock move line with an assigned lot, + when the lot number is changed on the move line, the link between the quality check + and stock move line should remain intact. + + This test verifies that relationship integrity is maintained even when lot numbers + are updated (not just initially assigned). + + Validates: Requirement 3.3 + """ + # Ensure the two lot numbers are different + assume(initial_lot_seed != updated_lot_seed) + + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {initial_lot_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check linked to the move line + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Record the initial move_line_id relationship + initial_move_line_id = quality_check.move_line_id.id + + # Assign initial lot number + initial_lot = self._create_lot(product, f'LOT-INITIAL-{initial_lot_seed}') + move_line.write({'lot_id': initial_lot.id}) + + # Refresh and verify relationship is still intact after first assignment + quality_check.invalidate_recordset() + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + "Relationship should be intact after initial lot assignment" + ) + + # Create a new lot and update the move line + updated_lot = self._create_lot(product, f'LOT-UPDATED-{updated_lot_seed}') + move_line.write({'lot_id': updated_lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # The move_line_id relationship should remain unchanged after lot update + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + f"Quality check should still be linked to move line {initial_move_line_id} " + f"after lot update" + ) + + # Verify the lot was actually updated + self.assertEqual( + quality_check.lot_id.id, + updated_lot.id, + "Quality check should have the updated lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_seed_1=st.integers(min_value=1, max_value=1000000), + lot_seed_2=st.integers(min_value=1, max_value=1000000), + lot_seed_3=st.integers(min_value=1, max_value=1000000), + ) + def test_property_relationship_integrity_multiple_updates(self, lot_seed_1, lot_seed_2, lot_seed_3): + """ + Property: For any quality check linked to a stock move line, when multiple + consecutive lot number updates occur, the link between the quality check and + stock move line should remain intact throughout all updates. + + This test verifies that relationship integrity is maintained even with + multiple sequential lot updates. + + Validates: Requirement 3.3 + """ + # Ensure all lot numbers are different + assume(lot_seed_1 != lot_seed_2) + assume(lot_seed_2 != lot_seed_3) + assume(lot_seed_1 != lot_seed_3) + + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_seed_1}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check linked to the move line + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # Record the initial move_line_id relationship + initial_move_line_id = quality_check.move_line_id.id + + # Create three different lots + lot_1 = self._create_lot(product, f'LOT-1-{lot_seed_1}') + lot_2 = self._create_lot(product, f'LOT-2-{lot_seed_2}') + lot_3 = self._create_lot(product, f'LOT-3-{lot_seed_3}') + + # First update: assign lot_1 + move_line.write({'lot_id': lot_1.id}) + quality_check.invalidate_recordset() + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + "Relationship should be intact after first lot assignment" + ) + + # Second update: change to lot_2 + move_line.write({'lot_id': lot_2.id}) + quality_check.invalidate_recordset() + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + "Relationship should be intact after second lot update" + ) + + # Third update: change to lot_3 + move_line.write({'lot_id': lot_3.id}) + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # The move_line_id relationship should remain unchanged after all updates + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + f"Quality check should still be linked to move line {initial_move_line_id} " + f"after multiple lot updates" + ) + + # Verify the final lot was assigned + self.assertEqual( + quality_check.lot_id.id, + lot_3.id, + "Quality check should have the final lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + num_updates=st.integers(min_value=1, max_value=5), + ) + def test_property_relationship_integrity_variable_updates(self, lot_name_seed, num_updates): + """ + Property: For any quality check linked to a stock move line, regardless of + the number of lot updates, the move_line_id relationship should never change. + + This test uses a variable number of updates to verify relationship integrity + across different update patterns. + + Validates: Requirement 3.3 + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check linked to the move line + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # Record the initial move_line_id relationship + initial_move_line_id = quality_check.move_line_id.id + + # Perform multiple lot updates + for i in range(num_updates): + lot = self._create_lot(product, f'LOT-{lot_name_seed}-{i}') + move_line.write({'lot_id': lot.id}) + + # Refresh and verify relationship after each update + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: Relationship should be intact after each update + self.assertEqual( + quality_check.move_line_id.id, + initial_move_line_id, + f"Quality check should still be linked to move line {initial_move_line_id} " + f"after update {i+1} of {num_updates}" + ) + + # Verify the lot was updated + self.assertEqual( + quality_check.lot_id.id, + lot.id, + f"Quality check should have lot {lot.name} assigned after update {i+1}" + ) diff --git a/tests/test_property_state_preservation.py b/tests/test_property_state_preservation.py new file mode 100644 index 0000000..aea436c --- /dev/null +++ b/tests/test_property_state_preservation.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from hypothesis import given, strategies as st, settings, assume + + +class TestStatePreservation(TransactionCase): + """ + Property-based tests for quality check state preservation during lot assignment. + + Feature: quality-check-lot-preserve, Property 1: State preservation during lot assignment + + Property: For any quality check on a receipt operation in any state (Pass, Fail, + In Progress, Todo), when a lot number is assigned to the related stock move line, + the quality check state should remain unchanged. + + Validates: Requirements 1.1, 1.2 + """ + + def setUp(self): + super(TestStatePreservation, self).setUp() + + # Get required models + self.StockMoveLine = self.env['stock.move.line'] + self.QualityCheck = self.env['quality.check'] + self.StockPicking = self.env['stock.picking'] + self.StockMove = self.env['stock.move'] + self.ProductProduct = self.env['product.product'] + self.StockLocation = self.env['stock.location'] + self.StockPickingType = self.env['stock.picking.type'] + self.StockLot = self.env['stock.lot'] + self.QualityPoint = self.env['quality.point'] + + # Get or create locations + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + + # Get or create receipt picking type + self.receipt_picking_type = self.env['stock.picking.type'].search([ + ('code', '=', 'incoming') + ], limit=1) + + if not self.receipt_picking_type: + self.receipt_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Receipts', + 'code': 'incoming', + 'sequence_code': 'IN', + 'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id, + }) + + def _create_product_with_tracking(self, name): + """Helper to create a product with lot tracking enabled""" + return self.ProductProduct.create({ + 'name': name, + 'type': 'consu', # 'consu' for consumable/storable product in Odoo 18 + 'tracking': 'lot', + }) + + def _create_receipt_picking(self, product): + """Helper to create a receipt picking with a move line for the given product""" + picking = self.StockPicking.create({ + 'picking_type_id': self.receipt_picking_type.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + move = self.StockMove.create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10.0, + 'product_uom': product.uom_id.id, + 'picking_id': picking.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + }) + + # Confirm the picking to create move lines + picking.action_confirm() + + return picking, move + + def _create_lot(self, product, lot_name): + """Helper to create a lot for a product""" + return self.StockLot.create({ + 'name': lot_name, + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + + def _create_quality_check_for_move_line(self, move_line, state='none'): + """Helper to create a quality check linked to a move line""" + # Get or create a quality team + quality_team = self.env['quality.alert.team'].search([], limit=1) + if not quality_team: + quality_team = self.env['quality.alert.team'].create({ + 'name': 'Test Quality Team', + }) + + # Create a quality point for the product if needed + quality_point = self.QualityPoint.search([ + ('product_ids', 'in', [move_line.product_id.id]), + ('picking_type_ids', 'in', [self.receipt_picking_type.id]), + ], limit=1) + + if not quality_point: + # Get a test type - use passfail which should always exist + test_type = self.env.ref('quality_control.test_type_passfail', raise_if_not_found=False) + if not test_type: + # If no test type exists, create a minimal one + test_type = self.env['quality.point.test_type'].create({ + 'name': 'Pass/Fail Test', + 'technical_name': 'passfail', + }) + + quality_point = self.QualityPoint.create({ + 'title': f'Quality Check for {move_line.product_id.name}', + 'product_ids': [(4, move_line.product_id.id)], + 'picking_type_ids': [(4, self.receipt_picking_type.id)], + 'test_type_id': test_type.id, + 'team_id': quality_team.id, + }) + + return self.QualityCheck.create({ + 'product_id': move_line.product_id.id, + 'picking_id': move_line.picking_id.id, + 'move_line_id': move_line.id, + 'quality_state': state, + 'point_id': quality_point.id, + 'team_id': quality_team.id, + }) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_state_preservation_on_lot_assignment(self, lot_name_seed, quality_state): + """ + Property: For any quality check on a receipt operation in any state, + when a lot number is assigned to the related stock move line, + the quality check state should remain unchanged. + + This test verifies Requirements 1.1 and 1.2: + - 1.1: Quality check state is preserved regardless of subsequent lot number assignment + - 1.2: Quality check maintains its state (Pass, Fail, In Progress, Todo) when lot is assigned + + Test strategy: + 1. Create a receipt operation with a quality check in a specific state + 2. Record the initial state + 3. Assign a lot number to the stock move line + 4. Verify the quality check state remains unchanged + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check with the specified state + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Record the initial quality check state + initial_state = quality_check.quality_state + + # Verify the quality check is for a receipt operation + self.assertTrue( + quality_check._is_receipt_operation(), + "Quality check should be identified as a receipt operation" + ) + + # Create and assign a lot number to the move line + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + + # Assign the lot to the move line (this is the critical action) + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database to get latest state + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # The quality check state should remain unchanged after lot assignment + self.assertEqual( + quality_check.quality_state, + initial_state, + f"Quality check state should remain '{initial_state}' after lot assignment, " + f"but found '{quality_check.quality_state}'" + ) + + # Additionally verify the lot was actually assigned to the quality check + self.assertEqual( + quality_check.lot_id.id, + lot.id, + f"Quality check should have lot {lot.name} assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + ) + def test_property_pass_state_preservation(self, lot_name_seed): + """ + Specific property test for "Pass" state preservation. + + Property: For any quality check marked as "Pass" on a receipt operation, + when a lot number is assigned, the quality check should remain in "Pass" state. + + This test specifically validates Requirement 1.3: + - When a quality check is marked as "Pass" before lot assignment, + the system keeps the quality check as "Pass" after lot number assignment + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check with "pass" state + quality_check = self._create_quality_check_for_move_line(move_line, state='pass') + + # Verify initial state is "pass" + self.assertEqual( + quality_check.quality_state, + 'pass', + "Quality check should initially be in 'pass' state" + ) + + # Create and assign a lot number + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: Quality check should still be in "pass" state + self.assertEqual( + quality_check.quality_state, + 'pass', + "Quality check should remain in 'pass' state after lot assignment" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_name_seed=st.integers(min_value=1, max_value=1000000), + initial_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_state_preservation_without_initial_lot(self, lot_name_seed, initial_state): + """ + Property: For any quality check created without a lot number, + when a lot number is later assigned to the move line, + the quality check state should be preserved. + + This tests the common workflow where quality checks are performed + before lot numbers are generated or assigned. + """ + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_name_seed}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line (no lot assigned yet) + move_line = picking.move_line_ids[0] + + # Verify no lot is assigned initially + self.assertFalse(move_line.lot_id, "Move line should not have a lot initially") + + # Create quality check with the specified state (no lot assigned) + quality_check = self._create_quality_check_for_move_line(move_line, state=initial_state) + + # Verify no lot is assigned to quality check initially + self.assertFalse(quality_check.lot_id, "Quality check should not have a lot initially") + + # Record the initial state + recorded_state = quality_check.quality_state + + # Now assign a lot number (simulating lot generation after quality check) + lot = self._create_lot(product, f'LOT-{lot_name_seed}') + move_line.write({'lot_id': lot.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # 1. State should be preserved + self.assertEqual( + quality_check.quality_state, + recorded_state, + f"Quality check state should remain '{recorded_state}' after lot assignment" + ) + + # 2. Lot should be assigned to quality check + self.assertEqual( + quality_check.lot_id.id, + lot.id, + "Quality check should have the lot assigned" + ) + + @settings(max_examples=100, deadline=None) + @given( + lot_seed_1=st.integers(min_value=1, max_value=1000000), + lot_seed_2=st.integers(min_value=1, max_value=1000000), + quality_state=st.sampled_from(['none', 'pass', 'fail']), + ) + def test_property_state_preservation_on_lot_change(self, lot_seed_1, lot_seed_2, quality_state): + """ + Property: For any quality check with an assigned lot number, + when the lot number is changed on the move line, + the quality check state should remain unchanged. + + This verifies that state preservation works not just for initial assignment, + but also for lot number changes. + """ + # Ensure the two lot numbers are different + assume(lot_seed_1 != lot_seed_2) + + # Create a product with lot tracking + product = self._create_product_with_tracking(f'Test Product {lot_seed_1}') + + # Create receipt picking + picking, move = self._create_receipt_picking(product) + + # Get the move line + move_line = picking.move_line_ids[0] + + # Create quality check with the specified state + quality_check = self._create_quality_check_for_move_line(move_line, state=quality_state) + + # Assign initial lot number + lot_1 = self._create_lot(product, f'LOT-1-{lot_seed_1}') + move_line.write({'lot_id': lot_1.id}) + + # Refresh and record the state after first lot assignment + quality_check.invalidate_recordset() + state_after_first_assignment = quality_check.quality_state + + # Verify state is still the original state + self.assertEqual( + state_after_first_assignment, + quality_state, + "State should be preserved after first lot assignment" + ) + + # Now change the lot number + lot_2 = self._create_lot(product, f'LOT-2-{lot_seed_2}') + move_line.write({'lot_id': lot_2.id}) + + # Refresh quality check from database + quality_check.invalidate_recordset() + + # PROPERTY VERIFICATION: + # State should still be the original state after lot change + self.assertEqual( + quality_check.quality_state, + quality_state, + f"Quality check state should remain '{quality_state}' after lot change" + ) + + # Verify the lot was actually changed + self.assertEqual( + quality_check.lot_id.id, + lot_2.id, + "Quality check should have the new lot assigned" + )