From 4a44ffa1fecbbf4e1267431f48df04fdb8e69070 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 11 Feb 2026 10:28:28 +0700 Subject: [PATCH] first commit --- .gitignore | 6 + README.md | 29 +++ __init__.py | 2 + __manifest__.py | 20 ++ models/__init__.py | 2 + models/mrp_production.py | 70 ++++++ models/quality.py | 305 ++++++++++++++++++++++++++ views/quality_check_views.xml | 13 ++ wizard/__init__.py | 1 + wizard/quality_check_wizard.py | 70 ++++++ wizard/quality_check_wizard_views.xml | 20 ++ 11 files changed, 538 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/mrp_production.py create mode 100644 models/quality.py create mode 100644 views/quality_check_views.xml create mode 100644 wizard/__init__.py create mode 100644 wizard/quality_check_wizard.py create mode 100644 wizard/quality_check_wizard_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9754ed1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*$py.class +.DS_Store +*.swp +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..9041e9e --- /dev/null +++ b/README.md @@ -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* diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..e8197f5 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..729886e --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from . import mrp_production +from . import quality diff --git a/models/mrp_production.py b/models/mrp_production.py new file mode 100644 index 0000000..35d15ad --- /dev/null +++ b/models/mrp_production.py @@ -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) diff --git a/models/quality.py b/models/quality.py new file mode 100644 index 0000000..53b77c4 --- /dev/null +++ b/models/quality.py @@ -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 diff --git a/views/quality_check_views.xml b/views/quality_check_views.xml new file mode 100644 index 0000000..043575d --- /dev/null +++ b/views/quality_check_views.xml @@ -0,0 +1,13 @@ + + + + quality.check.view.form.inherit.debug + quality.check + + + + + + + + diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..afa4376 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1 @@ +from . import quality_check_wizard diff --git a/wizard/quality_check_wizard.py b/wizard/quality_check_wizard.py new file mode 100644 index 0000000..eba033c --- /dev/null +++ b/wizard/quality_check_wizard.py @@ -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 diff --git a/wizard/quality_check_wizard_views.xml b/wizard/quality_check_wizard_views.xml new file mode 100644 index 0000000..3c37223 --- /dev/null +++ b/wizard/quality_check_wizard_views.xml @@ -0,0 +1,20 @@ + + + + quality.check.wizard.form.failure.inherit + quality.check.wizard + + + + measure_on != 'move_line' and not potential_failure_location_ids and not context.get('from_failure_form') + + + + + + +