mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 23:18:40 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user