first commit

This commit is contained in:
admin.suherdy 2025-11-27 10:00:38 +07:00
commit 086eb52c83
32 changed files with 3523 additions and 0 deletions

153
README.md Normal file
View File

@ -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

3
__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

45
__manifest__.py Normal file
View File

@ -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,
}

Binary file not shown.

Binary file not shown.

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import stock_move_line
from . import quality_check

Binary file not shown.

Binary file not shown.

Binary file not shown.

197
models/quality_check.py Normal file
View File

@ -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'])

138
models/stock_move_line.py Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

11
tests/__init__.py Normal file
View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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")

399
tests/test_edge_cases.py Normal file
View File

@ -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")

305
tests/test_integration.py Normal file
View File

@ -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")

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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}"
)

View File

@ -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"
)