mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-18 10:57:56 +00:00
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.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
// License: GNU General Public License v3. See license.txt
|
// 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 = erpnext.stock || {};
|
||||||
erpnext.stock.qi_incoming_purposes = [
|
erpnext.stock.qi_incoming_purposes = [
|
||||||
"Material Receipt",
|
"Material Receipt",
|
||||||
@@ -8,13 +10,23 @@ erpnext.stock.qi_incoming_purposes = [
|
|||||||
"Receive from Customer",
|
"Receive from Customer",
|
||||||
"Subcontracting Return",
|
"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) =>
|
erpnext.stock.is_incoming_qi_purpose = (purpose) =>
|
||||||
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
|
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
|
||||||
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
|
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
|
||||||
if (row.secondary_item_type || row.is_legacy_scrap_item) return false;
|
if (row.secondary_item_type || row.is_legacy_scrap_item) return false;
|
||||||
if (purpose === "Manufacture") return !!row.is_finished_item;
|
if (purpose === "Manufacture") return !!row.is_finished_item;
|
||||||
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
|
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 {
|
erpnext.TransactionController = class TransactionController extends erpnext.taxes_and_totals {
|
||||||
|
|||||||
@@ -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 (
|
from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import (
|
||||||
get_template_details,
|
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):
|
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"],
|
["items.quality_inspection", "is", "not set"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
require_distinct_warehouse = False
|
||||||
|
|
||||||
if reference_doctype == "Stock Entry":
|
if reference_doctype == "Stock Entry":
|
||||||
purpose = frappe.get_cached_value("Stock Entry", filters.get("reference_name"), "purpose")
|
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":
|
if purpose == "Manufacture":
|
||||||
my_filters.extend(
|
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"],
|
["items.t_warehouse", "is", "set"],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
elif purpose in QI_OUTGOING_PURPOSES:
|
||||||
my_filters.extend(
|
my_filters.extend(
|
||||||
[
|
[
|
||||||
"and",
|
"and",
|
||||||
["items.s_warehouse", "is", "set"],
|
["items.s_warehouse", "is", "set"],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
require_distinct_warehouse = True
|
||||||
|
else:
|
||||||
|
# purpose requires no quality inspection
|
||||||
|
return []
|
||||||
elif filters.get("inspection_type") != "In Process":
|
elif filters.get("inspection_type") != "In Process":
|
||||||
my_filters.extend(
|
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.
|
# 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")
|
items_field = frappe.get_meta(reference_doctype).get_field("items")
|
||||||
if items_field:
|
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()
|
return query.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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, "Material Consumption for Manufacture", 5)).submit()
|
||||||
frappe.get_doc(_make_stock_entry(work_order.name, "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):
|
def test_qi_creation_with_naming_rule_company_condition(self):
|
||||||
"""
|
"""
|
||||||
Unit test case to check the document naming rule with company condition
|
Unit test case to check the document naming rule with company condition
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ INSPECTION_FIELDNAME_MAP = {
|
|||||||
"Delivery Note": "inspection_required_before_delivery",
|
"Delivery Note": "inspection_required_before_delivery",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Purposes whose inward (t_warehouse) row is inspected.
|
||||||
QI_INCOMING_PURPOSES = (
|
QI_INCOMING_PURPOSES = (
|
||||||
"Material Receipt",
|
"Material Receipt",
|
||||||
"Repack",
|
"Repack",
|
||||||
@@ -33,6 +34,21 @@ QI_INCOMING_PURPOSES = (
|
|||||||
"Subcontracting Return",
|
"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):
|
def stock_entry_row_requires_inspection(purpose, row):
|
||||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
"""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)
|
return bool(row.is_finished_item)
|
||||||
if purpose in QI_INCOMING_PURPOSES:
|
if purpose in QI_INCOMING_PURPOSES:
|
||||||
return bool(row.t_warehouse)
|
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:
|
class QualityInspectionService:
|
||||||
|
|||||||
Reference in New Issue
Block a user