Compare commits

...

10 Commits

Author SHA1 Message Date
Frappe PR Bot
d1d3b241ae 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](1b1ea7f2aa))
* **item:** error on uncommitted input and escape values in variant dialog ([56d065b](56d065b919))
* **item:** rework multiple variant dialog for large numeric ranges ([6691217](66912173bd))
2026-07-03 10:29:44 +00:00
Mihir Kandoi
9cdaa738f1 Merge pull request #56848 from frappe/mergify/bp/version-16/pr-56742
fix(item): rework multiple variant dialog for large numeric ranges (backport #56741) (backport #56742)
2026-07-03 15:58:17 +05:30
Mihir Kandoi
56d065b919 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 <noreply@anthropic.com>
(cherry picked from commit d4da9a3d7d)
(cherry picked from commit 04c834d6a9)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
66912173bd 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 <noreply@anthropic.com>
(cherry picked from commit 99152b8300)
(cherry picked from commit 025d0cd7f3)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
1b1ea7f2aa 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 <noreply@anthropic.com>
(cherry picked from commit 4afbd4d3d9)
(cherry picked from commit 374b340e73)
2026-07-03 10:25:47 +00:00
Frappe PR Bot
fd00cebbd2 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](613b3c16c2))
2026-07-01 07:42:04 +00:00
Mihir Kandoi
fe6c276e72 Merge pull request #56687 from frappe/mergify/bp/version-16/pr-56672
fix(stock): support quality inspection for stock entry by purpose (ba… (backport #56672)
2026-07-01 13:10:18 +05:30
Sudharsanan11
613b3c16c2 fix(stock): support quality inspection for stock entry by purpose (backport #56446)
(cherry picked from commit 40ca3b5e5d)
2026-07-01 07:28:45 +00:00
Frappe PR Bot
666becc670 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](52ea9641ce))
* adjust outstanding amount calculation in purchase and sales registers ([1a2a9b6](1a2a9b6cfc))
* allow rename for Quality Inspection Parameter ([410a772](410a772510))
* **asset:** conditionally show Is Fully Depreciated field ([0e730bf](0e730bf1c7))
* 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](0829cb45a8))
* **controllers:** fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) ([3abadc7](3abadc7a5f))
* **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](30ba950abd))
* 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](df1b4431b6))
* exclude virtual child doctypes from deletion in transaction deletion record ([8a66570](8a665709d2))
* gross profit calculation with rate adjustment entries ([9c5b063](9c5b063884))
* handle missing serial and batch bundle in print format ([c84d4a2](c84d4a282d))
* 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](b4d83e542a))
* job card timer issue (backport [#56405](https://github.com/frappe/erpnext/issues/56405)) ([#56406](https://github.com/frappe/erpnext/issues/56406)) ([54c45d7](54c45d7b22))
* **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](bd54c7fea8))
* **letter-head:** guard company lookups when doc has no company field ([89059a9](89059a990f))
* link portal address rows to web form ([3a480c0](3a480c08b1))
* manual backport of [#55896](https://github.com/frappe/erpnext/issues/55896) ([490e125](490e125267))
* party aliases should be no copy ([0b42241](0b42241682))
* 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](e3958ad7bb))
* remove dead bundle helper call from purchase receipt print format ([2eaa635](2eaa635ab6))
* remove frappe.utils from jinja context in process statement of accounts ([37ec2d0](37ec2d0edd))
* rewrite item rate calculation (backport [#56315](https://github.com/frappe/erpnext/issues/56315)) ([ef3d444](ef3d444a60))
* set mr status to received when per_received is 100 even if per_ordered < 100 ([4181246](41812462b4))
* show contextual balance label on party dashboard for net balances ([a886d0b](a886d0b445))
* skip qty over-allowance check for non-stock items only ([bc313dc](bc313dc09d))
* **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](2c18c16be6))
* 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](e834098c28))
* update qty in future SLEs when cancelling documents ([#56638](https://github.com/frappe/erpnext/issues/56638)) ([01374db](01374db8da))
* update_qty_in_future_sle skips SLEs with same posting datetime ([#56612](https://github.com/frappe/erpnext/issues/56612)) ([5aa62d1](5aa62d1cda))
* 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](762ce5c684))
* use correct variable to fetch valuation method ([d2c8df9](d2c8df9451))

### Features

* **accounts:** add configurable job timeout for Process Period Closing Voucher ([d389014](d389014e57))
2026-07-01 03:34:53 +00:00
Diptanil Saha
628b932d55 Merge pull request #56652 from frappe/version-16-hotfix
chore: release v16
2026-07-01 09:03:17 +05:30
9 changed files with 379 additions and 146 deletions

View File

@@ -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.2"
def get_default_company(user=None):

View File

@@ -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

View File

@@ -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() {

View File

@@ -777,62 +777,141 @@ $.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 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 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 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 (errors.length) {
frappe.throw({
title: __("Invalid Attribute Values"),
message: errors.join("<br>"),
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 +937,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 +966,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);

View File

@@ -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");
}
},
});

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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",