Merge pull request #55968 from frappe/mergify/bp/version-16-hotfix/pr-55830

fix(stock): enable quality inspection for all Stock Entry purposes (backport #55830)
This commit is contained in:
Mihir Kandoi
2026-06-19 12:03:46 +05:30
committed by GitHub
6 changed files with 353 additions and 30 deletions

View File

@@ -22,6 +22,14 @@ from erpnext.controllers.sales_and_purchase_return import (
filter_serial_batches,
make_serial_batch_bundle_for_return,
)
# Re-exported for backward compatibility; canonical home is erpnext.exceptions.
from erpnext.exceptions import (
BatchExpiredError,
QualityInspectionNotSubmittedError,
QualityInspectionRejectedError,
QualityInspectionRequiredError,
)
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock import get_warehouse_account_map
@@ -37,22 +45,6 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
from erpnext.stock.stock_ledger import get_items_to_be_repost
class QualityInspectionRequiredError(frappe.ValidationError):
pass
class QualityInspectionRejectedError(frappe.ValidationError):
pass
class QualityInspectionNotSubmittedError(frappe.ValidationError):
pass
class BatchExpiredError(frappe.ValidationError):
pass
class StockController(AccountsController):
def validate(self):
super().validate()
@@ -2163,7 +2155,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
inspection_fieldname = inspection_fieldname_map.get(doctype)
if inspection_fieldname is None:
return []
return items if doctype == "Stock Entry" else []
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"

View File

@@ -28,3 +28,20 @@ class MandatoryAccountDimensionError(frappe.ValidationError):
class ReportingCurrencyExchangeNotFoundError(frappe.ValidationError):
pass
# stock
class QualityInspectionRequiredError(frappe.ValidationError):
pass
class QualityInspectionRejectedError(frappe.ValidationError):
pass
class QualityInspectionNotSubmittedError(frappe.ValidationError):
pass
class BatchExpiredError(frappe.ValidationError):
pass

View File

@@ -389,11 +389,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
);
}
const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(
this.frm.doc.doctype
)
? "Incoming"
: "Outgoing";
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";
let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
quality_inspection_field.get_route_options_for_new_doc = function (row) {
@@ -2885,11 +2887,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
];
const me = this;
const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(
this.frm.doc.doctype
)
? "Incoming"
: "Outgoing";
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 dialog = new frappe.ui.Dialog({
title: __("Select Items for Quality Inspection"),
size: "extra-large",

View File

@@ -216,10 +216,11 @@ 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",
inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing",
reference_type: frm.doc.doctype,
reference_name: frm.doc.name,
child_row_reference: row.doc.name,

View File

@@ -259,7 +259,6 @@
},
{
"default": "0",
"depends_on": "eval: doc.purpose === \"Manufacture\"",
"fieldname": "inspection_required",
"fieldtype": "Check",
"label": "Inspection Required"
@@ -769,7 +768,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-03-04 19:03:23.426082",
"modified": "2026-06-11 18:23:12.340065",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",

View File

@@ -1089,6 +1089,316 @@ class TestStockEntry(ERPNextTestSuite):
repack.insert()
self.assertRaises(frappe.ValidationError, repack.submit)
def test_check_item_quality_inspection_returns_items_for_stock_entry(self):
from erpnext.controllers.stock_controller import check_item_quality_inspection
items = [
{"item_code": "_Test Item", "qty": 1},
{"item_code": "_Test Item Home Desktop 100", "qty": 1},
]
se_result = check_item_quality_inspection("Stock Entry", 0, items)
self.assertEqual(len(se_result), 2)
# a doctype not in INSPECTION_FIELDNAME_MAP and not a Stock Entry returns nothing
self.assertEqual(check_item_quality_inspection("Material Request", 0, items), [])
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
def test_quality_inspection_across_stock_entry_purposes(self):
from erpnext.controllers.stock_controller import check_item_quality_inspection
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
)
item_code = "_Test Item For QI Purposes"
if not frappe.db.exists("Item", item_code):
create_item(item_code, is_stock_item=1)
s_wh = "Stores - _TC"
t_wh = "_Test Warehouse - _TC"
# 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
purposes = {
"Material Receipt": {"to_warehouse": t_wh},
"Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh},
"Material Issue": {"from_warehouse": s_wh},
}
for purpose, warehouses in purposes.items():
with self.subTest(purpose=purpose):
needs_qi = "to_warehouse" in warehouses
se = make_stock_entry(
item_code=item_code,
qty=5,
basic_rate=100,
purpose=purpose,
inspection_required=True,
do_not_submit=True,
**warehouses,
)
# QI can be created from the Stock Entry for any purpose
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
self.assertRaises(QualityInspectionRequiredError, se.submit)
# a rejected QI must also block submission
se_rej = make_stock_entry(
item_code=item_code,
qty=5,
basic_rate=100,
purpose=purpose,
inspection_required=True,
do_not_submit=True,
**warehouses,
)
create_quality_inspection(
reference_type="Stock Entry",
reference_name=se_rej.name,
item_code=item_code,
inspection_type="Incoming",
status="Rejected",
)
se_rej.reload()
self.assertRaises(QualityInspectionRejectedError, se_rej.submit)
# a submitted, accepted QI links itself to the inward row; submission then succeeds
se_ok = make_stock_entry(
item_code=item_code,
qty=5,
basic_rate=100,
purpose=purpose,
inspection_required=True,
do_not_submit=True,
**warehouses,
)
create_quality_inspection(
reference_type="Stock Entry",
reference_name=se_ok.name,
item_code=item_code,
inspection_type="Incoming",
status="Accepted",
)
se_ok.reload()
se_ok.submit()
self.assertEqual(se_ok.docstatus, 1)
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
def test_quality_inspection_required_for_manufacture(self):
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_wo_stock_entry,
)
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
)
wo = make_wo_order_test_record(qty=1)
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
)
# transfer raw materials to WIP (no inspection on the transfer)
transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
for d in transfer.get("items"):
d.s_warehouse = "Stores - _TC"
transfer.insert()
transfer.submit()
# manufacture with inspection required
mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
mfg.inspection_required = 1
mfg.insert()
self.assertRaises(QualityInspectionRequiredError, mfg.submit)
# a rejected QI on the finished-good row must also block submission
qi = create_quality_inspection(
reference_type="Stock Entry",
reference_name=mfg.name,
item_code=wo.production_item,
inspection_type="Incoming",
status="Rejected",
)
mfg.reload()
self.assertRaises(QualityInspectionRejectedError, mfg.submit)
# accepting the QI then allows submission
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
mfg.reload()
mfg.submit()
self.assertEqual(mfg.docstatus, 1)
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
def test_quality_inspection_required_for_material_transfer_for_manufacture(self):
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_wo_stock_entry,
)
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
)
wo = make_wo_order_test_record(qty=1)
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
)
transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
for d in transfer.get("items"):
d.s_warehouse = "Stores - _TC"
transfer.inspection_required = 1
transfer.insert()
self.assertRaises(QualityInspectionRequiredError, transfer.submit)
# a rejected QI on any row moved into WIP must block submission;
# every raw-material row moved into WIP needs a QI
qis = []
for item_code in {d.item_code for d in transfer.items if d.t_warehouse}:
qis.append(
create_quality_inspection(
reference_type="Stock Entry",
reference_name=transfer.name,
item_code=item_code,
inspection_type="Incoming",
status="Rejected",
)
)
transfer.reload()
self.assertRaises(QualityInspectionRejectedError, transfer.submit)
# accepting every QI then allows submission
for qi in qis:
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
transfer.reload()
transfer.submit()
self.assertEqual(transfer.docstatus, 1)
def test_quality_inspection_required_for_send_to_subcontractor(self):
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
from erpnext.controllers.tests.test_subcontracting_controller import (
get_subcontracting_order,
make_service_item,
)
from erpnext.exceptions import QualityInspectionRequiredError
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
)
make_service_item("Subcontracted Service Item 1")
sco = get_subcontracting_order(
service_items=[
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 500,
"fg_item": "_Test FG Item",
"fg_item_qty": 10,
}
]
)
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=100, basic_rate=100
)
se = frappe.get_doc(make_rm_stock_entry(sco.name))
se.from_warehouse = "_Test Warehouse - _TC"
se.to_warehouse = "_Test Warehouse - _TC"
se.stock_entry_type = "Send to Subcontractor"
se.inspection_required = 1
se.insert()
self.assertRaises(QualityInspectionRequiredError, se.submit)
for item_code in {row.item_code for row in se.items if row.t_warehouse}:
create_quality_inspection(
reference_type="Stock Entry",
reference_name=se.name,
item_code=item_code,
inspection_type="Outgoing",
status="Accepted",
)
se.reload()
se.submit()
self.assertEqual(se.docstatus, 1)
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
def test_quality_inspection_required_for_disassemble(self):
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_wo_stock_entry,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
)
source_warehouse = "Stores - _TC"
fg_item = make_item("Test Disassemble FG QI", {"is_stock_item": 1}).name
raw_materials = ["Test Disassemble RM QI 1", "Test Disassemble RM QI 2"]
for item in raw_materials:
make_item(item, {"is_stock_item": 1})
make_stock_entry(item_code=item, target=source_warehouse, qty=5, basic_rate=100)
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials)
wo = make_wo_order_test_record(
item=fg_item, qty=1, source_warehouse=source_warehouse, skip_transfer=1
)
# manufacture the FG so there is something to disassemble
mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
for row in mfg.items:
if row.item_code in raw_materials:
row.s_warehouse = source_warehouse
mfg.submit()
# disassemble with inspection required -> the component 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
qis = []
for item_code in {row.item_code for row in dis.items if row.t_warehouse}:
qis.append(
create_quality_inspection(
reference_type="Stock Entry",
reference_name=dis.name,
item_code=item_code,
inspection_type="Outgoing",
status="Rejected",
)
)
dis.reload()
self.assertRaises(QualityInspectionRejectedError, dis.submit)
# accepting every QI then allows submission
for qi in qis:
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
dis.reload()
dis.submit()
self.assertEqual(dis.docstatus, 1)
def test_customer_provided_parts_se(self):
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
se = make_stock_entry(