mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-05 14:40:52 +00:00
Compare commits
10 Commits
version-16
...
v16.26.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d3b241ae | ||
|
|
9cdaa738f1 | ||
|
|
56d065b919 | ||
|
|
66912173bd | ||
|
|
1b1ea7f2aa | ||
|
|
fd00cebbd2 | ||
|
|
fe6c276e72 | ||
|
|
613b3c16c2 | ||
|
|
666becc670 | ||
|
|
628b932d55 |
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user