From 847fd8aa33d030e95ab22db5a89e9c7696bf1635 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 20:26:09 +0530 Subject: [PATCH] fix(stock): exclude consumption from outgoing quality inspection The QI-by-purpose check required an inspection on every outgoing (s_warehouse) row for any purpose that was not incoming. Material Consumption for Manufacture rows are source-only and the Work Order mapper copies inspection_required from the BOM, so this silently blocked submission. An inspection_required BOM inspects the finished good, not each consumed raw material. Replace the "anything not incoming" fallthrough with an explicit QI_OUTGOING_PURPOSES allow-list (mirrored in transaction.js) so a new purpose cannot silently start requiring a QI. Consumption and Return Raw Material to Customer now need no QI; Issue, Transfer, Transfer for Manufacture, Send to Subcontractor, Subcontracting Delivery and Disassemble keep their outgoing checks. Scope item_query to the same set and add a regression test. --- erpnext/public/js/controllers/transaction.js | 14 ++++++- .../quality_inspection/quality_inspection.py | 26 +++++++++++-- .../doctype/stock_entry/test_stock_entry.py | 38 +++++++++++++++++++ .../services/quality_inspection_service.py | 20 +++++++++- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 86784d28748..c75c010b563 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1,6 +1,8 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt +// Keep these in sync with QI_INCOMING_PURPOSES / QI_OUTGOING_PURPOSES / +// stock_entry_row_requires_inspection in stock/services/quality_inspection_service.py. erpnext.stock = erpnext.stock || {}; erpnext.stock.qi_incoming_purposes = [ "Material Receipt", @@ -8,13 +10,23 @@ erpnext.stock.qi_incoming_purposes = [ "Receive from Customer", "Subcontracting Return", ]; +erpnext.stock.qi_outgoing_purposes = [ + "Material Issue", + "Material Transfer", + "Material Transfer for Manufacture", + "Send to Subcontractor", + "Subcontracting Delivery", + "Disassemble", +]; erpnext.stock.is_incoming_qi_purpose = (purpose) => purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose); erpnext.stock.row_requires_quality_inspection = (purpose, row) => { if (row.secondary_item_type || row.is_legacy_scrap_item) return false; if (purpose === "Manufacture") return !!row.is_finished_item; if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse; - return !!row.s_warehouse && row.s_warehouse !== row.t_warehouse; + if (erpnext.stock.qi_outgoing_purposes.includes(purpose)) + return !!row.s_warehouse && row.s_warehouse !== row.t_warehouse; + return false; }; erpnext.TransactionController = class TransactionController extends erpnext.taxes_and_totals { diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index d0978da0f7e..7cd92bbda2e 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -13,7 +13,10 @@ from frappe.utils import cint, flt, get_link_to_form, get_number_format_info from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( get_template_details, ) -from erpnext.stock.services.quality_inspection_service import QI_INCOMING_PURPOSES +from erpnext.stock.services.quality_inspection_service import ( + QI_INCOMING_PURPOSES, + QI_OUTGOING_PURPOSES, +) class QualityInspection(Document): @@ -387,8 +390,18 @@ def item_query(doctype: Any, txt: str | None, searchfield: Any, start: int, page ["items.quality_inspection", "is", "not set"], ] + require_distinct_warehouse = False + if reference_doctype == "Stock Entry": purpose = frappe.get_cached_value("Stock Entry", filters.get("reference_name"), "purpose") + my_filters.extend( + [ + "and", + ["items.secondary_item_type", "is", "not set"], + "and", + ["items.is_legacy_scrap_item", "=", 0], + ] + ) if purpose == "Manufacture": my_filters.extend( [ @@ -403,13 +416,17 @@ def item_query(doctype: Any, txt: str | None, searchfield: Any, start: int, page ["items.t_warehouse", "is", "set"], ] ) - else: + elif purpose in QI_OUTGOING_PURPOSES: my_filters.extend( [ "and", ["items.s_warehouse", "is", "set"], ] ) + require_distinct_warehouse = True + else: + # purpose requires no quality inspection + return [] elif filters.get("inspection_type") != "In Process": my_filters.extend( [ @@ -444,7 +461,10 @@ def item_query(doctype: Any, txt: str | None, searchfield: Any, start: int, page # query instead keeps it -- item_code is in the DISTINCT select, so it is valid on Postgres. items_field = frappe.get_meta(reference_doctype).get_field("items") if items_field: - query = query.orderby(frappe.qb.DocType(items_field.options).item_code) + child = frappe.qb.DocType(items_field.options) + if require_distinct_warehouse: + query = query.where(child.t_warehouse.isnull() | (child.s_warehouse != child.t_warehouse)) + query = query.orderby(child.item_code) return query.run() diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index aa04c7552f3..43066da7580 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2839,6 +2839,44 @@ class TestStockEntry(ERPNextTestSuite): frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit() frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit() + @ERPNextTestSuite.change_settings( + "Manufacturing Settings", + {"material_consumption": 1, "backflush_raw_materials_based_on": "BOM"}, + ) + def test_qi_not_required_for_material_consumption_for_manufacture(self): + """An inspection_required BOM inspects the finished good (the Manufacture rule), + not each consumed raw material, so Material Consumption for Manufacture (whose + rows are outgoing only) must still submit without a Quality Inspection.""" + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as _make_stock_entry + from erpnext.manufacturing.doctype.work_order.work_order import make_work_order + + fg_item = make_item("_Test QI Consumption FG", properties={"is_stock_item": 1}).name + rm_item = make_item("_Test QI Consumption RM", properties={"is_stock_item": 1}).name + warehouse = "Stores - WP" + + bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_submit=True) + bom.inspection_required = 1 + bom.submit() + + se = make_stock_entry(item_code=rm_item, target=warehouse, qty=5, rate=10, purpose="Material Receipt") + + work_order = make_work_order(bom.name, fg_item, 5) + work_order.company = se.company + work_order.skip_transfer = 1 + work_order.source_warehouse = warehouse + work_order.fg_warehouse = warehouse + work_order.submit() + + consumption = frappe.get_doc( + _make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5) + ) + # the mapper copies inspection_required from the BOM ... + self.assertEqual(consumption.inspection_required, 1) + # ... but the consumed rows are outgoing-only, so no QI is required and submit succeeds + consumption.submit() + self.assertEqual(consumption.docstatus, 1) + def test_qi_creation_with_naming_rule_company_condition(self): """ Unit test case to check the document naming rule with company condition diff --git a/erpnext/stock/services/quality_inspection_service.py b/erpnext/stock/services/quality_inspection_service.py index ed5fb2cf68f..3784725a4d2 100644 --- a/erpnext/stock/services/quality_inspection_service.py +++ b/erpnext/stock/services/quality_inspection_service.py @@ -26,6 +26,7 @@ INSPECTION_FIELDNAME_MAP = { "Delivery Note": "inspection_required_before_delivery", } +# Purposes whose inward (t_warehouse) row is inspected. QI_INCOMING_PURPOSES = ( "Material Receipt", "Repack", @@ -33,6 +34,21 @@ QI_INCOMING_PURPOSES = ( "Subcontracting Return", ) +# Purposes whose outgoing (s_warehouse) row is inspected. This is an explicit +# allow-list rather than "everything that isn't incoming" so a new purpose can't +# silently start requiring a QI. Material Consumption for Manufacture is left out +# on purpose: an inspection_required BOM inspects the manufactured output (handled +# by the "Manufacture" finished-good rule), not each consumed raw material. +# Keep this in sync with erpnext.stock.qi_* helpers in transaction.js. +QI_OUTGOING_PURPOSES = ( + "Material Issue", + "Material Transfer", + "Material Transfer for Manufacture", + "Send to Subcontractor", + "Subcontracting Delivery", + "Disassemble", +) + def stock_entry_row_requires_inspection(purpose, row): """Check if this Stock Entry row need a Quality Inspection.""" @@ -42,7 +58,9 @@ def stock_entry_row_requires_inspection(purpose, row): return bool(row.is_finished_item) if purpose in QI_INCOMING_PURPOSES: return bool(row.t_warehouse) - return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse) + if purpose in QI_OUTGOING_PURPOSES: + return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse) + return False class QualityInspectionService: