From 666becc6706e99ede24a831f27c858a5e7899af0 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Wed, 1 Jul 2026 03:34:53 +0000 Subject: [PATCH 1/7] chore(release): Bumped to Version 16.26.0 # [16.26.0](https://github.com/frappe/erpnext/compare/v16.25.0...v16.26.0) (2026-07-01) ### Bug Fixes * add permission checks in whitelisted functions (backport [#53103](https://github.com/frappe/erpnext/issues/53103)) ([#56669](https://github.com/frappe/erpnext/issues/56669)) ([52ea964](https://github.com/frappe/erpnext/commit/52ea9641cea8cb9b805ae755f030ee3def773246)) * adjust outstanding amount calculation in purchase and sales registers ([1a2a9b6](https://github.com/frappe/erpnext/commit/1a2a9b6cfcfdf792ec85f58844eddeb3b1e1f329)) * allow rename for Quality Inspection Parameter ([410a772](https://github.com/frappe/erpnext/commit/410a7725101fadaa17da4ce297be18986bda51dd)) * **asset:** conditionally show Is Fully Depreciated field ([0e730bf](https://github.com/frappe/erpnext/commit/0e730bf1c7ce3ad2aff70404723f91e0d081ce12)) * carry item-level project to Purchase Receipt GL entries (backport [#56568](https://github.com/frappe/erpnext/issues/56568)) ([#56619](https://github.com/frappe/erpnext/issues/56619)) ([0829cb4](https://github.com/frappe/erpnext/commit/0829cb45a8afc7bd82983a7d05eaaf8063166f60)) * **controllers:** fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) ([3abadc7](https://github.com/frappe/erpnext/commit/3abadc7a5f626cf47b57dd6d05298ebb7c3df546)) * **crm:** using `get_list` instead of `get_all` in `get_opportunities` (backport [#56463](https://github.com/frappe/erpnext/issues/56463)) ([#56466](https://github.com/frappe/erpnext/issues/56466)) ([30ba950](https://github.com/frappe/erpnext/commit/30ba950abd5fdf2564939d56680e3218e3584f19)) * do not allow closing the accounting period for future dates (backport [#56551](https://github.com/frappe/erpnext/issues/56551)) ([#56577](https://github.com/frappe/erpnext/issues/56577)) ([df1b443](https://github.com/frappe/erpnext/commit/df1b4431b6876bba09790d2119107c1ec18d2604)) * exclude virtual child doctypes from deletion in transaction deletion record ([8a66570](https://github.com/frappe/erpnext/commit/8a665709d2729781d2d3eb5aa96b2391964a2034)) * gross profit calculation with rate adjustment entries ([9c5b063](https://github.com/frappe/erpnext/commit/9c5b063884a436246739abbd83bb992cad04add1)) * handle missing serial and batch bundle in print format ([c84d4a2](https://github.com/frappe/erpnext/commit/c84d4a282d6d23f41394efcbd6b9d86a172d4866)) * ignored posting time 00:00:00 in RIV (backport [#56571](https://github.com/frappe/erpnext/issues/56571)) ([#56573](https://github.com/frappe/erpnext/issues/56573)) ([b4d83e5](https://github.com/frappe/erpnext/commit/b4d83e542aac8aebf5815aee5d126cde2a5c647b)) * job card timer issue (backport [#56405](https://github.com/frappe/erpnext/issues/56405)) ([#56406](https://github.com/frappe/erpnext/issues/56406)) ([54c45d7](https://github.com/frappe/erpnext/commit/54c45d7b22d15717f84a572e6c3b6ff0cd28384d)) * **lead:** added missing read permission check on `get_lead_details` (backport [#56272](https://github.com/frappe/erpnext/issues/56272)) ([#56274](https://github.com/frappe/erpnext/issues/56274)) ([bd54c7f](https://github.com/frappe/erpnext/commit/bd54c7fea8903c0b28c8c5f07289fc3bb624c1bd)) * **letter-head:** guard company lookups when doc has no company field ([89059a9](https://github.com/frappe/erpnext/commit/89059a990fdf4c13ae4eb6bfe01138635459676c)) * link portal address rows to web form ([3a480c0](https://github.com/frappe/erpnext/commit/3a480c08b1dd1cad110b5244bfb5ee51148f8904)) * manual backport of [#55896](https://github.com/frappe/erpnext/issues/55896) ([490e125](https://github.com/frappe/erpnext/commit/490e12526711c74ebc58ea25affe935346ab95d3)) * party aliases should be no copy ([0b42241](https://github.com/frappe/erpnext/commit/0b42241682fedf5a452765c3599f5a857adc7d65)) * precision issue causing COGS in inter transfer PR (backport [#56420](https://github.com/frappe/erpnext/issues/56420)) ([#56425](https://github.com/frappe/erpnext/issues/56425)) ([e3958ad](https://github.com/frappe/erpnext/commit/e3958ad7bb18343cefa3d72175e98e35e534f1e7)) * remove dead bundle helper call from purchase receipt print format ([2eaa635](https://github.com/frappe/erpnext/commit/2eaa635ab6c88818d2458ae6d09e074b60ad0a92)) * remove frappe.utils from jinja context in process statement of accounts ([37ec2d0](https://github.com/frappe/erpnext/commit/37ec2d0edd439a6dd49c4506d889b0977dcfb183)) * rewrite item rate calculation (backport [#56315](https://github.com/frappe/erpnext/issues/56315)) ([ef3d444](https://github.com/frappe/erpnext/commit/ef3d444a6013be06ed220aeb4f70db81a34d5984)) * set mr status to received when per_received is 100 even if per_ordered < 100 ([4181246](https://github.com/frappe/erpnext/commit/41812462b4f5de259dc4c898489822f8b6bbeb85)) * show contextual balance label on party dashboard for net balances ([a886d0b](https://github.com/frappe/erpnext/commit/a886d0b445547e4b861fc80c8e745a011c7b087d)) * skip qty over-allowance check for non-stock items only ([bc313dc](https://github.com/frappe/erpnext/commit/bc313dc09db263abf58bd8cc9df32b6cf559d300)) * **stock:** value batch/serial return from ledger when original receipt has no bundle (backport [#56631](https://github.com/frappe/erpnext/issues/56631)) ([#56646](https://github.com/frappe/erpnext/issues/56646)) ([2c18c16](https://github.com/frappe/erpnext/commit/2c18c16be61b227e8f5faefa7214947d3fbcedc3)) * sync Stock Reconciliation difference amount with GL after reposting (backport [#56574](https://github.com/frappe/erpnext/issues/56574)) ([#56585](https://github.com/frappe/erpnext/issues/56585)) ([e834098](https://github.com/frappe/erpnext/commit/e834098c28bcda3715fb8afb88b11c82681ed601)) * update qty in future SLEs when cancelling documents ([#56638](https://github.com/frappe/erpnext/issues/56638)) ([01374db](https://github.com/frappe/erpnext/commit/01374db8dab4979309b4d1fa446c4af0a008ddfc)) * update_qty_in_future_sle skips SLEs with same posting datetime ([#56612](https://github.com/frappe/erpnext/issues/56612)) ([5aa62d1](https://github.com/frappe/erpnext/commit/5aa62d1cda4d9a50a75c76bacba36fc30629ca9d)) * Use correct doctype name for PCV perm-check (backport [#56606](https://github.com/frappe/erpnext/issues/56606)) ([#56611](https://github.com/frappe/erpnext/issues/56611)) ([762ce5c](https://github.com/frappe/erpnext/commit/762ce5c6842fcf77d3754ea98f6b4f71bec9b932)) * use correct variable to fetch valuation method ([d2c8df9](https://github.com/frappe/erpnext/commit/d2c8df9451824dcf1519425bd14b48585bdbe3b0)) ### Features * **accounts:** add configurable job timeout for Process Period Closing Voucher ([d389014](https://github.com/frappe/erpnext/commit/d389014e579a743f80a7388590c12cc2d76602b6)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index e9347d1f1dd..88f365066d3 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -6,7 +6,7 @@ import frappe from frappe.model.document import Document from frappe.utils.user import is_website_user -__version__ = "16.25.0" +__version__ = "16.26.0" def get_default_company(user=None): From 613b3c16c2140334630f370ef68d7ee126396888 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 23:44:35 +0530 Subject: [PATCH 2/7] fix(stock): support quality inspection for stock entry by purpose (backport #56446) (cherry picked from commit 40ca3b5e5dde613c962d64eb1ae882aa903354fc) --- 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", From fd00cebbd234b8660fb68d8b0101b74284f7984b Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Wed, 1 Jul 2026 07:42:04 +0000 Subject: [PATCH 3/7] chore(release): Bumped to Version 16.26.1 ## [16.26.1](https://github.com/frappe/erpnext/compare/v16.26.0...v16.26.1) (2026-07-01) ### Bug Fixes * **stock:** support quality inspection for stock entry by purpose (backport [#56446](https://github.com/frappe/erpnext/issues/56446)) ([613b3c1](https://github.com/frappe/erpnext/commit/613b3c16c2140334630f370ef68d7ee126396888)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 88f365066d3..6fd003ba1fa 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -6,7 +6,7 @@ import frappe from frappe.model.document import Document from frappe.utils.user import is_website_user -__version__ = "16.26.0" +__version__ = "16.26.1" def get_default_company(user=None): From 1b1ea7f2aa92bfe7ac6b49965361e02312537e9a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 23:27:51 +0530 Subject: [PATCH 4/7] fix(item-attribute): clear attribute values when marking numeric Marking an attribute numeric hides the Item Attribute Values grid but leaves its rows in the doc, whose mandatory Attribute Value / Abbreviation block the save client-side before the server can clear them. Clear the table on the client too so the save goes through. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 4afbd4d3d9de4414744870c0c17c9ef504928d55) (cherry picked from commit 374b340e734a4a32173cabc8e65d9c8ceb465afe) --- .../stock/doctype/item_attribute/item_attribute.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.js b/erpnext/stock/doctype/item_attribute/item_attribute.js index 22c7978ac3c..d6f0f259174 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.js +++ b/erpnext/stock/doctype/item_attribute/item_attribute.js @@ -1,4 +1,13 @@ // Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on("Item Attribute", {}); +frappe.ui.form.on("Item Attribute", { + numeric_values(frm) { + // Numeric attributes have no discrete values; drop the rows so their + // mandatory Attribute Value / Abbreviation don't block the save. + if (frm.doc.numeric_values) { + frm.clear_table("item_attribute_values"); + frm.refresh_field("item_attribute_values"); + } + }, +}); From 66912173bd6d31b45e19c46667b40da79d4d0a6c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 23:28:03 +0530 Subject: [PATCH 5/7] fix(item): rework multiple variant dialog for large numeric ranges The 'Create Multiple Variants' dialog rendered one checkbox per attribute value and read the numeric config from the variant attribute child row. This broke in several ways: - A template whose attribute was made numeric after being added kept numeric_values=0 on the child row, so the dialog treated it as non-numeric, queried the empty Item Attribute Value table, and showed no values. - Enumerating a large range (e.g. 1-100000) into checkboxes froze the browser. Rework the dialog: - Read numeric_values / from_range / to_range / increment from the Item Attribute master, and guard increment > 0. - Replace the checkbox-per-value list with one MultiSelectPills per attribute, with a search placeholder. - Stop enumerating numeric ranges: preview the first few values and validate typed input against the range on demand, so huge ranges stay instant. - Block variant creation with a modal error if any selected value or pending input is invalid (out of range, off-increment, or not a number), so garbage like '00A' can't reach creation. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 99152b83004d6719c97ccd8ed86e9cfed7540315) (cherry picked from commit 025d0cd7f341d81f21bb532db1e3affa86a418a6) --- erpnext/stock/doctype/item/item.js | 267 ++++++++++++++++++----------- 1 file changed, 170 insertions(+), 97 deletions(-) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 898708962a9..e58652dab3e 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -777,62 +777,135 @@ $.extend(erpnext.item, { function make_fields_from_attribute_values(attr_dict) { let fields = []; - let att_key = frm.doc.attributes.map((idx) => idx.attribute); - att_key.forEach((name, i) => { + let attributes = frm.doc.attributes.filter((row) => !row.disabled); + attributes.forEach((row, i) => { + let name = row.attribute; if (i % 3 === 0) { fields.push({ fieldtype: "Section Break" }); } - fields.push({ fieldtype: "Column Break", label: name }); + fields.push({ fieldtype: "Column Break" }); fields.push({ - fieldtype: "Data", - placeholder: "Search", - fieldname: `search_${frappe.scrub(name)}`, - onchange: function (e) { - let value = e.target.value; - let result = attr_dict[name].filter((attr_value) => - attr_value.toString().toLowerCase().includes(value.toLowerCase()) - ); - attr_dict[name].forEach((attr_value) => { - if (result.includes(attr_value)) { - me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 0); - } else { - me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 1); - } - }); - }, - }); - attr_dict[name].forEach((value) => { - fields.push({ - fieldtype: "Check", - label: value, - fieldname: value, - default: 0, - onchange: function () { - let selected_attributes = get_selected_attributes(); - let lengths = Object.keys(selected_attributes).map((key) => { - return selected_attributes[key].length; - }); - if (!lengths.length) { - me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants")); - me.multiple_variant_dialog.disable_primary_action(); - } else { - let no_of_combinations = lengths.reduce((a, b) => a * b, 1); - let msg; - if (no_of_combinations === 1) { - msg = __("Make {0} Variant", [no_of_combinations]); - } else { - msg = __("Make {0} Variants", [no_of_combinations]); - } - me.multiple_variant_dialog.get_primary_btn().html(msg); - me.multiple_variant_dialog.enable_primary_action(); - } - }, - }); + fieldtype: "MultiSelectPills", + label: name, + fieldname: frappe.scrub(name), + placeholder: __("Search values..."), + get_data: (txt) => get_attribute_suggestions(attr_dict[name], txt), + onchange: update_primary_action, }); }); return fields; } + function get_attribute_suggestions(spec, txt) { + if (!spec) return []; + return Array.isArray(spec) ? filter_list(spec, txt) : numeric_suggestions(spec, txt); + } + + // Cap matches so a long value list never hands everything to Awesomplete, + // which would freeze the browser. + function filter_list(values, txt) { + txt = (txt || "").toLowerCase(); + let matches = []; + for (let value of values) { + if (!txt || value.toLowerCase().includes(txt)) { + matches.push(value); + if (matches.length >= 50) break; + } + } + return matches; + } + + // Numeric ranges aren't enumerated. With no input, preview the first few + // values; once the user types, accept it only if it lies on the increment + // within [from, to]. Both paths are cheap even for huge ranges. + function numeric_suggestions(range, txt) { + let { from_range: from, to_range: to, increment } = range; + if (!(increment > 0) || from > to) return []; + + txt = (txt || "").trim(); + if (!txt) { + let preview = []; + for ( + let value = from; + value <= to && preview.length < 50; + value = flt(value + increment, 6) + ) { + preview.push(String(value)); + } + return preview; + } + + return is_valid_attribute_value(range, txt) ? [String(flt(txt, 6))] : []; + } + + function is_valid_attribute_value(spec, value) { + if (!spec || !value) return false; + if (Array.isArray(spec)) return spec.includes(value); + + let { from_range: from, to_range: to, increment } = spec; + if (!(increment > 0)) return false; + + // Reject anything that isn't cleanly a number ("abc", "5000xyz", ""); + // flt would coerce these to 0 and wrongly accept them. + let text = String(value).trim(); + let num = Number(text); + if (text === "" || !Number.isFinite(num)) return false; + + if (num < from || num > to) return false; + let steps = (num - from) / increment; + return Math.abs(Math.round(steps) - steps) <= 1e-6; + } + + // Block variant creation if ANY value is invalid. Checks both the committed + // pills and any text still sitting in the input box (typed but not selected), + // so garbage like "00A" can never slip through to creation. + function validate_selected_attributes() { + let invalid = []; + frm.doc.attributes.forEach((row) => { + if (row.disabled) return; + let field = me.multiple_variant_dialog.get_field(frappe.scrub(row.attribute)); + if (!field) return; + + let spec = attr_val_fields[row.attribute]; + let values = (field.get_value() || []).slice(); + let pending = (field.$input?.val() || "").trim(); + if (pending) values.push(pending); + + let bad = [...new Set(values.filter((v) => !is_valid_attribute_value(spec, v)))]; + if (bad.length) { + invalid.push(`${frappe.utils.escape_html(row.attribute)}: ${bad.join(", ")}`); + } + }); + + if (invalid.length) { + frappe.throw({ + title: __("Invalid Attribute Values"), + message: + __("Please remove the following invalid values before creating variants:") + + "

" + + invalid.join("
"), + indicator: "red", + }); + } + } + + function update_primary_action() { + let selected_attributes = get_selected_attributes(); + let counts = Object.keys(selected_attributes).map((key) => selected_attributes[key].length); + if (!counts.length) { + me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants")); + me.multiple_variant_dialog.disable_primary_action(); + } else { + let no_of_combinations = counts.reduce((a, b) => a * b, 1); + let msg = + no_of_combinations === 1 + ? __("Make {0} Variant", [no_of_combinations]) + : __("Make {0} Variants", [no_of_combinations]); + me.multiple_variant_dialog.get_primary_btn().html(msg); + me.multiple_variant_dialog.enable_primary_action(); + } + } + function make_and_show_dialog(fields) { me.multiple_variant_dialog = new frappe.ui.Dialog({ title: __("Select Attribute Values"), @@ -858,6 +931,8 @@ $.extend(erpnext.item, { }); me.multiple_variant_dialog.set_primary_action(__("Create Variants"), () => { + validate_selected_attributes(); + let selected_attributes = get_selected_attributes(); let use_template_image = me.multiple_variant_dialog.get_value("use_template_image"); @@ -885,72 +960,70 @@ $.extend(erpnext.item, { }); }); - $($(me.multiple_variant_dialog.$wrapper.find(".form-column")).find(".frappe-control")).css( - "margin-bottom", - "0px" - ); - me.multiple_variant_dialog.disable_primary_action(); me.multiple_variant_dialog.clear(); me.multiple_variant_dialog.show(); - me.multiple_variant_dialog.$wrapper - .find("div[data-fieldname^='search_']") - .find(".clearfix") - .hide(); } function get_selected_attributes() { let selected_attributes = {}; - me.multiple_variant_dialog.$wrapper.find(".form-column").each((i, col) => { - if (i === 0) return; - let attribute_name = $(col).find(".column-label").html().trim(); - selected_attributes[attribute_name] = []; - let checked_opts = $(col).find(".checkbox input"); - checked_opts.each((i, opt) => { - if ($(opt).is(":checked")) { - selected_attributes[attribute_name].push($(opt).attr("data-fieldname")); - } - }); - if (!selected_attributes[attribute_name].length) { - delete selected_attributes[attribute_name]; + frm.doc.attributes.forEach((row) => { + if (row.disabled) return; + let values = me.multiple_variant_dialog.get_value(frappe.scrub(row.attribute)); + if (values && values.length) { + selected_attributes[row.attribute] = values; } }); - return selected_attributes; } frm.doc.attributes.forEach(function (d) { if (!d.disabled) { let p = new Promise((resolve) => { - if (!d.numeric_values) { - frappe - .call({ - method: "frappe.client.get_list", - args: { - doctype: "Item Attribute Value", - filters: [["parent", "=", d.attribute]], - fields: ["attribute_value"], - limit_page_length: 0, - parent: "Item Attribute", - order_by: "idx", - }, - }) - .then((r) => { - if (r.message) { - attr_val_fields[d.attribute] = r.message.map(function (d) { - return d.attribute_value; + // Read the numeric configuration from the Item Attribute master + // instead of the variant attribute row, which may be stale or + // blank if the attribute was made numeric after it was added here. + frappe.db + .get_value("Item Attribute", d.attribute, [ + "numeric_values", + "from_range", + "to_range", + "increment", + ]) + .then((res) => { + let attr = res.message || {}; + + if (!attr.numeric_values) { + frappe + .call({ + method: "frappe.client.get_list", + args: { + doctype: "Item Attribute Value", + filters: [["parent", "=", d.attribute]], + fields: ["attribute_value"], + limit_page_length: 0, + parent: "Item Attribute", + order_by: "idx", + }, + }) + .then((r) => { + attr_val_fields[d.attribute] = (r.message || []).map( + (row) => row.attribute_value + ); + resolve(); }); - resolve(); - } - }); - } else { - let values = []; - for (var i = d.from_range; i <= d.to_range; i = flt(i + d.increment, 6)) { - values.push(i); - } - attr_val_fields[d.attribute] = values; - resolve(); - } + } else { + // Store the range instead of enumerating it; a large range + // (e.g. 1-100000) is slow to build and to search. Values are + // validated against the range on demand while typing. + attr_val_fields[d.attribute] = { + from_range: flt(attr.from_range), + to_range: flt(attr.to_range), + increment: flt(attr.increment), + }; + resolve(); + } + }); }); promises.push(p); From 56d065b919a5a3348dfc85ba91ce37539eb14e5d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 23:37:32 +0530 Subject: [PATCH 6/7] fix(item): error on uncommitted input and escape values in variant dialog Address review feedback: - A typed-but-not-selected value passed validation yet was dropped by get_selected_attributes (reads committed pills only). Treat any pending input as an error so it is never silently omitted from creation. - Escape pill / pending values before interpolating them into the HTML error message. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit d4da9a3d7df040392e591a9bb075d971506ea5df) (cherry picked from commit 04c834d6a9adf4a84d7a69ce190f2a8c6a4941ab) --- erpnext/stock/doctype/item/item.js | 36 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index e58652dab3e..8bb5bc373e7 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -856,34 +856,40 @@ $.extend(erpnext.item, { return Math.abs(Math.round(steps) - steps) <= 1e-6; } - // Block variant creation if ANY value is invalid. Checks both the committed - // pills and any text still sitting in the input box (typed but not selected), - // so garbage like "00A" can never slip through to creation. + // Block variant creation if anything is wrong: an invalid committed pill, or + // text typed but not added as a pill (which get_selected_attributes would + // otherwise drop silently). The user must fix each before creation proceeds. function validate_selected_attributes() { - let invalid = []; + let errors = []; frm.doc.attributes.forEach((row) => { if (row.disabled) return; let field = me.multiple_variant_dialog.get_field(frappe.scrub(row.attribute)); if (!field) return; + let attribute = frappe.utils.escape_html(row.attribute); let spec = attr_val_fields[row.attribute]; - let values = (field.get_value() || []).slice(); - let pending = (field.$input?.val() || "").trim(); - if (pending) values.push(pending); - let bad = [...new Set(values.filter((v) => !is_valid_attribute_value(spec, v)))]; - if (bad.length) { - invalid.push(`${frappe.utils.escape_html(row.attribute)}: ${bad.join(", ")}`); + let invalid = [ + ...new Set((field.get_value() || []).filter((v) => !is_valid_attribute_value(spec, v))), + ]; + if (invalid.length) { + let values = invalid.map(frappe.utils.escape_html).join(", "); + errors.push(__("{0}: remove invalid value(s) {1}", [attribute, values])); + } + + let pending = (field.$input?.val() || "").trim(); + if (pending) { + let value = frappe.utils.escape_html(pending); + errors.push( + __("{0}: select the typed value {1} from the list or clear it", [attribute, value]) + ); } }); - if (invalid.length) { + if (errors.length) { frappe.throw({ title: __("Invalid Attribute Values"), - message: - __("Please remove the following invalid values before creating variants:") + - "

" + - invalid.join("
"), + message: errors.join("
"), indicator: "red", }); } From d1d3b241ae7bc21d18cf830a4bacd568e21a2a19 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Fri, 3 Jul 2026 10:29:44 +0000 Subject: [PATCH 7/7] chore(release): Bumped to Version 16.26.2 ## [16.26.2](https://github.com/frappe/erpnext/compare/v16.26.1...v16.26.2) (2026-07-03) ### Bug Fixes * **item-attribute:** clear attribute values when marking numeric ([1b1ea7f](https://github.com/frappe/erpnext/commit/1b1ea7f2aa92bfe7ac6b49965361e02312537e9a)) * **item:** error on uncommitted input and escape values in variant dialog ([56d065b](https://github.com/frappe/erpnext/commit/56d065b919a5a3348dfc85ba91ce37539eb14e5d)) * **item:** rework multiple variant dialog for large numeric ranges ([6691217](https://github.com/frappe/erpnext/commit/66912173bd6d31b45e19c46667b40da79d4d0a6c)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 6fd003ba1fa..910a6c95ebd 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -6,7 +6,7 @@ import frappe from frappe.model.document import Document from frappe.utils.user import is_website_user -__version__ = "16.26.1" +__version__ = "16.26.2" def get_default_company(user=None):