first commit

This commit is contained in:
Suherdy Yacob 2026-02-11 10:28:28 +07:00
commit 4a44ffa1fe
11 changed files with 538 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*$py.class
.DS_Store
*.swp
*.bak

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# Quality Patch
This module addresses specific issues encountered in Odoo 19 Quality Management.
## Fixed Issues
1. **MRP Quality Check AttributeError**: Fixes `AttributeError: 'mrp.production' object has no attribute 'lot_producing_id'` by adding a **compatibility layer** to the `mrp.production` model. It provides a computed `lot_producing_id` field (alias to `lot_producing_ids[:1]`), satisfying all Odoo 18-style callers.
2. **Partial Failure / Quantity Discrepancy & Reversion**: Fixes issues where partial failure quantities (e.g., 20,000) reverted to full production quantities (e.g., 220,000) after MO completion.
- **Persistent Input Storage**: Introduces a new field `qty_failed_manual` that permanently stores your wizard input.
- **Recompute Protection**: The system now strictly prioritizes this manual value over any automatic recomputations, ensuring reports remain accurate.
- **Popup Capture**: Overrides the wizard's confirmation logic to ensure the specific quantity entered in the failure popup is captured correctly.
3. **MO Closing Error (Price Difference)**: Fixes a `ValueError` during MO closing when a product has multiple split moves (common after partial quality failures). Overrides `_cal_price` on `stock.move` to handle multiple finished moves.
6. **"Control per Quantity" on MOs**: Removes the standard restriction that prevented selecting "Control per Quantity" for Manufacturing Operations, allowing for the partial failure workflow to be used.
## Installation
1. Ensure this module is in your Odoo `addons` path.
2. Update the Apps List in Odoo.
3. Install or **Upgrade** `quality_patch`.
## Technical Details
- **Model Compatibility**: Extends `mrp.production` to provide `lot_producing_id`.
- **Inheritance**: Extends `quality.check.wizard` (Transient Model) and `quality.check`.
- **Views**: Extends `quality_control.quality_check_wizard_form_failure` via XPath.
- **Dependencies**: `quality_mrp`, `quality_control`, `mrp`.
---
*Created by Antigravity AI Assistant*

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import models
from . import wizard

20
__manifest__.py Normal file
View File

@ -0,0 +1,20 @@
{
'name': 'Quality Patch',
'version': '1.0',
'category': 'Quality Management',
'summary': 'Patch for Odoo 19 Quality Check issues',
'description': """
Fixes:
1. lot_producing_id AttributeError in quality_mrp
2. Fail location visibility in quality_control
""",
'author': 'Antigravity',
'depends': ['quality_mrp', 'quality_control', 'mrp'],
'data': [
'views/quality_check_views.xml',
'wizard/quality_check_wizard_views.xml',
],
'installable': True,
'application': False,
'license': 'OEEL-1',
}

2
models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import mrp_production
from . import quality

70
models/mrp_production.py Normal file
View File

@ -0,0 +1,70 @@
from odoo import api, fields, models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
lot_producing_id = fields.Many2one(
'stock.lot',
compute='_compute_lot_producing_id',
string='Producing Lot (v18 Compatible)',
help="Compatibility field for Odoo 18 modules using lot_producing_id"
)
@api.depends('lot_producing_ids')
def _compute_lot_producing_id(self):
for production in self:
production.lot_producing_id = production.lot_producing_ids[:1]
def _cal_price(self, consumed_moves):
""" Patch to handle multiple finished moves for the same product.
Odoo's mrp_account._cal_price uses finished_move.ensure_one(), which crashes
if quality checks split the finished move into multiple lines (e.g. Pass/Fail).
"""
# We only apply this logic if there are multiple finished moves for the MO product
finished_moves = self.move_finished_ids.filtered(
lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel') and x.quantity > 0)
if len(finished_moves) > 1:
# If we have multiple moves, we need to bypass the strict ensure_one() in the parent
# while still ensuring the cost is calculated correctly.
# We call super for EACH move separately by temporarily filtering move_finished_ids
# but that's complex since _cal_price is usually called on the whole MO.
# Alternative: Since we can't easily change how the super() code reaches finished_move,
# we handle the multi-move case here and return True to bypass the crash in super().
# (Note: Odoo's mrp_account._cal_price starts with super()._cal_price(consumed_moves))
# The super() call in mrp_account._cal_price is:
# res = super()._cal_price(consumed_moves)
# which usually just calculates the price on the move if it's NOT fifo/avco.
# Let's try to mimic the logic but for multiple moves.
work_center_cost = 0
for work_order in self.workorder_ids:
work_center_cost += work_order._cal_cost()
total_quantity = sum(m.product_uom._compute_quantity(m.quantity, m.product_id.uom_id) for m in finished_moves)
if total_quantity == 0:
return super()._cal_price(consumed_moves)
total_cost = sum(move.value for move in consumed_moves) + work_center_cost + (self.extra_cost * total_quantity)
byproduct_moves = self.move_byproduct_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.quantity > 0)
byproduct_cost_share = sum(byproduct_moves.mapped('cost_share'))
# Update price_unit for each finished move
shared_price_unit = (total_cost * (1 - byproduct_cost_share / 100)) / total_quantity
for move in finished_moves:
move.price_unit = shared_price_unit
# Also handle byproducts if any
for byproduct in byproduct_moves:
if byproduct.cost_share > 0 and byproduct.product_id.cost_method in ('fifo', 'average'):
byproduct_qty = byproduct.product_uom._compute_quantity(byproduct.quantity, byproduct.product_id.uom_id)
if byproduct_qty > 0:
byproduct.price_unit = (total_cost * byproduct.cost_share / 100) / byproduct_qty
return True
return super()._cal_price(consumed_moves)

305
models/quality.py Normal file
View File

@ -0,0 +1,305 @@
from odoo import api, fields, models
class QualityCheck(models.Model):
_inherit = 'quality.check'
qty_failed_manual = fields.Float('Manual Failed Quantity', copy=False)
@api.depends("production_id.qty_producing", "move_line_id", "move_line_id.quantity", "qty_tested", "qty_failed_manual", "quality_state")
def _compute_qty_line(self):
""" Patch to prevent 0 qty_line and respect partial quantities for MOs.
If we are on an MO and have a tested quantity, that quantity represents the
check better than the full MO or full move line quantity.
"""
# We process the checks; for those that don't match our specific criteria, we'll call super (or handle defaults)
# However, calling super() with a partial set is tricky if the logic differs significantly.
# quality_mrp's _compute_qty_line sets qty_line = production_id.qty_producing.
# Let's iterate and set values.
for qc in self:
if qc.quality_state == 'fail' and qc.qty_failed_manual > 0:
qc.qty_line = qc.qty_failed_manual
elif qc.move_line_id:
# If we have a move line, it usually defines the quantity
qc.qty_line = qc.move_line_id.quantity
elif qc.qty_tested > 0:
# If we don't have a move line yet (before split), qty_tested is our best guess
qc.qty_line = qc.qty_tested
elif qc.production_id and qc.production_id.qty_producing > 0:
qc.qty_line = qc.production_id.qty_producing
else:
# Fallback to standard logic if we can't determine better
# But since we can't easily call super() for a single record without potential recursion or complexity if mixed
# We replicate the base defaults: 1.0 or whatever super does.
# The safest way is to let super calculate first, then override.
pass
# Call super first to populate defaults
super()._compute_qty_line()
# Now override with our specific logic
for qc in self:
if qc.quality_state == 'fail' and qc.qty_failed_manual > 0:
qc.qty_line = qc.qty_failed_manual
# We preserve the other logical improvements we wanted (like using move_line_id.quantity if available)
# But primarily we care about the failed manual quantity for now.
elif qc.move_line_id:
qc.qty_line = qc.move_line_id.quantity
@api.depends('qty_line', 'quality_state')
def _compute_qty_passed(self):
for qc in self:
if qc.quality_state == 'pass':
qc.qty_passed = qc.qty_line
else:
qc.qty_passed = 0
@api.depends('qty_line', 'quality_state', 'qty_failed_manual')
def _compute_qty_failed(self):
""" Patch to allow manual override of failed quantity from wizard context """
import logging
_logger = logging.getLogger(__name__)
for qc in self:
if qc.qty_failed_manual > 0:
_logger.info(f"DEBUG: Using qty_failed_manual {qc.qty_failed_manual} for check {qc.id}")
qc.qty_failed = qc.qty_failed_manual
elif qc.quality_state == 'fail':
# Priority 2: Context override (legacy/fallback)
context_qty = self.env.context.get('quality_check_qty_failed')
if context_qty is not None:
_logger.info(f"DEBUG: Using context qty {context_qty} for check {qc.id}")
qc.qty_failed = context_qty
# Priority 3: MO checks likely use the full qty_line of the split move line
else:
_logger.info(f"DEBUG: Using qty_line {qc.qty_line} for check {qc.id}")
qc.qty_failed = qc.qty_line
else:
qc.qty_failed = 0
def _can_move_line_to_failure_location(self):
self.ensure_one()
# Ported from Odoo 18 quality_mrp/models/quality.py
# We also allow if qty_failed_manual is set, assuming the user implies a quantity check
if self.production_id and self.quality_state == 'fail' and (self.point_id.measure_on == 'move_line' or self.qty_failed_manual > 0):
mo = self.production_id
move = mo.move_finished_ids.filtered(lambda m: m.product_id == mo.product_id)
# Ensure we have a move line if not already set (rare but possible in some flows)
if not self.move_line_id and move:
self.move_line_id = move.move_line_ids[:1]
return True
return False
def _move_line_to_failure_location(self, failure_location_id, failed_qty=None):
""" Ported from Odoo 18 quality_control/models/quality.py
This handles the splitting of the stock.move.line and stock.move
"""
import logging
_logger = logging.getLogger(__name__)
for check in self:
_logger.info(f"DEBUG: Processing check {check.id} for failure split")
if not check._can_move_line_to_failure_location():
_logger.info(f"DEBUG: Check {check.id} cannot move to failure location")
continue
failed_qty = failed_qty or check.qty_failed_manual or check.move_line_id.quantity
move_line = check.move_line_id
move = move_line.move_id
mo = check.production_id
dest_location = failure_location_id or move_line.location_dest_id.id
_logger.info(f"DEBUG: Split stats - Check: {check.id}, Failed Qty: {failed_qty}, Original Qty: {move_line.quantity}, Dest Loc: {dest_location}")
# Switch to mts if the failure location is not the same as the final destination
if move_line.move_id.move_dest_ids and \
move_line.move_id.move_dest_ids.location_id.id != dest_location:
move_line.move_id.move_dest_ids._break_mto_link(move_line.move_id)
if failed_qty == move_line.quantity:
_logger.info(f"DEBUG: Full failure for check {check.id}")
move_line.location_dest_id = dest_location
if move_line.quantity == move.quantity:
move.location_dest_id = dest_location
else:
move.with_context(do_not_unreserve=True).product_uom_qty -= failed_qty
move.copy({
'location_dest_id': dest_location,
'move_orig_ids': move.move_orig_ids.ids,
'product_uom_qty': failed_qty,
'state': 'assigned',
'move_line_ids': [(4, move_line.id)],
})
check.failure_location_id = dest_location
return
# Partial Failure Case (Split)
_logger.info(f"DEBUG: Partial failure split for check {check.id}")
# Determine Lot ID with fallback to MO
lot_id = move_line.lot_id.id
if not lot_id and move.production_id:
# Check for lot_producing_ids (M2M) or lot_producing_id (M2O)
if hasattr(move.production_id, 'lot_producing_ids') and move.production_id.lot_producing_ids:
lot_id = move.production_id.lot_producing_ids[0].id
_logger.info(f"DEBUG: Found lot {lot_id} from MO lot_producing_ids")
elif hasattr(move.production_id, 'lot_producing_id') and move.production_id.lot_producing_id:
lot_id = move.production_id.lot_producing_id.id
_logger.info(f"DEBUG: Found lot {lot_id} from MO lot_producing_id")
if not lot_id:
_logger.warning(f"DEBUG: No Lot ID found for Move {move.id} / Line {move_line.id}")
# Common line values preparation (template)
line_vals_template = {
'product_id': move.product_id.id,
'product_uom_id': move.product_uom.id,
'location_dest_id': dest_location,
'quantity': failed_qty,
'lot_id': lot_id,
'company_id': move.company_id.id,
}
if move.state == 'done':
_logger.info(f"DEBUG: Move {move.id} is DONE. Creating new move from Stock -> Reject")
source_location_id = move.location_dest_id.id
new_failed_move = move.copy({
'location_id': source_location_id,
'location_dest_id': dest_location,
'move_orig_ids': move.move_orig_ids.ids,
'product_uom_qty': failed_qty,
'state': 'draft',
'move_line_ids': [],
})
new_failed_move._action_confirm()
# Manual line creation for Done case
line_vals = line_vals_template.copy()
line_vals.update({
'move_id': new_failed_move.id,
'location_id': source_location_id,
})
failed_move_line = self.env['stock.move.line'].create(line_vals)
_logger.info(f"DEBUG: Manually created line {failed_move_line.id} with lot {lot_id}")
new_failed_move.picked = True
new_failed_move._action_done()
else:
_logger.info(f"DEBUG: Move {move.id} is ASSIGNED/CONFIRMED. Using _split")
# _split returns a list of values (dicts)
vals_list = move._split(failed_qty)
_logger.info(f"DEBUG: _split returned vals count: {len(vals_list)}")
if vals_list:
new_failed_move = self.env['stock.move'].create(vals_list)
_logger.info(f"DEBUG: Created split move {new_failed_move.id}. State: {new_failed_move.state}")
else:
_logger.warning("DEBUG: _split returned empty! Fallback to copy.")
new_failed_move = move.copy({
'location_dest_id': dest_location,
'product_uom_qty': failed_qty,
'state': 'draft',
'move_line_ids': [],
})
_logger.info(f"DEBUG: Fallback created move {new_failed_move.id}")
# Configure and Confirm
new_failed_move.location_dest_id = dest_location
new_failed_move._action_confirm()
_logger.info(f"DEBUG: Confirmed new move {new_failed_move.id}. State: {new_failed_move.state}")
# Manual Line Creation/Update (Force Lot)
line_vals = line_vals_template.copy()
line_vals.update({
'move_id': new_failed_move.id,
'location_id': new_failed_move.location_id.id,
})
_logger.info(f"DEBUG: Line Vals: {line_vals}")
if new_failed_move.move_line_ids:
# Update existing auto-created line
target_line = new_failed_move.move_line_ids[0]
target_line.write(line_vals)
failed_move_line = target_line
_logger.info(f"DEBUG: Updated existing line {target_line.id} with Lot {lot_id}")
else:
# Create new line
failed_move_line = self.env['stock.move.line'].create(line_vals)
_logger.info(f"DEBUG: Manually created line {failed_move_line.id} with Lot {lot_id}")
# Reduce original reservation to free up Lot
# Important: Do this AFTER Confirming new move but BEFORE Done?
# Or maybe before creating new line?
# Actually, if we reduce quantity on original line, it releases reservation.
if move_line.quantity >= failed_qty:
_logger.info(f"DEBUG: Reducing original line {move_line.id} qty from {move_line.quantity} by {failed_qty}")
move_line.quantity -= failed_qty
new_failed_move.picked = True
try:
new_failed_move._action_done()
_logger.info(f"DEBUG: Forced new move {new_failed_move.id} to DONE state.")
except Exception as e:
_logger.error(f"DEBUG: Failed to force Done on {new_failed_move.id}. Error: {e}")
# Log line details for debugging
for ml in new_failed_move.move_line_ids:
_logger.info(f"DEBUG: Line {ml.id}: Lot={ml.lot_id.name}, Qty={ml.quantity}, Loc={ml.location_id.name}->{ml.location_dest_id.name}")
raise e
_logger.info(f"DEBUG: Forced new move {new_failed_move.id} to DONE state.")
# Create new check for the PASSED quantity (original move line)
new_check_vals = check.copy_data()[0]
new_check_vals.update({
'move_line_id': move_line.id,
'qty_tested': 0,
'quality_state': 'pass',
})
new_check = self.create(new_check_vals)
_logger.info(f"DEBUG: Created new_check (Passed) {new_check.id} linked to original move_line {move_line.id}")
new_check.do_pass()
# Update CURRENT check to point to the FAILED move line
check.move_line_id = failed_move_line
check.failure_location_id = dest_location
check.qty_failed = failed_qty
_logger.info(f"DEBUG: Updated check {check.id} (Failed) to failed_move_line {failed_move_line.id} and loc {dest_location}")
# Additional Odoo 18 quality_mrp logic
if check.production_id and check.move_line_id:
check.move_line_id.move_id.picked = False
def _move_to_failure_location(self, failure_location_id, failed_qty=None):
""" Override/Patch to direct to our new logic """
if any(c._can_move_line_to_failure_location() for c in self):
self._move_line_to_failure_location(failure_location_id, failed_qty)
else:
super()._move_to_failure_location(failure_location_id, failed_qty)
def write(self, vals):
import logging
_logger = logging.getLogger(__name__)
if 'quality_state' in vals:
_logger.info(f"DEBUG: Quality Check {self.ids} state changed to {vals['quality_state']}")
if 'qty_failed_manual' in vals:
_logger.info(f"DEBUG: Quality Check {self.ids} qty_failed_manual updated to {vals['qty_failed_manual']}")
return super().write(vals)
def unlink(self):
import logging
_logger = logging.getLogger(__name__)
for qc in self:
_logger.info(f"DEBUG: Unlinking Quality Check {qc.id} - State: {qc.quality_state}, MO: {qc.production_id.id}, MoveLine: {qc.move_line_id.id}")
return super().unlink()
class QualityPoint(models.Model):
_inherit = "quality.point"
def _check_measure_on(self):
# Override to allow "Item" (Control per Quantity) for Manufacturing Operations
# The original constraint in quality_mrp raises UserError if measure_on == 'move_line'
# and operation is manufacturing. We want to allow this.
return

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="quality_check_view_form_inherit_debug" model="ir.ui.view">
<field name="name">quality.check.view.form.inherit.debug</field>
<field name="model">quality.check</field>
<field name="inherit_id" ref="quality_control.quality_check_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='failure_location_id']" position="before">
<field name="qty_failed_manual" readonly="0" invisible="quality_state != 'fail'"/>
</xpath>
</field>
</record>
</odoo>

1
wizard/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import quality_check_wizard

View File

@ -0,0 +1,70 @@
from odoo import api, models
class QualityCheckWizard(models.TransientModel):
_inherit = 'quality.check.wizard'
# The AttributeError fix is now handled by the compatibility field in models/mrp_production.py
@api.onchange('qty_failed')
def onchange_qty_failed(self):
""" Disable enterprise quality_mrp behavior that resets qty_failed to qty_line """
pass
def do_fail(self):
""" Ensure move_line_id is populated for MO checks with Quantity control.
This is required for quality_control's _move_to_failure_location to split
the stock move correctly.
"""
check = self.current_check_id
# We mimic the enterprise/quality_mrp behavior but in our patch
if check.production_id and check.point_id.measure_on == 'move_line' and not check.move_line_id:
# Try to find a finished move line for this product
move_line = check.production_id.finished_move_line_ids.filtered(
lambda ml: ml.product_id == (check.product_id or self.product_id)
)[:1]
if move_line:
check.move_line_id = move_line
# Also set lot if available
if not check.lot_line_id and check.production_id.lot_producing_ids:
check.lot_line_id = check.production_id.lot_producing_ids[0]
return super().do_fail()
def confirm_fail(self):
""" Override to capture the quantity from the failure wizard popup """
import logging
_logger = logging.getLogger(__name__)
_logger.info(f"DEBUG: confirm_fail called. qty_failed from wizard: {self.qty_failed}")
if self.qty_failed > 0:
self.current_check_id.qty_failed_manual = self.qty_failed
_logger.info(f"DEBUG: Saved qty_failed_manual: {self.current_check_id.qty_failed_manual} to check {self.current_check_id.id}")
# Odoo 18 Wizard logic:
# self.current_check_id.do_fail()
# if self.measure_on == 'move_line':
# self.current_check_id._move_line_to_failure_location(self.failure_location_id.id, self.qty_failed)
# We replicate this but with our relaxed condition:
self.current_check_id.do_fail()
# Check if we should split the move line.
# We allow it if strictly defined as move_line check OR if a manual fail qty is provided (relaxed logic)
if self.current_check_id.point_id.measure_on == 'move_line' or self.qty_failed > 0:
_logger.info(f"DEBUG: Triggering _move_line_to_failure_location for check {self.current_check_id.id}")
# Use our ported method
self.current_check_id._move_line_to_failure_location(self.failure_location_id.id, self.qty_failed)
else:
_logger.info(f"DEBUG: Skipping _move_line_to_failure_location. measure_on={self.current_check_id.point_id.measure_on}, qty_failed={self.qty_failed}")
return self.action_generate_next_window()
def show_failure_message(self):
""" Initialize qty_failed with tested quantity or line quantity """
res = super().show_failure_message()
# If the user has already tested a specific quantity, that's likely the one they found
# failed if it's a pass/fail check on a quantity.
if self.qty_tested and self.qty_tested < self.qty_line:
self.qty_failed = self.qty_tested
return res

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="quality_check_wizard_form_failure_inherit" model="ir.ui.view">
<field name="name">quality.check.wizard.form.failure.inherit</field>
<field name="model">quality.check.wizard</field>
<field name="inherit_id" ref="quality_control.quality_check_wizard_form_failure"/>
<field name="arch" type="xml">
<xpath expr="//group[1]/group[1]" position="attributes">
<attribute name="invisible">measure_on != 'move_line' and not potential_failure_location_ids and not context.get('from_failure_form')</attribute>
</xpath>
<xpath expr="//field[@name='failure_location_id']" position="after">
<field name="failure_location_id"
invisible="potential_failure_location_ids"
domain="[('usage', '=', 'internal')]"
groups="stock.group_stock_multi_locations"
string="Failure Location (Manual)"/>
</xpath>
</field>
</record>
</odoo>