From 40ca3b5e5dde613c962d64eb1ae882aa903354fc Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 23:44:35 +0530 Subject: [PATCH] fix(stock): support quality inspection for stock entry by purpose (backport #56446) --- erpnext/controllers/stock_controller.py | 40 +++++++++- erpnext/public/js/controllers/transaction.js | 63 +++++++++++----- .../quality_inspection/quality_inspection.py | 48 +++++++++++- .../stock/doctype/stock_entry/stock_entry.js | 9 ++- .../doctype/stock_entry/test_stock_entry.py | 75 ++++++++++++++----- .../stock_entry_detail.json | 4 +- 6 files changed, 192 insertions(+), 47 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9d6d8c1f854..331be31d280 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -46,6 +46,42 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor ) from erpnext.stock.stock_ledger import get_items_to_be_repost +# Purposes whose inward (t_warehouse) row is inspected. +QI_INCOMING_PURPOSES = ( + "Material Receipt", + "Repack", + "Receive from Customer", + "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.""" + if row.get("type") or row.get("is_legacy_scrap_item"): + return False + if purpose == "Manufacture": + return bool(row.is_finished_item) + if purpose in QI_INCOMING_PURPOSES: + return bool(row.t_warehouse) + if purpose in QI_OUTGOING_PURPOSES: + return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse) + return False + class StockController(AccountsController): def validate(self): @@ -1477,8 +1513,8 @@ class StockController(AccountsController): "Item", row.item_code, inspection_required_fieldname ): qi_required = True - elif self.doctype == "Stock Entry" and row.t_warehouse: - qi_required = True # inward stock needs inspection + elif self.doctype == "Stock Entry": + qi_required = stock_entry_row_requires_inspection(self.purpose, row) if row.get("type") or row.get("is_legacy_scrap_item"): continue diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index d3832b4dd46..410ab292170 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1,6 +1,34 @@ // 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 controllers/stock_controller.py. +erpnext.stock = erpnext.stock || {}; +erpnext.stock.qi_incoming_purposes = [ + "Material Receipt", + "Repack", + "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.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; + 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 { setup() { super.setup(); @@ -408,13 +436,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe ); } - const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]; - const incoming_purposes = ["Manufacture", "Material Receipt"]; - const inspection_type = - incoming_doctypes.includes(this.frm.doc.doctype) || - (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose)) - ? "Incoming" - : "Outgoing"; + const inspection_type = this.quality_inspection_type(); let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function (row) { @@ -2901,13 +2923,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe ]; const me = this; - const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]; - const incoming_purposes = ["Manufacture", "Material Receipt"]; - const inspection_type = - incoming_doctypes.includes(this.frm.doc.doctype) || - (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose)) - ? "Incoming" - : "Outgoing"; + const inspection_type = this.quality_inspection_type(); const dialog = new frappe.ui.Dialog({ title: __("Select Items for Quality Inspection"), size: "extra-large", @@ -2999,14 +3015,23 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } + quality_inspection_type() { + const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]; + const is_incoming = + incoming_doctypes.includes(this.frm.doc.doctype) || + (this.frm.doc.doctype === "Stock Entry" && + erpnext.stock.is_incoming_qi_purpose(this.frm.doc.purpose)); + return is_incoming ? "Incoming" : "Outgoing"; + } + has_inspection_required(item) { - if (this.frm.doc.doctype === "Stock Entry" && this.frm.doc.purpose == "Manufacture") { - if (item.is_finished_item && !item.quality_inspection) { - return true; - } - } else if (!item.quality_inspection) { + if (item.quality_inspection) { + return false; + } + if (this.frm.doc.doctype !== "Stock Entry") { return true; } + return erpnext.stock.row_requires_quality_inspection(this.frm.doc.purpose, item); } get_method_for_payment() { diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 586bc4dbef1..5e7a6ba307d 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -8,6 +8,10 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, cstr, flt, get_link_to_form, get_number_format_info +from erpnext.controllers.stock_controller import ( + QI_INCOMING_PURPOSES, + QI_OUTGOING_PURPOSES, +) from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( get_template_details, ) @@ -385,13 +389,43 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): ["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.t_warehouse", "is", "not set"], + ["items.type", "is", "not set"], + "and", + ["items.is_legacy_scrap_item", "=", 0], ] ) + if purpose == "Manufacture": + my_filters.extend( + [ + "and", + ["items.is_finished_item", "=", 1], + ] + ) + elif purpose in QI_INCOMING_PURPOSES: + my_filters.extend( + [ + "and", + ["items.t_warehouse", "is", "set"], + ] + ) + 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( [ @@ -412,7 +446,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): ] ) - return frappe.get_query( + query = frappe.get_query( reference_doctype, fields=["items.item_code, items.item_name"], filters=my_filters, @@ -421,7 +455,15 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): order_by="items.item_code", ignore_permissions=False, distinct=True, - ).run() + ) + if require_distinct_warehouse: + # The cross-column guard (s_warehouse != t_warehouse) can't be expressed in frappe's + # filter-list syntax, so it is appended as a raw query-builder condition. This relies on + # the "items.s_warehouse" filter above having already LEFT-JOINed the child table, so + # child.t_warehouse references that same joined table. + child = frappe.qb.DocType(frappe.get_meta(reference_doctype).get_field("items").options) + query = query.where(child.t_warehouse.isnull() | (child.s_warehouse != child.t_warehouse)) + return query.run() @frappe.whitelist() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index ff2dd1818c0..c627c6bbdb1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -199,6 +199,10 @@ frappe.ui.form.on("Stock Entry", { }, setup_quality_inspection: function (frm) { + frm.get_docfield("items", "quality_inspection").depends_on = (row) => + frm.doc.inspection_required && + erpnext.stock.row_requires_quality_inspection(frm.doc.purpose, row); + if (!frm.doc.inspection_required) { return; } @@ -216,11 +220,12 @@ frappe.ui.form.on("Stock Entry", { } let quality_inspection_field = frm.get_docfield("items", "quality_inspection"); - const incoming_purposes = ["Manufacture", "Material Receipt"]; quality_inspection_field.get_route_options_for_new_doc = function (row) { if (frm.is_new()) return {}; return { - inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing", + inspection_type: erpnext.stock.is_incoming_qi_purpose(frm.doc.purpose) + ? "Incoming" + : "Outgoing", reference_type: frm.doc.doctype, reference_name: frm.doc.name, child_row_reference: row.doc.name, diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 4815078ba1f..ce316f5105b 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -1174,16 +1174,21 @@ class TestStockEntry(ERPNextTestSuite): # stock the source warehouse for transfer / issue purposes make_stock_entry(item_code=item_code, target=s_wh, qty=100, basic_rate=100) - # purpose -> warehouses for the moved row; inward (with target) requires QI + # purpose -> warehouses for the moved row and the direction QI is required on: + # Material Receipt inspects the inward row, Transfer/Issue inspect the outgoing row. purposes = { - "Material Receipt": {"to_warehouse": t_wh}, - "Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh}, - "Material Issue": {"from_warehouse": s_wh}, + "Material Receipt": {"warehouses": {"to_warehouse": t_wh}, "inspection_type": "Incoming"}, + "Material Transfer": { + "warehouses": {"from_warehouse": s_wh, "to_warehouse": t_wh}, + "inspection_type": "Outgoing", + }, + "Material Issue": {"warehouses": {"from_warehouse": s_wh}, "inspection_type": "Outgoing"}, } - for purpose, warehouses in purposes.items(): + for purpose, config in purposes.items(): with self.subTest(purpose=purpose): - needs_qi = "to_warehouse" in warehouses + warehouses = config["warehouses"] + inspection_type = config["inspection_type"] se = make_stock_entry( item_code=item_code, @@ -1199,13 +1204,7 @@ class TestStockEntry(ERPNextTestSuite): allowed = check_item_quality_inspection("Stock Entry", 0, se.as_dict().get("items")) self.assertTrue(any(row.get("item_code") == item_code for row in allowed)) - if not needs_qi: - # outward-only entry: QI is not enforced - se.submit() - self.assertEqual(se.docstatus, 1) - continue - - # inward entry without QI must block submission + # entry without QI must block submission self.assertRaises(QualityInspectionRequiredError, se.submit) # a rejected QI must also block submission @@ -1222,13 +1221,13 @@ class TestStockEntry(ERPNextTestSuite): reference_type="Stock Entry", reference_name=se_rej.name, item_code=item_code, - inspection_type="Incoming", + inspection_type=inspection_type, status="Rejected", ) se_rej.reload() self.assertRaises(QualityInspectionRejectedError, se_rej.submit) - # a submitted, accepted QI links itself to the inward row; submission then succeeds + # a submitted, accepted QI links itself to the inspected row; submission then succeeds se_ok = make_stock_entry( item_code=item_code, qty=5, @@ -1242,7 +1241,7 @@ class TestStockEntry(ERPNextTestSuite): reference_type="Stock Entry", reference_name=se_ok.name, item_code=item_code, - inspection_type="Incoming", + inspection_type=inspection_type, status="Accepted", ) se_ok.reload() @@ -1425,15 +1424,15 @@ class TestStockEntry(ERPNextTestSuite): row.s_warehouse = source_warehouse mfg.submit() - # disassemble with inspection required -> the component rows need a QI + # disassemble with inspection required -> the consumed (outgoing) rows need a QI dis = frappe.get_doc(make_wo_stock_entry(wo.name, "Disassemble", 1)) dis.inspection_required = 1 dis.insert() self.assertRaises(QualityInspectionRequiredError, dis.submit) - # a rejected QI on any disassembled component row must also block submission + # a rejected QI on any consumed (outgoing) row must also block submission qis = [] - for item_code in {row.item_code for row in dis.items if row.t_warehouse}: + for item_code in {row.item_code for row in dis.items if row.s_warehouse}: qis.append( create_quality_inspection( reference_type="Stock Entry", @@ -2830,6 +2829,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.work_order 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/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index b446aa1e51e..b41a7038b78 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -324,7 +324,7 @@ "options": "Batch" }, { - "depends_on": "eval:parent.inspection_required && doc.t_warehouse", + "depends_on": "eval:parent.inspection_required", "fieldname": "quality_inspection", "fieldtype": "Link", "label": "Quality Inspection", @@ -679,7 +679,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-27 11:40:38.294196", + "modified": "2026-06-30 12:18:34.132425", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail",