From 243312985030dc515e6fedf7b253af08f3c55b06 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:10:43 +0530 Subject: [PATCH 01/32] fix: show only template items in Variant Of filter --- erpnext/stock/doctype/item/item.json | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 8a458e8ea04..62561f19945 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -170,6 +170,7 @@ "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Variant Of", + "link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]", "options": "Item", "read_only": 1, "search_index": 1, From 2bf9fcb81718f882f33893d53b1bf6019f3a90fd Mon Sep 17 00:00:00 2001 From: Raghav Ruia Date: Wed, 24 Jun 2026 16:06:30 +0530 Subject: [PATCH 02/32] feat: confirmation dialog when enabling negative stock on Item Co-Authored-By: Claude Opus 4.8 --- erpnext/stock/doctype/item/item.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index ed6d4efe43d..d4cd4b61f6a 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -54,6 +54,28 @@ frappe.ui.form.on("Item", { } }, + allow_negative_stock(frm) { + if (!frm.doc.allow_negative_stock) { + return; + } + + let msg = __( + "Using negative stock disables FIFO/Moving average valuation when inventory is negative." + ); + msg += " "; + msg += __("This is considered dangerous from accounting point of view."); + msg += "
"; + msg += __("Do you still want to enable negative inventory?"); + + frappe.confirm( + msg, + () => {}, + () => { + frm.set_value("allow_negative_stock", 0); + } + ); + }, + setup: function (frm) { frm.add_fetch("attribute", "numeric_values", "numeric_values"); frm.add_fetch("attribute", "from_range", "from_range"); From 69d5d2bbc169c779681f7dcbe2c4d80a3a821667 Mon Sep 17 00:00:00 2001 From: Raghav Ruia Date: Fri, 26 Jun 2026 09:38:23 +0530 Subject: [PATCH 03/32] refactor: extract negative stock confirmation into shared util Deduplicate the identical confirmation dialog used by Item and Stock Settings into erpnext.utils.confirm_negative_stock, and collapse the message into a single translatable string. Co-Authored-By: Claude Opus 4.8 --- erpnext/public/js/utils.js | 12 +++++++++++ erpnext/stock/doctype/item/item.js | 20 +------------------ .../doctype/stock_settings/stock_settings.js | 20 +------------------ 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 51637316446..acaf7fb056e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -562,6 +562,18 @@ $.extend(erpnext.utils, { }, }); +erpnext.utils.confirm_negative_stock = function (frm) { + if (!frm.doc.allow_negative_stock) return; + + frappe.confirm( + __( + "Using negative stock disables FIFO/Moving average valuation when inventory is negative. This is considered dangerous from accounting point of view.
Do you still want to enable negative inventory?" + ), + () => {}, + () => frm.set_value("allow_negative_stock", 0) + ); +}; + erpnext.utils.select_alternate_items = function (opts) { const frm = opts.frm; const warehouse_field = opts.warehouse_field || "warehouse"; diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index d4cd4b61f6a..3bc7499aaee 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -55,25 +55,7 @@ frappe.ui.form.on("Item", { }, allow_negative_stock(frm) { - if (!frm.doc.allow_negative_stock) { - return; - } - - let msg = __( - "Using negative stock disables FIFO/Moving average valuation when inventory is negative." - ); - msg += " "; - msg += __("This is considered dangerous from accounting point of view."); - msg += "
"; - msg += __("Do you still want to enable negative inventory?"); - - frappe.confirm( - msg, - () => {}, - () => { - frm.set_value("allow_negative_stock", 0); - } - ); + erpnext.utils.confirm_negative_stock(frm); }, setup: function (frm) { diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 3d70c199d05..db0c7bb337c 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -96,25 +96,7 @@ frappe.ui.form.on("Stock Settings", { }, allow_negative_stock: function (frm) { - if (!frm.doc.allow_negative_stock) { - return; - } - - let msg = __( - "Using negative stock disables FIFO/Moving average valuation when inventory is negative." - ); - msg += " "; - msg += __("This is considered dangerous from accounting point of view."); - msg += "
"; - msg += __("Do you still want to enable negative inventory?"); - - frappe.confirm( - msg, - () => {}, - () => { - frm.set_value("allow_negative_stock", 0); - } - ); + erpnext.utils.confirm_negative_stock(frm); }, auto_insert_price_list_rate_if_missing(frm) { if (!frm.doc.auto_insert_price_list_rate_if_missing) return; From 54da9fc27a4df6a0511fa52cc908cd818a4a4958 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:26:06 +0530 Subject: [PATCH 04/32] fix: update modified timestamp in item.json --- erpnext/stock/doctype/item/item.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 62561f19945..e111a9f14bc 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -1091,7 +1091,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-05-27 10:18:46.862670", + "modified": "2026-07-05 23:24:45.734144", "modified_by": "Administrator", "module": "Stock", "name": "Item", From c0cfe5f363fa04da18c5efcbe57d7c4e50a5a9a5 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 6 Jul 2026 14:15:29 +0530 Subject: [PATCH 05/32] fix: rename variant item_code/item_name when attribute abbreviation changes Item Attribute abbreviations only got baked into a variant's item_code and item_name at creation time (make_variant_item_code returns early once item_code is set). Renaming an abbreviation afterwards left every existing variant stuck with the stale code, silently out of sync with its own attribute. Detect abbreviation renames on Item Attribute save, find every variant using the affected value, and rebuild+rename its item_code via frappe.rename_doc so linked records follow along. item_name is rebuilt in lockstep from the template's item_name, even if it had since been customized, since both fields are meant to be derived from the same abbreviation. --- erpnext/controllers/item_variant.py | 62 +++++++++++++++++++ .../doctype/item_attribute/item_attribute.py | 2 + 2 files changed, 64 insertions(+) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 4dadc91da3b..3e4f632307e 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -186,6 +186,68 @@ def update_variant_attribute_values(item_attribute): frappe.flags.attribute_values = None +def get_attribute_abbr_renames(item_attribute): + """Return the set of (current) attribute values whose abbreviation was renamed.""" + if item_attribute.numeric_values: + return set() + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return set() + + old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values} + changed_values = set() + + for row in item_attribute.item_attribute_values: + if row.name in old_abbrs and old_abbrs[row.name] != row.abbr: + changed_values.add(row.attribute_value) + + return changed_values + + +def update_variant_item_codes_for_abbr_renames(item_attribute): + """Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation.""" + changed_values = get_attribute_abbr_renames(item_attribute) + if not changed_values: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + variant_names = ( + frappe.qb.from_(item_variant_table) + .select(item_variant_table.parent) + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value.isin(list(changed_values))) + .distinct() + .run(pluck=True) + ) + + for variant_name in variant_names: + rename_variant_item_code(variant_name) + + +def rename_variant_item_code(variant_name): + """Recompute a variant's item_code/item_name from its template and current attribute abbreviations, + renaming the Item if it has changed.""" + variant = frappe.get_doc("Item", variant_name) + if not variant.variant_of: + return + + template = frappe.get_cached_doc("Item", variant.variant_of) + + new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes}) + make_variant_item_code(template.item_code, template.item_name, new_code) + + if not new_code.item_code or new_code.item_code == variant.item_code: + return + + frappe.rename_doc("Item", variant.item_code, new_code.item_code) + + # Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so + # item_name is always rebuilt here too, even if it had since been customized away from that pattern. + if new_code.item_name and new_code.item_name != variant.item_name: + frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name) + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 822d257e050..ae2029e3215 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -10,6 +10,7 @@ from frappe.utils import flt from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, update_variant_attribute_values, + update_variant_item_codes_for_abbr_renames, validate_is_incremental, validate_item_attribute_value, ) @@ -46,6 +47,7 @@ class ItemAttribute(Document): def on_update(self): update_variant_attribute_values(self) + update_variant_item_codes_for_abbr_renames(self) self.validate_exising_items() self.set_enabled_disabled_in_items() From e718a70b2603a19fc6c9f2213be34f313ef68be2 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 6 Jul 2026 14:15:40 +0530 Subject: [PATCH 06/32] test: cover variant item_code/item_name rename on abbreviation change Add regression coverage for the new abbreviation-rename propagation: a simple item_code rename, item_name derived from a template whose item_name differs from its item_code, and a manually customized item_name getting rebuilt rather than left stale. --- erpnext/stock/doctype/item/test_item.py | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 7569d1c538e..425d5e4692a 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -506,6 +506,100 @@ class TestItem(ERPNextTestSuite): "Large", ) + def test_rename_attribute_abbr_updates_variant_item_code(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L")) + self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"), + "_Test Variant Item-LRG", + ) + + def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self): + # item_name can be derived from the template's item_name, which may differ from its + # item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name + # must follow the abbreviation rename the same way item_code does. + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1) + + template = frappe.get_doc("Item", "_Test Variant Item").as_dict() + template = frappe.get_doc( + { + "doctype": "Item", + "item_code": "_Test Variant Item Diff", + "item_name": "Test Variant Friendly Name", + "item_group": template.item_group, + "stock_uom": template.stock_uom, + "has_variants": 1, + "attributes": [{"attribute": "Test Size"}], + } + ) + template.insert() + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)) + + variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"}) + variant.save() + self.assertEqual(variant.item_code, "_Test Variant Item Diff-L") + self.assertEqual(variant.item_name, "Test Variant Friendly Name-L") + + # even a manually customized item_name (unrelated to the auto-generated pattern) must be + # rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep. + frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name") + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"), + "Test Variant Friendly Name-LRG", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) From e6f9149ad70bb8a96993a8216954eee5f9eea367 Mon Sep 17 00:00:00 2001 From: S Sakthivel Murugan Date: Thu, 25 Jun 2026 11:11:40 +0530 Subject: [PATCH 07/32] fix: use company currency instead of global default in report --- erpnext/accounts/report/cash_flow/cash_flow.py | 1 + .../accounts/report/gross_profit/gross_profit.py | 2 ++ .../purchase_order_trends/purchase_order_trends.py | 4 ++++ erpnext/controllers/trends.py | 13 ++++++++++--- .../report/quotation_trends/quotation_trends.py | 6 ++++-- .../report/sales_order_trends/sales_order_trends.py | 4 ++++ .../delivery_note_trends/delivery_note_trends.py | 4 ++++ .../report/landed_cost_report/landed_cost_report.py | 6 ++++-- .../purchase_receipt_trends.py | 4 ++++ 9 files changed, 37 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index e6eae689ca9..f0835bac439 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -81,6 +81,7 @@ def execute(filters=None): "parent_section": None, "indent": 0.0, "section": cash_flow_section["section_header"], + "currency": company_currency, } ) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index af209a67f25..c600226e9ee 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ ) if total_base_amount else 0, + "currency": filters.currency, } ) ) @@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_percent": flt(gross_profit_percent, currency_precision), + "currency": filters.currency, } total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]] diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py index dd518e838ad..4bbd14e76ae 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -4,6 +4,7 @@ from frappe import _ +import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -50,6 +51,7 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] + company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -60,4 +62,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": company_currency, } diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 530b6574a42..f65dd29985b 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.utils import DateTimeLikeObject, getdate, today +import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -214,7 +215,7 @@ def get_data(filters, conditions): data.append(des) - total_row = calculate_total_row(data1, conditions["columns"]) + total_row = calculate_total_row(data1, conditions["columns"], filters.get("company")) data.append(total_row) else: data = frappe.db.sql( @@ -239,20 +240,23 @@ def get_data(filters, conditions): as_list=1, ) - total_row = calculate_total_row(data, conditions["columns"]) + total_row = calculate_total_row(data, conditions["columns"], filters.get("company")) data.append(total_row) return data -def calculate_total_row(data, columns): +def calculate_total_row(data, columns, company=None): def wrap_in_quotes(label): return f"'{label}'" total_values = {} + currency_col_idx = None for i, col in enumerate(columns): if "Float" in col or "Currency/currency" in col: total_values[i] = 0 + if col.split(":")[0] == "Currency": + currency_col_idx = i for row in data: for i in total_values.keys(): @@ -262,6 +266,9 @@ def calculate_total_row(data, columns): for i in range(1, len(columns)): total_row.append(total_values.get(i, None)) + if currency_col_idx is not None: + total_row[currency_col_idx] = company and erpnext.get_company_currency(company) + return total_row diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py index 92f9d17a9c7..57c6cc4e2e3 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -1,9 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - from frappe import _ +import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -50,7 +50,7 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] - + company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -59,4 +59,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": company_currency, } diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py index ca11b8302de..71b31d9b175 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -4,6 +4,7 @@ from frappe import _ +import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -50,6 +51,7 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] + company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -58,4 +60,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": company_currency, } diff --git a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py index a456bad72d7..8e98a6832e5 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -4,6 +4,7 @@ from frappe import _ +import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -45,6 +46,7 @@ def get_chart_data(data, filters): labels.append(row[0]) datapoints.append(row[-1]) + company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -52,4 +54,6 @@ def get_chart_data(data, filters): }, "type": "bar", "fieldtype": "Currency", + "options": "currency", + "currency": company_currency, } diff --git a/erpnext/stock/report/landed_cost_report/landed_cost_report.py b/erpnext/stock/report/landed_cost_report/landed_cost_report.py index b5738c2e20f..7b8503e8537 100644 --- a/erpnext/stock/report/landed_cost_report/landed_cost_report.py +++ b/erpnext/stock/report/landed_cost_report/landed_cost_report.py @@ -24,6 +24,7 @@ def get_columns() -> list[dict]: "label": _("Total Landed Cost"), "fieldname": "landed_cost", "fieldtype": "Currency", + "options": "currency", }, { "label": _("Purchase Voucher Type"), @@ -49,6 +50,8 @@ def get_columns() -> list[dict]: def get_data(filters) -> list[list]: + company_currency = frappe.get_cached_value("Company", filters.company, "default_currency") + landed_cost_vouchers = get_landed_cost_vouchers(filters) or {} landed_vouchers = list(landed_cost_vouchers.keys()) vendor_invoices = {} @@ -57,7 +60,6 @@ def get_data(filters) -> list[list]: data = [] - print(vendor_invoices) for name, vouchers in landed_cost_vouchers.items(): res = { "name": name, @@ -72,6 +74,7 @@ def get_data(filters) -> list[list]: "landed_cost": d.landed_cost, "voucher_type": d.voucher_type, "voucher_no": d.voucher_no, + "currency": company_currency, } ) else: @@ -88,7 +91,6 @@ def get_data(filters) -> list[list]: if vendor_invoice_list and len(vendor_invoice_list) > len(vouchers): for row in vendor_invoice_list[last_index + 1 :]: - print(row) data.append({"vendor_invoice": row}) return data diff --git a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py index 9d313b477a3..1f7098ba806 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py @@ -4,6 +4,7 @@ from frappe import _ +import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -44,6 +45,7 @@ def get_chart_data(data, filters): labels.append(row[0]) datapoints.append(row[-1]) + company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { @@ -53,4 +55,6 @@ def get_chart_data(data, filters): "type": "bar", "colors": ["#5e64ff"], "fieldtype": "Currency", + "options": "currency", + "currency": company_currency, } From b72ecdda0ddb477913e5514fd6bf545b72dbca2b Mon Sep 17 00:00:00 2001 From: S Sakthivel Murugan Date: Sun, 5 Jul 2026 00:14:02 +0530 Subject: [PATCH 08/32] test: add regression test for trends chart total row --- .../purchase_order_trends.py | 15 +- .../test_purchase_order_trends.py | 166 ++++++++++++++++++ erpnext/controllers/trends.py | 13 +- .../quotation_trends/quotation_trends.py | 15 +- .../quotation_trends/test_quotation_trends.py | 92 ++++++++++ .../sales_order_trends/sales_order_trends.py | 14 +- .../test_sales_order_trends.py | 160 +++++++++++++++++ .../delivery_note_trends.py | 8 +- .../landed_cost_report/landed_cost_report.py | 12 +- .../purchase_receipt_trends.py | 8 +- 10 files changed, 470 insertions(+), 33 deletions(-) diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py index 4bbd14e76ae..f220b9a5308 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -4,7 +4,6 @@ from frappe import _ -import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -15,7 +14,6 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Order") data = get_data(filters, conditions) chart_data = get_chart_data(data, conditions, filters) - return conditions["columns"], data, None, chart_data @@ -40,9 +38,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -51,7 +55,6 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] - company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -63,5 +66,5 @@ def get_chart_data(data, conditions, filters): "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", "options": "currency", - "currency": company_currency, + "currency": conditions.get("company_currency"), } diff --git a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py index 90d84447cb7..d11ad290120 100644 --- a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py @@ -2,7 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import today +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite @@ -30,3 +33,166 @@ class TestPurchaseOrderTrends(ERPNextTestSuite): self.assertTrue(columns) supplier_rows = [row for row in data if row[0] == "_Test Supplier"] self.assertEqual(len(supplier_rows), 1) + + def test_total_row_not_double_counted_in_chart(self): + # Regression test for the fix in trends.calculate_total_row that populates the + # Total row's Currency column. Before the fix in get_chart_data (skipping the + # Total row by label instead of `if not row[start]`), that populated Currency + # cell made the Total-row-skip guard falsy, so the already-summed Total row got + # added into the chart a second time (a PO of qty=3, rate=100 -> 300 read as 600). + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=3, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + + self.assertTrue(columns) + self.assertTrue(data) + + # The Total row (present in `data`) must not be re-summed into the chart's datapoints. + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] # Total(Amt) is the last column + + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertEqual(chart_total, expected_total) + self.assertEqual(chart_total, 300) + + def test_chart_currency_matches_company_currency(self): + # Regression test: the chart's "currency" key should reflect the transacting + # company's currency (conditions["company_currency"]), not a stale global default. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=1, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + _columns, _data, _message, chart = execute(filters) + + expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency") + self.assertEqual(chart["currency"], expected_currency) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is split across two suppliers -> two detail rows under one header row. + # _Test Item 2 has only one supplier -> exactly one detail row under its header row. + # A regression that double-counts header rows would inflate the chart above 600; + # a regression that zeroes single-group rows would report less than 600. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier 1", qty=2, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 (item/supplier) + 200 (item/supplier1) + 100 (item2/supplier) = 600 + self.assertEqual(expected_total, 600) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_supplier_group_by_item(self): + # Same regression, opposite role assignment: based_on="Supplier" with group_by="Item". + # Supplier's based_on_cols (Supplier, Supplier Name, Supplier Group, Currency) put the + # group_by placeholder at a different column index than the Item-based_on case above, + # exercising the alternate `inc`/`ind` arithmetic. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Supplier", + "group_by": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=2, rate=150, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index f65dd29985b..63e8671eb0a 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -43,6 +43,9 @@ def get_columns(filters, trans): "addl_tables": based_on_details["addl_tables"], "addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""), } + conditions["company_currency"] = ( + erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None + ) return conditions @@ -215,7 +218,7 @@ def get_data(filters, conditions): data.append(des) - total_row = calculate_total_row(data1, conditions["columns"], filters.get("company")) + total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency")) data.append(total_row) else: data = frappe.db.sql( @@ -240,13 +243,13 @@ def get_data(filters, conditions): as_list=1, ) - total_row = calculate_total_row(data, conditions["columns"], filters.get("company")) + total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency")) data.append(total_row) return data -def calculate_total_row(data, columns, company=None): +def calculate_total_row(data, columns, company_currency=None): def wrap_in_quotes(label): return f"'{label}'" @@ -255,7 +258,7 @@ def calculate_total_row(data, columns, company=None): for i, col in enumerate(columns): if "Float" in col or "Currency/currency" in col: total_values[i] = 0 - if col.split(":")[0] == "Currency": + if "Link/Currency" in col: currency_col_idx = i for row in data: @@ -267,7 +270,7 @@ def calculate_total_row(data, columns, company=None): total_row.append(total_values.get(i, None)) if currency_col_idx is not None: - total_row[currency_col_idx] = company and erpnext.get_company_currency(company) + total_row[currency_col_idx] = company_currency return total_row diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py index 57c6cc4e2e3..e5b62569394 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -3,7 +3,6 @@ from frappe import _ -import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0] for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -50,7 +55,7 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] - company_currency = erpnext.get_company_currency(filters.get("company")) + return { "data": { "labels": labels, @@ -60,5 +65,5 @@ def get_chart_data(data, conditions, filters): "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", "options": "currency", - "currency": company_currency, + "currency": conditions.get("company_currency"), } diff --git a/erpnext/selling/report/quotation_trends/test_quotation_trends.py b/erpnext/selling/report/quotation_trends/test_quotation_trends.py index 4ff03a5b53c..95ba6dd50bc 100644 --- a/erpnext/selling/report/quotation_trends/test_quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/test_quotation_trends.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe import _ from erpnext.selling.doctype.quotation.test_quotation import make_quotation from erpnext.selling.report.quotation_trends.quotation_trends import execute @@ -86,3 +87,94 @@ class TestQuotationTrends(ERPNextTestSuite): labels, after = self.run_report(based_on="Customer") self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is quoted to two customers -> two detail rows under one header row. + # _Test Item 2 is quoted to only one customer -> exactly one detail row under its + # header row. A regression that double-counts header rows would inflate the chart + # above 800; a regression that zeroes single-group rows would report less than 800. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + make_quotation( + item="_Test Item", party_name="_Test Customer", qty=4, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + item="_Test Item", party_name="_Test Customer 1", qty=1, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + item="_Test Item 2", party_name="_Test Customer", qty=3, rate=100, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 400 (item/customer) + 100 (item/customer1) + 300 (item2/customer) = 800 + self.assertEqual(expected_total, 800) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_customer_group_by_item(self): + # Same regression, opposite role assignment: based_on="Customer" with group_by="Item". + # Customer's based_on_cols for Quotation (Party, Party Name, Territory, Currency) put + # the group_by placeholder at a different column index than the Item-based_on case + # above, exercising the alternate `inc`/`ind` arithmetic. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Customer", + "group_by": "Item", + } + ) + + make_quotation( + party_name="_Test Customer", item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + party_name="_Test Customer", item="_Test Item 2", qty=1, rate=100, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + make_quotation( + item="_Test Item", party_name="_Test Customer", qty=2, rate=150, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py index 71b31d9b175..e0de678f22d 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -4,7 +4,6 @@ from frappe import _ -import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -51,7 +56,6 @@ def get_chart_data(data, conditions, filters): for i in range(len(row)): datapoints[i] += row[i] - company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -61,5 +65,5 @@ def get_chart_data(data, conditions, filters): "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", "options": "currency", - "currency": company_currency, + "currency": conditions.get("company_currency"), } diff --git a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py index 46f856a6f03..47a1c9679f8 100644 --- a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py @@ -2,7 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import today +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite @@ -51,3 +54,160 @@ class TestSalesOrderTrends(ERPNextTestSuite): self.assertTrue(columns) customer_rows = [row for row in data if row[0] == "_Test Customer"] self.assertEqual(len(customer_rows), 1) + + def test_total_row_not_double_counted_in_chart(self): + # Regression test for the fix in trends.calculate_total_row that populates the + # Total row's Currency column. Before the fix in get_chart_data (skipping the + # Total row by label instead of `if not row[start]`), that populated Currency + # cell made the Total-row-skip guard falsy, so the already-summed Total row got + # added into the chart a second time (an SO of qty=3, rate=100 -> 300 read as 600). + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order(item_code="_Test Item", qty=3, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] # Total(Amt) is the last column + + chart_total = sum(chart["data"]["datasets"][0]["values"]) + self.assertEqual(chart_total, expected_total) + self.assertEqual(chart_total, 300) + + def test_chart_currency_matches_company_currency(self): + # Regression test: the chart's "currency" key should reflect the transacting + # company's currency (conditions["company_currency"]), not a stale global default. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order(item_code="_Test Item", qty=1, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + _columns, _data, _message, chart = execute(filters) + expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency") + self.assertEqual(chart["currency"], expected_currency) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is split across two customers -> two detail rows under one header row. + # _Test Item 2 has only one customer -> exactly one detail row under its header row. + # A regression that double-counts header rows would inflate the chart above 600; + # a regression that zeroes single-group rows would report less than 600. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item", customer="_Test Customer 1", qty=2, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item 2", customer="_Test Customer", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 (item/customer) + 200 (item/customer1) + 100 (item2/customer) = 600 + self.assertEqual(expected_total, 600) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_customer_group_by_item(self): + # Same regression, opposite role assignment: based_on="Customer" with group_by="Item". + # Customer's based_on_cols (Customer, Customer Name, Territory, Currency) put the + # group_by placeholder at a different column index than the Item-based_on case above, + # exercising the alternate `inc`/`ind` arithmetic. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item 2", customer="_Test Customer", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Customer", + "group_by": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=2, rate=150, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py index 8e98a6832e5..1365a02ba25 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -4,7 +4,6 @@ from frappe import _ -import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -15,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Delivery Note") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -46,7 +45,6 @@ def get_chart_data(data, filters): labels.append(row[0]) datapoints.append(row[-1]) - company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { "labels": labels, @@ -55,5 +53,5 @@ def get_chart_data(data, filters): "type": "bar", "fieldtype": "Currency", "options": "currency", - "currency": company_currency, + "currency": conditions.get("company_currency"), } diff --git a/erpnext/stock/report/landed_cost_report/landed_cost_report.py b/erpnext/stock/report/landed_cost_report/landed_cost_report.py index 7b8503e8537..18473c51b24 100644 --- a/erpnext/stock/report/landed_cost_report/landed_cost_report.py +++ b/erpnext/stock/report/landed_cost_report/landed_cost_report.py @@ -4,6 +4,8 @@ import frappe from frappe import _ +import erpnext + def execute(filters: dict | None = None): columns = get_columns() @@ -26,6 +28,13 @@ def get_columns() -> list[dict]: "fieldtype": "Currency", "options": "currency", }, + { + "label": _("Currency"), + "fieldname": "currency", + "fieldtype": "Link", + "options": "Currency", + "hidden": 1, + }, { "label": _("Purchase Voucher Type"), "fieldname": "voucher_type", @@ -50,8 +59,7 @@ def get_columns() -> list[dict]: def get_data(filters) -> list[list]: - company_currency = frappe.get_cached_value("Company", filters.company, "default_currency") - + company_currency = erpnext.get_company_currency(filters.get("company")) landed_cost_vouchers = get_landed_cost_vouchers(filters) or {} landed_vouchers = list(landed_cost_vouchers.keys()) vendor_invoices = {} diff --git a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py index 1f7098ba806..4210d1a3604 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py @@ -4,7 +4,6 @@ from frappe import _ -import erpnext from erpnext.controllers.trends import get_columns, get_data @@ -15,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Receipt") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -45,7 +44,6 @@ def get_chart_data(data, filters): labels.append(row[0]) datapoints.append(row[-1]) - company_currency = erpnext.get_company_currency(filters.get("company")) return { "data": { @@ -56,5 +54,5 @@ def get_chart_data(data, filters): "colors": ["#5e64ff"], "fieldtype": "Currency", "options": "currency", - "currency": company_currency, + "currency": conditions.get("company_currency"), } From 015fa68fc04ff198d63cf549b6cd2be316d23134 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 6 Jul 2026 18:23:19 +0530 Subject: [PATCH 09/32] fix: make trend report based-on and group-by column labels translatable based_wise_columns_query() and group_wise_column() in trends.py built column labels as raw strings, so "Item", "Item Name", "Customer", "Supplier", "Territory", "Currency", etc. never went through _() and stayed in English regardless of the user's language, unlike the period and total columns right next to them which were already wrapped correctly. --- erpnext/controllers/trends.py | 143 +++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 18 deletions(-) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 530b6574a42..33e04e4bbf0 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -371,7 +371,10 @@ def based_wise_columns_query(based_on, trans): # based_on_cols, based_on_select, based_on_group_by, addl_tables if based_on == "Item": - based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"] + based_on_details["based_on_cols"] = [ + {"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"}, + {"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"}, + ] # item_name is an editable per-line field, not functionally dependent on item_code, so it # is aggregated (one row per item_code) rather than added to GROUP BY (which would split # the row and change the MariaDB row count). See get_data's group-by query. @@ -380,7 +383,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Item Group": - based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Item Group"), + "fieldtype": "Link", + "options": "Item Group", + "width": 120, + "fieldname": "item_group", + } + ] based_on_details["based_on_select"] = "t2.item_group," based_on_details["based_on_group_by"] = "t2.item_group" based_on_details["addl_tables"] = "" @@ -388,18 +399,47 @@ def based_wise_columns_query(based_on, trans): elif based_on == "Customer": if trans == "Quotation": based_on_details["based_on_cols"] = [ - "Party:Link/Customer:120", - "Party Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Party"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "party", + }, + {"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"}, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" ] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory," else: based_on_details["based_on_cols"] = [ - "Customer:Link/Customer:120", - "Customer Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "customer", + }, + { + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 120, + "fieldname": "customer_name", + }, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" @@ -410,16 +450,35 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Customer Group": - based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"] + based_on_details["based_on_cols"] = [ + { + "label": _("Customer Group"), + "fieldtype": "Link", + "options": "Customer Group", + "fieldname": "customer_group", + } + ] based_on_details["based_on_select"] = "t1.customer_group," based_on_details["based_on_group_by"] = "t1.customer_group" based_on_details["addl_tables"] = "" elif based_on == "Supplier": based_on_details["based_on_cols"] = [ - "Supplier:Link/Supplier:120", - "Supplier Name:Data:120", - "Supplier Group:Link/Supplier Group:140", + { + "label": _("Supplier"), + "fieldtype": "Link", + "options": "Supplier", + "width": 120, + "fieldname": "supplier", + }, + {"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"}, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + }, ] # supplier_name is a stored per-transaction field (not functionally dependent on supplier), so # it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped @@ -433,26 +492,58 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Supplier Group": - based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"] + based_on_details["based_on_cols"] = [ + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + } + ] based_on_details["based_on_select"] = "t3.supplier_group," based_on_details["based_on_group_by"] = "t3.supplier_group" based_on_details["addl_tables"] = ",`tabSupplier` t3" based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Territory": - based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + } + ] based_on_details["based_on_select"] = "t1.territory," based_on_details["based_on_group_by"] = "t1.territory" based_on_details["addl_tables"] = "" elif based_on == "Project": if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t1.project," based_on_details["based_on_group_by"] = "t1.project" based_on_details["addl_tables"] = "" elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t2.project," based_on_details["based_on_group_by"] = "t2.project" based_on_details["addl_tables"] = "" @@ -461,7 +552,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["based_on_select"] += "t4.default_currency as currency," based_on_details["based_on_group_by"] += ", t4.default_currency" - based_on_details["based_on_cols"].append("Currency:Link/Currency:120") + based_on_details["based_on_cols"].append( + { + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 120, + "fieldname": "currency", + } + ) based_on_details["addl_tables"] += ", `tabCompany` t4" based_on_details["addl_tables_relational_cond"] = ( based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name" @@ -472,6 +571,14 @@ def based_wise_columns_query(based_on, trans): def group_wise_column(group_by): if group_by: - return [group_by + ":Link/" + group_by + ":120"] + return [ + { + "label": _(group_by), + "fieldtype": "Link", + "options": group_by, + "width": 120, + "fieldname": frappe.scrub(group_by), + } + ] else: return [] From 6beb3d2509b7370e2cadc037dfc85a68490b6684 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 7 Jul 2026 11:43:52 +0530 Subject: [PATCH 10/32] perf: avoid per-row Warehouse doc fetches in auto reorder job get_item_warehouse_projected_qty ran an uncached frappe.get_doc per Bin row to walk the warehouse parent chain, re-fetching the same ancestors for every item sharing a warehouse. Preload the warehouse parent map once and walk it in memory instead. --- erpnext/stock/reorder_item.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index dc6168f52ac..9c8a93b4b69 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -182,6 +182,10 @@ def get_item_warehouse_projected_qty(items_to_consider): item_warehouse_projected_qty = {} items_to_consider = list(items_to_consider.keys()) + warehouse_parent_map = frappe._dict( + frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True) + ) + for item_code, warehouse, projected_qty in frappe.get_all( "Bin", filters={"item_code": ["in", items_to_consider], "warehouse": ["is", "set"]}, @@ -194,16 +198,14 @@ def get_item_warehouse_projected_qty(items_to_consider): if warehouse not in item_warehouse_projected_qty.get(item_code): item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse) + parent_warehouse = warehouse_parent_map.get(warehouse) - while warehouse_doc.parent_warehouse: - if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse): - item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt( - projected_qty - ) + while parent_warehouse: + if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse): + item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty) else: - item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse) + item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty) + parent_warehouse = warehouse_parent_map.get(parent_warehouse) return item_warehouse_projected_qty From 5da878d25f21c4e9d240d511cd9ca178ff67c45e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 7 Jul 2026 12:28:21 +0530 Subject: [PATCH 11/32] perf: batch bin lookups in delivery note stock update update_current_stock() in delivery_note.py used to call frappe.db.get_value("Bin", ...) separately for every row in items and every row in packed_items - so a delivery note with 200 items and 200 packed items made 400 separate database calls on every save. now it groups item codes by warehouse and fetches bin data with one query per distinct warehouse, then assigns actual_qty/projected_qty to each row from that result - same values as before, far fewer database calls, and no cross-product over-fetch across warehouses. --- .../doctype/delivery_note/delivery_note.py | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a3a1884cae2..e100da3c4a4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -401,22 +401,34 @@ class DeliveryNote(SellingController): frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): - if self.get("_action") and self._action != "update_after_submit": - for d in self.get("items"): - d.actual_qty = frappe.db.get_value( - "Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty" - ) + if not (self.get("_action") and self._action != "update_after_submit"): + return - for d in self.get("packed_items"): - bin_qty = frappe.db.get_value( - "Bin", - {"item_code": d.item_code, "warehouse": d.warehouse}, - ["actual_qty", "projected_qty"], - as_dict=True, - ) - if bin_qty: - d.actual_qty = flt(bin_qty.actual_qty) - d.projected_qty = flt(bin_qty.projected_qty) + warehouse_item_codes = {} + for d in self.get("items") + self.get("packed_items"): + warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code) + + if not warehouse_item_codes: + return + + bin_map = {} + for warehouse, item_codes in warehouse_item_codes.items(): + for b in frappe.get_all( + "Bin", + filters={"item_code": ["in", item_codes], "warehouse": warehouse}, + fields=["item_code", "actual_qty", "projected_qty"], + ): + bin_map[(b.item_code, warehouse)] = b + + for d in self.get("items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + d.actual_qty = bin_data.actual_qty if bin_data else None + + for d in self.get("packed_items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + if bin_data: + d.actual_qty = flt(bin_data.actual_qty) + d.projected_qty = flt(bin_data.projected_qty) def validate_expense_account(self): company_values = frappe.get_cached_value( From 2ec780cb353b74de25802b9062dca8c8c6956edd Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 7 Jul 2026 16:11:55 +0530 Subject: [PATCH 12/32] fix: validate planned end date is not before planned start date in work order --- erpnext/manufacturing/doctype/work_order/work_order.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 68f139305a5..5c50031aa80 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce, IfNull, Sum from frappe.utils import ( cint, flt, + get_datetime, get_link_to_form, now, nowdate, @@ -317,6 +318,10 @@ class WorkOrder(Document): self.validate_subcontracting_inward_order() def validate_dates(self): + if self.planned_start_date and self.planned_end_date: + if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date): + frappe.throw(_("Planned End Date cannot be before Planned Start Date")) + if self.actual_start_date and self.actual_end_date: if self.actual_end_date < self.actual_start_date: frappe.throw(_("Actual End Date cannot be before Actual Start Date")) From 174027bd57732f8636d088bd0cfe2709a1a2e30c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 9 Jul 2026 17:55:19 +0530 Subject: [PATCH 13/32] refactor(accounts): extract deferred-accounting and document-schedule out of AccountsController Continues the AccountsController service decomposition (Phase 5). - Add accounts/services/deferred_accounting.py with DeferredAccountingService owning the deferred revenue/expense validations (income/expense account defaulting and service start/end date checks). - Move the document-schedule orchestration (validate_all_documents_schedule and the invoice/non-invoice variants) into PaymentScheduleService, where they already delegated, removing the controller-to-service round trip. - Update the three validate() call sites; keep validate_auto_repeat_subscription_dates on the controller (still called by buying/selling controllers). No behavior change. accounts_controller.py 1818 -> 1745 lines. --- .../accounts/services/deferred_accounting.py | 57 ++++++++++++ erpnext/accounts/services/payment_schedule.py | 33 +++++++ erpnext/controllers/accounts_controller.py | 91 ++----------------- 3 files changed, 99 insertions(+), 82 deletions(-) create mode 100644 erpnext/accounts/services/deferred_accounting.py diff --git a/erpnext/accounts/services/deferred_accounting.py b/erpnext/accounts/services/deferred_accounting.py new file mode 100644 index 00000000000..8465d079955 --- /dev/null +++ b/erpnext/accounts/services/deferred_accounting.py @@ -0,0 +1,57 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Deferred revenue/expense accounting validations.""" + +import frappe +from frappe import _ +from frappe.utils import getdate + +DEFERRED_ACCOUNT_FIELD = { + "Sales Invoice": "deferred_revenue_account", + "Purchase Invoice": "deferred_expense_account", +} + + +class DeferredAccountingService: + def __init__(self, doc): + self.doc = doc + + def validate_income_expense_account(self) -> None: + account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype) + + for item in self.doc.get("items"): + if not self._is_deferred(item) or item.get(account_field): + continue + + default_account = frappe.get_cached_value("Company", self.doc.company, "default_" + account_field) + if not default_account: + frappe.throw( + _( + "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" + ).format(item.idx) + ) + item.set(account_field, default_account) + + def validate_start_and_end_date(self) -> None: + for item in self.doc.items: + if not self._is_deferred(item): + continue + + if not (item.service_start_date and item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start and End Date is required for deferred accounting").format( + item.idx + ) + ) + elif getdate(item.service_start_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start Date cannot be greater than Service End Date").format(item.idx) + ) + elif getdate(self.doc.posting_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(item.idx) + ) + + def _is_deferred(self, item) -> bool: + return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense")) diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py index d1ff7e91cb7..79cf352cd6c 100644 --- a/erpnext/accounts/services/payment_schedule.py +++ b/erpnext/accounts/services/payment_schedule.py @@ -293,6 +293,39 @@ class PaymentScheduleService: _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total") ) + def validate_all_documents_schedule(self) -> None: + if self.doc.doctype in ("Sales Invoice", "Purchase Invoice"): + self.validate_invoice_documents_schedule() + elif self.doc.doctype in ("Quotation", "Purchase Order", "Sales Order"): + self.validate_non_invoice_documents_schedule() + + def validate_invoice_documents_schedule(self) -> None: + doc = self.doc + if ( + doc.is_return + or (doc.doctype == "Purchase Invoice" and doc.is_paid) + or (doc.doctype == "Sales Invoice" and doc.is_pos) + or doc.get("is_opening") == "Yes" + ): + doc.payment_terms_template = "" + doc.payment_schedule = [] + + if doc.is_return: + return + + self.validate_payment_schedule_dates() + self.set_due_date() + self.set_payment_schedule() + if not doc.get("ignore_default_payment_terms_template"): + self.validate_payment_schedule_amount() + doc.validate_due_date() + doc.validate_advance_entries() + + def validate_non_invoice_documents_schedule(self) -> None: + self.set_payment_schedule() + self.validate_payment_schedule_dates() + self.validate_payment_schedule_amount() + def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: return frappe.get_value(doctype, po_or_so, "payment_terms_template") diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6b477471db7..56e6e381bb5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -234,7 +234,9 @@ class AccountsController(TransactionBase): if self.is_return: self.validate_qty() else: - self.validate_deferred_start_and_end_date() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_start_and_end_date() from erpnext.accounts.services.internal_transfer import InternalTransferService @@ -262,7 +264,9 @@ class AccountsController(TransactionBase): validate_return(self) - self.validate_all_documents_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(self).validate_all_documents_schedule() from erpnext.accounts.services.party_validation import PartyValidator @@ -286,7 +290,9 @@ class AccountsController(TransactionBase): self.set_advance_gain_or_loss() - self.validate_deferred_income_expense_account() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_income_expense_account() InternalTransferService(self).set_account() if self.doctype == "Purchase Invoice": @@ -504,89 +510,10 @@ class AccountsController(TransactionBase): ) ) - def validate_deferred_income_expense_account(self): - field_map = { - "Sales Invoice": "deferred_revenue_account", - "Purchase Invoice": "deferred_expense_account", - } - - for item in self.get("items"): - if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): - if not item.get(field_map.get(self.doctype)): - default_deferred_account = frappe.get_cached_value( - "Company", self.company, "default_" + field_map.get(self.doctype) - ) - if not default_deferred_account: - frappe.throw( - _( - "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" - ).format(item.idx) - ) - else: - item.set(field_map.get(self.doctype), default_deferred_account) - def validate_auto_repeat_subscription_dates(self): if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date): frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date")) - def validate_deferred_start_and_end_date(self): - for d in self.items: - if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"): - if not (d.service_start_date and d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start and End Date is required for deferred accounting").format( - d.idx - ) - ) - elif getdate(d.service_start_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start Date cannot be greater than Service End Date").format( - d.idx - ) - ) - elif getdate(self.posting_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx) - ) - - def validate_invoice_documents_schedule(self): - if ( - self.is_return - or (self.doctype == "Purchase Invoice" and self.is_paid) - or (self.doctype == "Sales Invoice" and self.is_pos) - or self.get("is_opening") == "Yes" - ): - self.payment_terms_template = "" - self.payment_schedule = [] - - if self.is_return: - return - - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.validate_payment_schedule_dates() - ps.set_due_date() - ps.set_payment_schedule() - if not self.get("ignore_default_payment_terms_template"): - ps.validate_payment_schedule_amount() - self.validate_due_date() - self.validate_advance_entries() - - def validate_non_invoice_documents_schedule(self): - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.set_payment_schedule() - ps.validate_payment_schedule_dates() - ps.validate_payment_schedule_amount() - - def validate_all_documents_schedule(self): - if self.doctype in ("Sales Invoice", "Purchase Invoice"): - self.validate_invoice_documents_schedule() - elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"): - self.validate_non_invoice_documents_schedule() - def before_print(self, settings=None): if self.doctype in [ "Purchase Order", From 758a837de4b7e40653e33fdf8110b988e71187c3 Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Thu, 9 Jul 2026 18:01:17 +0530 Subject: [PATCH 14/32] fix: update BOM operations when routing is changed The routing field handler only fetched operations from the routing when the operations table was empty. When a new BOM version is created (via "New Version"), operations are copied from the source BOM, so selecting a different routing left the old operations in place - both in the form and after saving. Drop the `!frm.doc.operations.length` guard from the routing handler so that (re)selecting a routing always refetches the operations from that routing via the existing get_routing method, which clears and repopulates the operations table. Co-Authored-By: Claude Opus 4.8 --- erpnext/manufacturing/doctype/bom/bom.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 9fbe4f1174c..7a002da2fac 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -586,7 +586,11 @@ frappe.ui.form.on("BOM", { }, routing(frm) { - if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { + // Refetch operations whenever the routing is (re)selected, so that + // changing the routing - e.g. on a new BOM version copied from another + // BOM - replaces the operations with those of the newly selected routing + // instead of keeping the old ones. + if (frm.doc.routing && frm.doc.with_operations) { frappe.call({ doc: frm.doc, method: "get_routing", From 5956d3e092ea992f96950d757a070e58e3f902a5 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 18:23:15 +0530 Subject: [PATCH 15/32] fix(stock): accept dict for doc arg in apply_price_list The type-hint refactor rejected the doc dict sent by the form controller; broaden the annotation to match ctx and fix the cts= kwarg typo in transaction_base. --- erpnext/stock/get_item_details.py | 2 +- erpnext/utilities/transaction_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index aa076894649..a58c1b037ef 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1594,7 +1594,7 @@ def get_batch_qty(batch_no: str, warehouse: str, item_code: str): @frappe.whitelist() @erpnext.normalize_ctx_input(ItemDetailsCtx) -def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | None = None): +def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | dict | None = None): """Apply pricelist on a document-like dict object and return as {'parent': dict, 'children': list} diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 85f2e83f8d9..dd071c0b717 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -575,7 +575,7 @@ class TransactionBase(StatusUpdater): "is_internal_customer": self.is_internal_customer, } # TODO: test method call impact on document - apply_price_list(cts=args, as_doc=True, doc=self) + apply_price_list(ctx=args, as_doc=True, doc=self) def delete_events(ref_type, ref_name): From 4d629df2997c0cb7c5bc31ca15e9fef8cf8ddab1 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 18:43:11 +0530 Subject: [PATCH 16/32] fix(stock): point stock entry client calls at services module The stock_entry_handler modules were moved to services; update the retention, expired-batch and subcontract call paths in the client so the whitelisted methods resolve again. --- .../stock/doctype/purchase_receipt/purchase_receipt.js | 4 ++-- erpnext/stock/doctype/stock_entry/stock_entry.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 71d2265879e..4a6f8d960d2 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -342,7 +342,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend make_retention_stock_entry() { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse", args: { company: cur_frm.doc.company, items: cur_frm.doc.items, @@ -455,7 +455,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.qty) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index fed14074419..9469ac48a62 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -518,7 +518,7 @@ frappe.ui.form.on("Stock Entry", { __("Expired Batches"), function () { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch.get_expired_batch_items", + method: "erpnext.stock.doctype.stock_entry.services.serial_batch.get_expired_batch_items", freeze: true, callback: function (r) { if (!r.exc && r.message) { @@ -692,7 +692,7 @@ frappe.ui.form.on("Stock Entry", { make_retention_stock_entry: function (frm) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse", args: { company: frm.doc.company, items: frm.doc.items, @@ -961,7 +961,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.purchase_order) { frm.set_value("subcontracting_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.purchase_order, target_doc: frm, freeze: true, @@ -973,7 +973,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.subcontracting_order) { frm.set_value("purchase_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.subcontracting_order, target_doc: frm, freeze: true, @@ -1187,7 +1187,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, From b625525b038805a3a41a16113dd00a065d2f4708 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 7 Jul 2026 16:57:28 +0530 Subject: [PATCH 17/32] fix(manufacturing): accept plain dict for doc in get_items_for_material_requests the whitelisted endpoint typed doc as str | frappe._dict | Document, so a json request body (a plain dict) failed pydantic type validation. widen to str | dict | Document, matching the convention used elsewhere in erpnext. --- .../doctype/production_plan/services/material_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index c4f12d8f1b7..21b21aabff1 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -132,7 +132,7 @@ class MaterialRequestService: @frappe.whitelist() def get_items_for_material_requests( - doc: str | frappe._dict | Document, + doc: str | dict | Document, warehouses: str | list | None = None, get_parent_warehouse_data: bool | int | None = None, ): From 0574a0d95e7cd73c297126e9c4ae3b4ae12820e3 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 04:16:12 +0530 Subject: [PATCH 18/32] feat(manufacturing): allow group warehouse for raw material availability in production plan add an optional raw material group warehouse on production plan. when set, raw material availability is checked across its child warehouses (bin rows aggregated), while material is still received into for warehouse. for warehouse is restricted to a child of the group and required when raw materials are fetched; a group warehouse can never reach a material request line. when the group is left blank, availability falls back to for warehouse and the previous flow. --- .../production_plan/production_plan.js | 35 +++++++++- .../production_plan/production_plan.json | 10 ++- .../production_plan/production_plan.py | 46 +++++++++++++ .../services/material_request.py | 65 +++++++++++++++++-- 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 2337b8d0246..3bef5d30712 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -40,10 +40,29 @@ frappe.ui.form.on("Production Plan", { }); frm.set_query("for_warehouse", function (doc) { + // when a group is chosen, For Warehouse must be one of its child warehouses + if (doc.raw_material_group_warehouse) { + return { + query: "erpnext.manufacturing.doctype.production_plan.production_plan.get_child_warehouses", + filters: { + group_warehouse: doc.raw_material_group_warehouse, + company: doc.company, + }, + }; + } + return { + filters: [ + ["Warehouse", "company", "=", doc.company], + ["Warehouse", "is_group", "=", 0], + ], + }; + }); + + frm.set_query("raw_material_group_warehouse", function (doc) { return { filters: { company: doc.company, - is_group: 0, + is_group: 1, }, }; }); @@ -102,6 +121,13 @@ frappe.ui.form.on("Production Plan", { }); }, + raw_material_group_warehouse(frm) { + // For Warehouse must sit inside the chosen group, so drop a stale selection + if (frm.doc.for_warehouse) { + frm.set_value("for_warehouse", null); + } + }, + refresh(frm) { if (frm.doc.docstatus === 1) { frm.trigger("show_progress"); @@ -451,6 +477,7 @@ frappe.ui.form.on("Production Plan", { frm.events.get_items_for_material_requests(frm); } else { const title = __("Transfer Materials For Warehouse {0}", [frm.doc.for_warehouse]); + const source_warehouse = frm.doc.raw_material_group_warehouse; var dialog = new frappe.ui.Dialog({ title: title, fields: [ @@ -459,6 +486,7 @@ frappe.ui.form.on("Production Plan", { fieldtype: "Table MultiSelect", fieldname: "warehouses", options: "Production Plan Material Request Warehouse", + default: source_warehouse ? [{ warehouse: source_warehouse }] : [], get_query: function () { return { filters: { @@ -515,8 +543,9 @@ frappe.ui.form.on("Production Plan", { download_materials_required(frm) { const warehouses_data = []; - if (frm.doc.for_warehouse) { - warehouses_data.push({ warehouse: frm.doc.for_warehouse }); + const availability_warehouse = frm.doc.raw_material_group_warehouse || frm.doc.for_warehouse; + if (availability_warehouse) { + warehouses_data.push({ warehouse: availability_warehouse }); } const fields = [ diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 32a67eae228..d7e5c48de1c 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -52,6 +52,7 @@ "include_safety_stock", "ignore_existing_ordered_qty", "column_break_25", + "raw_material_group_warehouse", "for_warehouse", "get_items_for_mr", "transfer_materials", @@ -318,6 +319,13 @@ "label": "For Warehouse", "options": "Warehouse" }, + { + "description": "Optional group warehouse. Raw material availability is checked across its child warehouses; material is still received into For Warehouse.", + "fieldname": "raw_material_group_warehouse", + "fieldtype": "Link", + "label": "Raw Material Group Warehouse", + "options": "Warehouse" + }, { "fieldname": "warehouses", "fieldtype": "Table MultiSelect", @@ -445,7 +453,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-12 19:48:09.302503", + "modified": "2026-07-07 00:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index be57e1108b8..64c1f81447b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -102,6 +102,7 @@ class ProductionPlan(Document): posting_date: DF.Date prod_plan_references: DF.Table[ProductionPlanItemReference] project: DF.Link | None + raw_material_group_warehouse: DF.Link | None reserve_stock: DF.Check sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_orders: DF.Table[ProductionPlanSalesOrder] @@ -144,8 +145,30 @@ class ProductionPlan(Document): validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() self.validate_material_request_type() + self.validate_raw_material_group_warehouse() self.enable_auto_reserve_stock() + def validate_raw_material_group_warehouse(self): + if not self.raw_material_group_warehouse: + return + + group = frappe.db.get_value( + "Warehouse", self.raw_material_group_warehouse, ["lft", "rgt", "is_group"], as_dict=True + ) + if not group.is_group: + frappe.throw( + _("{0} must be a group warehouse.").format(frappe.bold(_("Raw Material Group Warehouse"))) + ) + + if self.for_warehouse: + child = frappe.db.get_value("Warehouse", self.for_warehouse, ["lft", "rgt"], as_dict=True) + if not (group.lft <= child.lft and child.rgt <= group.rgt): + frappe.throw( + _("For Warehouse {0} must be a child of the group warehouse {1}.").format( + frappe.bold(self.for_warehouse), frappe.bold(self.raw_material_group_warehouse) + ) + ) + def enable_auto_reserve_stock(self): if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): self.reserve_stock = 1 @@ -466,3 +489,26 @@ class ProductionPlan(Document): def all_items_completed(self): return SubAssemblyService(self).all_items_completed() + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_child_warehouses( + doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict +): + "Leaf warehouses under the given group warehouse, for the For Warehouse link query." + bounds = frappe.db.get_value("Warehouse", filters.get("group_warehouse"), ["lft", "rgt"], as_dict=True) + if not bounds: + return [] + + wh = frappe.qb.DocType("Warehouse") + query = ( + frappe.qb.from_(wh) + .select(wh.name) + .where((wh.is_group == 0) & (wh.lft >= bounds.lft) & (wh.rgt <= bounds.rgt)) + ) + if filters.get("company"): + query = query.where(wh.company == filters.get("company")) + if txt: + query = query.where(wh[searchfield].like(f"%{txt}%")) + return query.limit(page_len).offset(start).run() diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index 21b21aabff1..fc2ad13df73 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -97,6 +97,13 @@ class MaterialRequestService: def _material_request_item(self, item, material_request_type, schedule_date): from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None + # a group warehouse cannot receive stock; it must never reach a Material Request line + if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.throw( + _("Cannot create Material Request for item {0} in group warehouse {1}.").format( + frappe.bold(item.item_code), frappe.bold(item.warehouse) + ) + ) project = ( frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None ) @@ -139,6 +146,7 @@ def get_items_for_material_requests( frappe.has_permission("Production Plan", "read", throw=True) doc = _normalize_mr_doc(doc) + _validate_group_warehouse_target(doc) warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data) doc["mr_items"] = [] @@ -163,6 +171,17 @@ def _normalize_mr_doc(doc): return doc +def _validate_group_warehouse_target(doc): + # the group only scopes availability; raw materials still need a concrete + # receiving warehouse, so for_warehouse is required once we generate items. + if doc.get("raw_material_group_warehouse") and not doc.get("for_warehouse"): + frappe.throw( + _("{0} is required to get raw materials when {1} is set.").format( + frappe.bold(_("For Warehouse")), frappe.bold(_("Raw Material Group Warehouse")) + ) + ) + + def _filter_warehouses(doc, warehouses, get_parent_warehouse_data): if not warehouses: return warehouses @@ -355,13 +374,18 @@ def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precisi def _build_mr_items(doc, so_item_details, ignore_ordered_qty): mr_items = [] consumed_qty = defaultdict(float) - warehouse = doc.get("for_warehouse") + # raw_material_group_warehouse (optional, group) only widens the availability + # scope to its child warehouses; material is still received into for_warehouse. + target_warehouse = doc.get("for_warehouse") + scope_warehouse = doc.get("raw_material_group_warehouse") or target_warehouse company = doc.get("company") include_safety_stock = doc.get("include_safety_stock") for sales_order, item_dict in so_item_details.items(): for details in item_dict.values(): - warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") + fallback = details.get("source_warehouse") or details.get("default_warehouse") + scope_warehouse = scope_warehouse or fallback + target_warehouse = target_warehouse or fallback row = _mr_item_for_details( doc, details, @@ -369,7 +393,8 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): company, ignore_ordered_qty, include_safety_stock, - warehouse, + scope_warehouse, + target_warehouse, consumed_qty, ) if row: @@ -378,10 +403,19 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): def _mr_item_for_details( - doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty + doc, + details, + sales_order, + company, + ignore_ordered_qty, + include_safety_stock, + warehouse, + target_warehouse, + consumed_qty, ): - bin_dict = get_bin_details(details, doc.company, warehouse) - bin_dict = bin_dict[0] if bin_dict else {} + # get_bin_details scopes to the warehouse's descendants, returning one row per + # child warehouse; sum them so a group warehouse reflects combined child stock. + bin_dict = _aggregate_bin_details(get_bin_details(details, doc.company, warehouse)) if details.qty <= 0: return None return get_material_request_items( @@ -392,11 +426,27 @@ def _mr_item_for_details( ignore_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ) +def _aggregate_bin_details(bin_list): + qty_fields = ( + "projected_qty", + "actual_qty", + "ordered_qty", + "reserved_qty_for_production", + "planned_qty", + ) + aggregated = {field: 0 for field in qty_fields} + for row in bin_list or []: + for field in qty_fields: + aggregated[field] += flt(row.get(field)) + return aggregated + + def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data): if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses): return mr_items @@ -428,6 +478,7 @@ def get_material_request_items( ignore_existing_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ): @@ -438,7 +489,7 @@ def get_material_request_items( item_group_defaults = get_item_group_defaults(row.item_code, company) conversion_factor = _mr_purchase_conversion_factor(row) return _material_request_item_row( - row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults + row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults ) From 2247ef9a500dcf46550eae8a0bacc95d97c5f9b0 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 04:16:30 +0530 Subject: [PATCH 19/32] test(manufacturing): add production plan group warehouse tests verify child-stock aggregation, transfer sourcing from child warehouses, that a for warehouse outside the group is rejected, and that a for warehouse is required when raw materials are fetched. --- .../production_plan/test_production_plan.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ac2b38ea216..e63bc8a2b09 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1592,6 +1592,104 @@ class TestProductionPlan(ERPNextTestSuite): for row in plan.mr_items: self.assertFalse(row.from_warehouse) + def _setup_group_rm_warehouse(self): + """FG + RM with a group raw-material warehouse (C1, C2) partially stocked (3 + 4).""" + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + group_warehouse = "_Test Warehouse Group - _TC" + child_1 = "_Test Warehouse Group-C1 - _TC" + child_2 = "_Test Warehouse Group-C2 - _TC" + + fg_item = "Test PP Group FG" + rm_item = "Test PP Group RM" + create_item(rm_item, valuation_rate=100) + create_item(fg_item, valuation_rate=100) + if not frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1}): + create_nested_bom({fg_item: {rm_item: {}}}, prefix="") + + make_stock_entry(item_code=rm_item, qty=3, rate=100, target=child_1) + make_stock_entry(item_code=rm_item, qty=4, rate=100, target=child_2) + + return frappe._dict( + group_warehouse=group_warehouse, + children={child_1, child_2}, + for_wh=child_1, # a leaf inside the group, used as For Warehouse + fg_item=fg_item, + rm_item=rm_item, + ) + + def test_group_raw_material_warehouse_aggregates_child_stock(self): + "Combined child stock (3 + 4) is used as projected qty; material targets For Warehouse." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests(plan.as_dict()) + + rm_rows = [d for d in mr_items if d.get("item_code") == data.rm_item] + self.assertEqual(len(rm_rows), 1) + # projected qty reflects the sum across both child warehouses, not a single child + self.assertEqual(flt(rm_rows[0].get("projected_qty")), 7.0) + # the group is only an availability scope; the row targets For Warehouse + self.assertEqual(rm_rows[0].get("warehouse"), data.for_wh) + + def test_group_raw_material_warehouse_transfers_from_child_warehouses(self): + "Material is transferred only from actual child warehouses, never the group node." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + ignore_existing_ordered_qty=1, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": data.group_warehouse}] + ) + + transfer_rows = [d for d in mr_items if d.get("material_request_type") == "Material Transfer"] + self.assertTrue(transfer_rows) + for row in transfer_rows: + self.assertIn(row.get("from_warehouse"), data.children) + for row in mr_items: + # a group warehouse must never be a Material Request target + self.assertNotEqual(row.get("warehouse"), data.group_warehouse) + + def test_for_warehouse_must_be_child_of_group(self): + "A For Warehouse outside the chosen group warehouse is rejected on save." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse="_Test Warehouse - _TC", # outside the group + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, plan.save) + + def test_for_warehouse_required_with_group_when_getting_raw_materials(self): + "A group warehouse without a For Warehouse is rejected when raw materials are fetched." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + raw_material_group_warehouse=data.group_warehouse, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, get_items_for_material_requests, plan.as_dict()) + def test_skip_available_qty_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom @@ -3122,6 +3220,7 @@ def create_production_plan(**args): "sub_assembly_warehouse": args.sub_assembly_warehouse, "reserve_stock": args.reserve_stock or 0, "for_warehouse": args.for_warehouse or None, + "raw_material_group_warehouse": args.raw_material_group_warehouse or None, } ) From 38da3fc76edaa6af8cdf3c72465b2f9c5287e6b9 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:57:20 +0530 Subject: [PATCH 20/32] fix: display outstanding amount using company default currency (#56785) Co-authored-by: S Sakthivel Murugan --- .../opening_invoice_creation_tool_item.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json index 6448d725de9..7389d0687b6 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json @@ -82,6 +82,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Outstanding Amount", + "options": "Company:company:default_currency", "reqd": 1 }, { @@ -136,7 +137,7 @@ ], "istable": 1, "links": [], - "modified": "2026-04-29 17:08:15.617047", + "modified": "2026-07-02 15:17:11.938499", "modified_by": "Administrator", "module": "Accounts", "name": "Opening Invoice Creation Tool Item", From 6a4c5b60626ef78ab89d81a10ab2a8f460529c05 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 2 Jul 2026 15:08:04 +0530 Subject: [PATCH 21/32] refactor: add payment ledger to ignore link --- .../exchange_rate_revaluation/exchange_rate_revaluation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 0ed30eaee52..15f8c0b0b2b 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -91,7 +91,7 @@ class ExchangeRateRevaluation(Document): ) def on_cancel(self): - self.ignore_linked_doctypes = "GL Entry" + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() def check_journal_entry_condition(self): From a0b14c0607e466be920edbfa8987ec1d9d051161 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 2 Jul 2026 17:52:03 +0530 Subject: [PATCH 22/32] refactor: reversal capability on exchange rate revaluation --- .../exchange_rate_revaluation.js | 34 ++++++-- .../exchange_rate_revaluation.py | 78 ++++++++++++++++--- .../test_exchange_rate_revaluation.py | 4 +- .../journal_entry/journal_entry_list.js | 5 +- 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index 2637e49d00a..fac8b582a22 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", { refresh: function (frm) { if (frm.doc.docstatus == 1) { frappe.call({ - method: "check_journal_entry_condition", + method: "check_journal_and_reversal", doc: frm.doc, callback: function (r) { if (r.message) { - frm.add_custom_button( - __("Journal Entries"), - function () { - return frm.events.make_jv(frm); - }, - __("Create") - ); + if (!r.message.journals_posted) { + frm.add_custom_button( + __("Journal Entries"), + function () { + return frm.events.make_jv(frm); + }, + __("Create") + ); + } else if (!r.message.reversals_posted) { + frm.add_custom_button( + __("Reversal Journal Entries"), + function () { + return frm.events.make_reverse_journal(frm); + }, + __("Create") + ); + } } }, }); @@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, }); }, + make_reverse_journal: function (frm) { + frappe.call({ + method: "make_reverse_journal", + doc: frm.doc, + freeze: true, + freeze_message: __("Reversing Journals..."), + }); + }, }); frappe.ui.form.on("Exchange Rate Revaluation Account", { diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 15f8c0b0b2b..84ba411c97f 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder import Criterion, Order from frappe.query_builder.functions import Max, NullIf, Sum -from frappe.utils import flt, get_link_to_form +from frappe.utils import flt, get_link_to_form, nowdate import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -94,22 +94,28 @@ class ExchangeRateRevaluation(Document): self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() - def check_journal_entry_condition(self): + def check_journal_and_reversal(self): exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + journals_posted = False + reversals_posted = False + + je = qb.DocType("Journal Entry") jea = qb.DocType("Journal Entry Account") journals = ( - qb.from_(jea) - .select(jea.parent) + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) .distinct() .where( (jea.reference_type == "Exchange Rate Revaluation") & (jea.reference_name == self.name) & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals ) - .run() + .run(pluck="name") ) - if journals: gle = qb.DocType("GL Entry") total_amt = ( @@ -124,12 +130,31 @@ class ExchangeRateRevaluation(Document): .run() ) - if total_amt and total_amt[0][0] != self.total_gain_loss: - return True + if total_amt and total_amt[0][0] == self.total_gain_loss: + journals_posted = True else: - return False + journals_posted = False - return True + # reverse journals + reverse_journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.notnull()) + ) + .run(pluck="name") + ) + if reverse_journals: + reversals_posted = True + else: + reversals_posted = False + + return {"journals_posted": journals_posted, "reversals_posted": reversals_posted} def fetch_and_calculate_accounts_data(self): accounts = self.get_accounts_data() @@ -347,6 +372,7 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def make_jv_entries(self): + frappe.has_permission("Journal Entry", "write", throw=True) zero_balance_jv = self.make_jv_for_zero_balance() if zero_balance_jv: frappe.msgprint( @@ -575,6 +601,38 @@ class ExchangeRateRevaluation(Document): journal_entry.save() return journal_entry + @frappe.whitelist() + def make_reverse_journal(self): + frappe.has_permission("Journal Entry", "write", throw=True) + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .distinct() + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals + ) + .run(pluck="name") + ) + if journals: + from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry + + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.submit() + frappe.msgprint( + _("Revaluation journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) + def calculate_exchange_rate_using_last_gle(company, account, party_type, party): """ diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index e794311c2cd..177688220a3 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -132,7 +132,7 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + self.assertTrue(err.check_journal_and_reversal()) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -221,7 +221,7 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + self.assertTrue(err.check_journal_and_reversal()) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 6ea0df946f2..1738beb3630 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,7 +1,10 @@ frappe.listview_settings["Journal Entry"] = { - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"], get_indicator: function (doc) { if (doc.docstatus === 1) { + if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { + return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; + } return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; } }, From 68382420637e4492be2bb52c156cd1222b34fa80 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 9 Jul 2026 13:09:29 +0530 Subject: [PATCH 23/32] refactor: handle reverse ERR journals in AR / AP report --- .../report/accounts_receivable/accounts_receivable.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 9b5fbc1b606..ac6f6fdac66 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -264,10 +264,12 @@ class ReceivablePayableReport: # Build and use a separate row for Employee Advances. # This allows Payments or Journals made against Emp Advance to be processed. - if ( - not row - and ple.against_voucher_type == "Employee Advance" - and self.filters.handle_employee_advances + if not row and ( + (ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances) + or ( + ple.against_voucher_type == "Exchange Rate Revaluation" + and self.filters.for_revaluation_journals + ) ): _d = self.build_voucher_dict(ple) _d.voucher_type = ple.against_voucher_type From 65775e59a1bc5fcb114db57a278bd8cc86c071c5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 10 Jul 2026 10:55:39 +0530 Subject: [PATCH 24/32] refactor(test): for reverse journals as well --- .../test_exchange_rate_revaluation.py | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 177688220a3..5a37bccaafb 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_and_reversal()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_and_reversal()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -299,6 +301,86 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_05_revaluation_journal_reversal(self): + """ + Test reversing of revaluation journals + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = self.company + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + self.assertEqual(len(err.accounts), 1) + err.save().submit() + + gain_loss_account = err.get_for_unrealized_gain_loss_account() + usd_account = err.accounts[0].account + old_balance = err.accounts[0].balance_in_base_currency + new_balance = err.accounts[0].new_balance_in_base_currency + total_gain_loss = err.total_gain_loss + + # Create JV for ERR + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") + self.assertEqual(len(je.accounts), 3) + expected = [ + (usd_account, new_balance, 0.0, 100.0, 0.0), + (usd_account, 0.0, old_balance, 0.0, 100.0), + (gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss), + ] + actual = [] + for acc in je.accounts: + actual.append( + ( + acc.account, + acc.debit, + acc.credit, + acc.debit_in_account_currency, + acc.credit_in_account_currency, + ) + ) + self.assertEqual(expected, actual) + + # Assert reversals are not posted + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertFalse(ret.get("reversals_posted")) + + err.make_reverse_journal() + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertTrue(ret.get("reversals_posted")) + + reverse_jv = frappe.db.get_all( + "Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name" + ) + self.assertIsNotNone(reverse_jv) + class TestExchangeRateRevaluationValidation(ERPNextTestSuite): """Validation and gain/loss calculation paths, exercised on the document directly From 9cb6610b9e380a296df7802c857462bc59c4ed07 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:34:47 +0530 Subject: [PATCH 25/32] fix(stock): correct stock ageing value for moving average and lifo items (#56693) * fix(stock): recompute moving average item slots * test(stock): add test to validate the stock value of moving average items * fix(stock): support lifo valuation in stock ageing report lifo items were aged as fifo (oldest consumed first), so the report kept the newest lots on hand and reported the wrong stock value and average age. prefetch each item's valuation method (it can't be resolved mid-stream without breaking the unbuffered cursor) and consume from the tail for lifo items. also reuse that shared lookup in the moving average revaluation pass. scoped to plain items; batch, serial and same-voucher repack legs stay on fifo. * test(stock): add test for lifo consumption in stock ageing report --- .../stock/report/stock_ageing/stock_ageing.py | 84 +++++++++++- .../report/stock_ageing/test_stock_ageing.py | 127 ++++++++++++++++++ 2 files changed, 204 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index c52d466b897..fb64fb70bcd 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -289,6 +289,7 @@ class FIFOSlots: self.serial_no_details = {} self.batch_no_details = {} self.batchwise_valuation_by_batch = {} + self.valuation_method_by_item = {} self.filters = filters self.sle = sle @@ -310,8 +311,9 @@ class FIFOSlots: if stock_ledger_entries is None: # streaming path: nested queries invalidate the streaming cursor below, - # so batchwise valuation flags must be resolved beforehand + # so batchwise valuation flags and item valuation methods must be resolved beforehand self._prefetch_batchwise_valuations() + self._prefetch_valuation_methods() if frappe.db.db_type == "postgres": # postgres server-side cursors can't run nested queries mid-iteration; _get_stock_ledger_entries @@ -334,12 +336,28 @@ class FIFOSlots: for row in stock_ledger_entries: self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos) + self._recompute_moving_average_slots() + if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _recompute_moving_average_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"): + continue + + details = item_dict["details"] + if self._get_item_valuation_method(details.name) != "Moving Average": + continue + + rate = flt(details.valuation_rate) + for slot in item_dict["fifo_queue"]: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) @@ -360,7 +378,10 @@ class FIFOSlots: if row.actual_qty > 0: self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + from_end = self._get_item_valuation_method(row.name) == "LIFO" + self._compute_outgoing_stock( + row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end + ) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -473,6 +494,45 @@ class FIFOSlots: for batch_no, use_batchwise_valuation in query.run(): self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _get_item_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + if item_code not in self.valuation_method_by_item: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all methods before iteration + self.valuation_method_by_item[item_code] = get_valuation_method( + item_code, self.filters.get("company") + ) + + return self.valuation_method_by_item[item_code] + + def _prefetch_valuation_methods(self) -> None: + from erpnext.stock.utils import get_valuation_method + + company = self.filters.get("company") + sle = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .inner_join(item) + .on(sle.item_code == item.name) + .select(item.name, item.valuation_method) + .distinct() + .where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1)) + ) + query = self._apply_filter(query, sle, "item_code") + + # items with no item-level method share the company/settings default; resolve it once + default_method = None + for item_code, valuation_method in query.run(): + if not valuation_method: + if default_method is None: + default_method = get_valuation_method(item_code, company) + valuation_method = default_method + self.valuation_method_by_item[item_code] = valuation_method + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -589,7 +649,13 @@ class FIFOSlots: fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) def _compute_outgoing_stock( - self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + serial_nos: list, + batch_nos: list, + from_end: bool = False, ): "Update FIFO Queue on outward stock." if serial_nos: @@ -597,7 +663,7 @@ class FIFOSlots: elif batch_nos: self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - self._consume_fifo_slots(row, fifo_queue, transfer_key) + self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end) def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] @@ -674,19 +740,23 @@ class FIFOSlots: ) self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) - def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: + def _consume_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False + ) -> None: + # LIFO consumes the most recent inward first, so pop from the tail instead of the head. + index = -1 if from_end else 0 qty_to_pop = abs(row.actual_qty) stock_value = abs(row.stock_value_difference) while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] + slot = fifo_queue[index] if fifo_queue else [0, None, 0] slot_qty = flt(slot[FIFO_QTY_INDEX]) slot_value = flt(slot[FIFO_VALUE_INDEX]) if 0 < slot_qty <= qty_to_pop: qty_to_pop -= slot_qty stock_value -= slot_value - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(index)) elif not fifo_queue: fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 7809451744d..180a424b209 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age @@ -63,6 +65,131 @@ class TestStockAgeing(ERPNextTestSuite): data = format_report_data(self.filters, slots, self.filters["to_date"]) self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30 + def test_moving_average_value_ties_to_stock_balance(self): + """For Moving Average items the queue value is re-derived as qty * rate so the + report's stock value ties to Stock Balance, instead of stranding a residual + from FIFO-by-qty consumption vs blended outgoing value.""" + sle = [ + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=20, + stock_value_difference=2000, + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-10), + qty_after_transaction=10, + stock_value_difference=(-1500), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-5), + qty_after_transaction=5, + stock_value_difference=(-750), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-04", + voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["MA Item"]["fifo_queue"] + total_value = sum(slot[2] for slot in queue) + + # Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150 + self.assertEqual(total_value, 750.0) + + def test_lifo_consumes_newest_first(self): + """LIFO items consume the most recent inward first, so the oldest lot stays on + hand. The remaining queue, stock value and average age must reflect the older + stock, unlike the default FIFO which retains the newest lots.""" + sle = [ + frappe._dict( + name="LIFO Item", + actual_qty=30, + qty_after_transaction=30, + stock_value_difference=30, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=20, + qty_after_transaction=50, + stock_value_difference=20, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=(-10), + qty_after_transaction=40, + stock_value_difference=(-10), + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["LIFO Item"]["fifo_queue"] + + # newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10 + self.assertEqual(queue[0][0], 30.0) + self.assertEqual(queue[-1][0], 10.0) + self.assertEqual(sum(slot[0] for slot in queue), 40.0) + self.assertEqual(sum(slot[2] for slot in queue), 40.0) + + # average age skews older than the FIFO result (8.5) because the old lot is retained + self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75) + def test_insufficient_balance(self): "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ From bdbb8481b02a856dddf2b86c7628a3c9f734259e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 10 Jul 2026 15:20:28 +0530 Subject: [PATCH 26/32] fix(stock): link job card in stock entry created from pick list A Stock Entry created from a Pick List against a job card's Material Request never set job_card, job_card_item, fg_completed_qty or the 'Material Transfer for Manufacture' purpose, so the Job Card did not recognize the transfer and blocked submission. The WIP warehouse was also not populated. Route such pick lists through a job-card-aware branch mirroring the direct Material Request -> Stock Entry mapper, and set the purpose to 'Material Transfer for Manufacture' in the work order branch so the WO -> MR -> Pick List flow updates the work order too. --- .../doctype/job_card/test_job_card.py | 42 ++++++++++++++++++ erpnext/stock/doctype/pick_list/mapper.py | 43 ++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index efb1636e7c1..4d8998f64db 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -665,6 +665,48 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + def test_job_card_material_transfer_via_pick_list(self): + from erpnext.stock.doctype.material_request.mapper import create_pick_list + from erpnext.stock.doctype.pick_list.mapper import ( + create_stock_entry as create_stock_entry_from_pick_list, + ) + + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + + for item in work_order.required_items: + make_stock_entry( + item_code=item.item_code, + target=item.source_warehouse, + qty=item.required_qty * 2, + basic_rate=100, + ) + + job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name") + job_card = frappe.get_doc("Job Card", job_card_name) + + mr = make_material_request(job_card_name) + mr.schedule_date = today() + mr.submit() + + pick_list = create_pick_list(mr.name) + pick_list.submit() + + ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict())) + self.assertEqual(ste.purpose, "Material Transfer for Manufacture") + self.assertEqual(ste.job_card, job_card_name) + self.assertEqual(ste.work_order, work_order.name) + self.assertEqual(ste.fg_completed_qty, job_card.for_quantity) + for row in ste.items: + self.assertEqual(row.t_warehouse, job_card.wip_warehouse) + self.assertTrue(row.job_card_item) + + ste.insert() + ste.submit() + + job_card.reload() + self.assertEqual(job_card.transferred_qty, job_card.for_quantity) + def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index df2c3f4e2c2..f9281931de5 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -289,15 +289,22 @@ def create_stock_entry(pick_list: str | dict): stock_entry.pick_list = pick_list.get("name") stock_entry.purpose = pick_list.get("purpose") stock_entry.company = pick_list.get("company") - stock_entry.set_stock_entry_type() - if pick_list.get("work_order"): + job_card = pick_list.get("material_request") and frappe.db.get_value( + "Material Request", pick_list.get("material_request"), "job_card" + ) + + if job_card: + stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card) + elif pick_list.get("work_order"): stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) elif pick_list.get("material_request"): stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + stock_entry.set_stock_entry_type() + if not stock_entry.get("items"): return frappe.msgprint(_("All picked items have already been transferred against this Pick List")) @@ -344,9 +351,41 @@ def stock_entry_exists(pick_list_name): return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) +def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): + job_card = frappe.db.get_value( + "Job Card", + job_card, + ["name", "work_order", "bom_no", "for_quantity", "transferred_qty", "wip_warehouse"], + as_dict=True, + ) + + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.job_card = job_card.name + stock_entry.work_order = job_card.work_order + stock_entry.from_bom = 1 + stock_entry.bom_no = job_card.bom_no + stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0) + stock_entry.to_warehouse = job_card.wip_warehouse + + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = job_card.wip_warehouse + if location.material_request_item: + item.job_card_item = frappe.db.get_value( + "Material Request Item", location.material_request_item, "job_card_item" + ) + stock_entry.append("items", item) + + return stock_entry + + def update_stock_entry_based_on_work_order(pick_list, stock_entry): work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + stock_entry.purpose = "Material Transfer for Manufacture" stock_entry.work_order = work_order.name stock_entry.company = work_order.company stock_entry.from_bom = 1 From 3e4d5e674582aa33b6a64e56bbfef1c6a8862623 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 10 Jul 2026 15:36:32 +0530 Subject: [PATCH 27/32] perf(stock): avoid n+1 queries for work order item source warehouse hoist the invariant work order lookup and batch-fetch work order item source warehouses once instead of querying per raw material row in get_bom_raw_materials --- .../stock/doctype/stock_entry/stock_entry.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ca72ced6157..321948c52e2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1423,6 +1423,23 @@ class StockEntry(StockController, SubcontractingInwardController): used_alternative_items = get_used_alternative_items( subcontract_order_field=self.subcontract_data.order_field, work_order=self.work_order ) + + skip_transfer, from_wip_warehouse = ( + frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"]) + if self.work_order + else [None, None] + ) + wo_item_source_warehouses = {} + if skip_transfer and not from_wip_warehouse: + for d in frappe.get_all( + "Work Order Item", + filters={"parent": self.work_order}, + fields=["item_code", "source_warehouse"], + ): + # default ordering is creation desc; keep the first (most recent) row per + # item_code to match the limit-1 behaviour of the get_value call this replaces + wo_item_source_warehouses.setdefault(d.item_code, d.source_warehouse) + for item in item_dict.values(): # if source warehouse presents in BOM set from_warehouse as bom source_warehouse if item["allow_alternative_item"]: @@ -1430,18 +1447,8 @@ class StockEntry(StockController, SubcontractingInwardController): "Work Order", self.work_order, "allow_alternative_item" ) - skip_transfer, from_wip_warehouse = ( - frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"]) - if self.work_order - else [None, None] - ) - item.from_warehouse = ( - frappe.get_value( - "Work Order Item", - {"parent": self.work_order, "item_code": item.item_code}, - "source_warehouse", - ) + wo_item_source_warehouses.get(item.item_code) if skip_transfer and not from_wip_warehouse else self.from_warehouse or item.source_warehouse or item.default_warehouse ) From e88e63976a10a5e7c2c47600ab4ab72c79928000 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 10 Jul 2026 16:32:37 +0530 Subject: [PATCH 28/32] perf(bom): batch default account/cost-center/warehouse lookups in bom explosion --- erpnext/manufacturing/doctype/bom/bom.py | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 263f0e0b09b..6583e28889a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1387,13 +1387,29 @@ def _merge_phantom_bom_items(item_dict, item, company, opts): def _set_default_accounts_for_items(item_dict, company): + fields = [ + ["Account", "expense_account", "stock_adjustment_account"], + ["Cost Center", "cost_center", "cost_center"], + ["Warehouse", "default_warehouse", ""], + ] + + company_of = {} + for d in fields: + names = {item_details.get(d[1]) for item_details in item_dict.values() if item_details.get(d[1])} + company_of[d[0]] = ( + { + r.name: r.company + for r in frappe.get_all( + d[0], filters={"name": ("in", list(names))}, fields=["name", "company"] + ) + } + if names + else {} + ) + for item, item_details in item_dict.items(): - for d in [ - ["Account", "expense_account", "stock_adjustment_account"], - ["Cost Center", "cost_center", "cost_center"], - ["Warehouse", "default_warehouse", ""], - ]: - company_in_record = frappe.db.get_value(d[0], item_details.get(d[1]), "company") + for d in fields: + company_in_record = company_of[d[0]].get(item_details.get(d[1])) if not item_details.get(d[1]) or (company_in_record and company != company_in_record): item_dict[item][d[1]] = frappe.get_cached_value("Company", company, d[2]) if d[2] else None From 2c03894e0045a94b19e413bab28af2d319c3f05b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 10 Jul 2026 16:36:44 +0530 Subject: [PATCH 29/32] fix(stock): batch job card item lookup and honour semi_fg_bom Fetch job_card_item for all pick list locations in one query instead of one per row, and prefer the job card's semi_fg_bom over the work order BOM, mirroring the direct Job Card -> Stock Entry mapper. --- erpnext/stock/doctype/pick_list/mapper.py | 28 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index f9281931de5..c6e27087e75 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -355,7 +355,7 @@ def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): job_card = frappe.db.get_value( "Job Card", job_card, - ["name", "work_order", "bom_no", "for_quantity", "transferred_qty", "wip_warehouse"], + ["name", "work_order", "bom_no", "semi_fg_bom", "for_quantity", "transferred_qty", "wip_warehouse"], as_dict=True, ) @@ -363,25 +363,41 @@ def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): stock_entry.job_card = job_card.name stock_entry.work_order = job_card.work_order stock_entry.from_bom = 1 - stock_entry.bom_no = job_card.bom_no + stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0) stock_entry.to_warehouse = job_card.wip_warehouse + job_card_items = get_job_card_items_by_material_request_item(pick_list) + for location in pick_list.locations: if get_pending_transfer_stock_qty(location) <= 0: continue item = frappe._dict() update_common_item_properties(item, location) item.t_warehouse = job_card.wip_warehouse - if location.material_request_item: - item.job_card_item = frappe.db.get_value( - "Material Request Item", location.material_request_item, "job_card_item" - ) + item.job_card_item = job_card_items.get(location.material_request_item) stock_entry.append("items", item) return stock_entry +def get_job_card_items_by_material_request_item(pick_list): + material_request_items = [ + location.material_request_item for location in pick_list.locations if location.material_request_item + ] + if not material_request_items: + return {} + + return dict( + frappe.get_all( + "Material Request Item", + filters={"name": ["in", material_request_items]}, + fields=["name", "job_card_item"], + as_list=True, + ) + ) + + def update_stock_entry_based_on_work_order(pick_list, stock_entry): work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) From a30f72dae1800e2538d2fdade23fb9a860249de1 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:01:49 +0530 Subject: [PATCH 30/32] fix: fetch payment entry reference amounts from invoice (#56928) --- .../doctype/payment_request/payment_request.py | 1 + .../payment_request/test_payment_request.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 5b6a56e69c3..d71af8bc677 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -542,6 +542,7 @@ class PaymentRequest(Document): bank_amount=bank_amount, created_from_payment_request=True, ) + payment_entry.set_missing_ref_details(force=True) payment_entry.update( { diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index f09b9b6a626..440933360d1 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -774,6 +774,22 @@ class TestPaymentRequest(ERPNextTestSuite): pi.load_from_db() self.assertEqual(pr_2.grand_total, pi.outstanding_amount) + def test_payment_entry_reference_details_fetched_from_invoice(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=94500) + pi.submit() + + pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1) + pr.grand_total = 94000 + pr.submit() + + pe = pr.create_payment_entry(submit=False) + + self.assertEqual(pe.references[0].reference_name, pi.name) + self.assertEqual(pe.references[0].total_amount, pi.grand_total) + self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount) + self.assertEqual(pe.references[0].allocated_amount, 94000) + self.assertEqual(pe.paid_amount, 94000) + def test_consider_journal_entry_and_return_invoice(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry From 394c9d80f943cc4f821795a86ccb9adc0369d4fa Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 10 Jul 2026 20:59:08 +0530 Subject: [PATCH 31/32] fix: use correct mapper path for make_work_orders call --- erpnext/selling/doctype/sales_order/sales_order.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 6a27febe21a..61a79027a33 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1368,7 +1368,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex frappe.throw(__("Please select at least one item to continue")); } me.frm.call({ - method: "make_work_orders", + method: "erpnext.selling.doctype.sales_order.mapper.make_work_orders", args: { items: data, company: me.frm.doc.company, From 4b6860de62e7148b2e164a35cd98c2677d2ff836 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Sat, 11 Jul 2026 04:03:45 +0530 Subject: [PATCH 32/32] fix: sync translations from crowdin (#57010) * fix: Swedish translations * fix: Bosnian translations --- erpnext/locale/bs.po | 118 +++++++++++++++++++++---------------------- erpnext/locale/sv.po | 20 ++++---- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/erpnext/locale/bs.po b/erpnext/locale/bs.po index 0e53d6a1dab..781e0d00cfc 100644 --- a/erpnext/locale/bs.po +++ b/erpnext/locale/bs.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2026-07-05 10:19+0000\n" -"PO-Revision-Date: 2026-07-08 21:28\n" +"PO-Revision-Date: 2026-07-09 21:42\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Bosnian\n" "MIME-Version: 1.0\n" @@ -2487,7 +2487,7 @@ msgstr "Trošak Aktivnosti postoji za {0} u odnosu na vrstu aktivnosti - {1}" #: erpnext/projects/doctype/activity_type/activity_type.js:10 msgid "Activity Cost per Employee" -msgstr "Trošak aktivnosti po personalu" +msgstr "Trošak Aktivnosti po Osoblju" #. Label of the activity_type (Link) field in DocType 'Sales Invoice Timesheet' #. Label of the activity_type (Link) field in DocType 'Activity Cost' @@ -2724,7 +2724,7 @@ msgstr "Dodaj popust" #: erpnext/public/js/event.js:40 msgid "Add Employees" -msgstr "Dodaj Personal" +msgstr "Dodaj Osoblje" #: erpnext/public/js/bom_configurator/bom_configurator.bundle.js:256 #: erpnext/selling/doctype/sales_order/sales_order.js:278 @@ -3896,7 +3896,7 @@ msgstr "Svi odjeli" #. Option for the 'Send To' (Select) field in DocType 'SMS Center' #: erpnext/selling/doctype/sms_center/sms_center.json msgid "All Employee (Active)" -msgstr "Sav Personal (Aktivni)" +msgstr "Sve Osoblje (Aktivno)" #: erpnext/setup/doctype/item_group/item_group.py:35 #: erpnext/setup/doctype/item_group/item_group.py:36 @@ -3934,7 +3934,7 @@ msgstr "Kontakt svih prodajnih partnera" #. Option for the 'Send To' (Select) field in DocType 'SMS Center' #: erpnext/selling/doctype/sms_center/sms_center.json msgid "All Sales Person" -msgstr "Sav Prodajni Personal" +msgstr "Sve Prodajno Osoblje" #. Description of a DocType #: erpnext/setup/doctype/sales_person/sales_person.json @@ -5212,7 +5212,7 @@ msgstr "Primjenjivo na (Pozicija)" #. Label of the to_emp (Link) field in DocType 'Authorization Rule' #: erpnext/setup/doctype/authorization_rule/authorization_rule.json msgid "Applicable To (Employee)" -msgstr "Primjenjivo na (Personal)" +msgstr "Primjenjivo na (Osoblje)" #. Label of the system_role (Link) field in DocType 'Authorization Rule' #: erpnext/setup/doctype/authorization_rule/authorization_rule.json @@ -6227,7 +6227,7 @@ msgstr "Imovina {assets_link} kreirana za {item_code}" #: erpnext/manufacturing/doctype/job_card/job_card.js:712 msgid "Assign Job to Employee" -msgstr "Dodijeli Posao Personalu" +msgstr "Dodijeli Posao Osoblju" #. Label of the assign_to_name (Read Only) field in DocType 'Asset Maintenance #. Task' @@ -9715,7 +9715,7 @@ msgstr "Nije moguće spojiti" #: erpnext/setup/doctype/employee/employee.py:292 msgid "Cannot Relieve Employee" -msgstr "Nije moguće razriješiti Personal" +msgstr "Nije moguće Razriješiti Osoblje" #: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:71 msgid "Cannot Resubmit Ledger entries for vouchers in Closed fiscal year." @@ -11917,7 +11917,7 @@ msgstr "Poduzeće imovine {0} i nabavni dokument {1} ne odgovara." #: erpnext/setup/doctype/employee/employee.py:164 msgid "Company or Personal Email is mandatory when 'Create User Automatically' is enabled" -msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Kreiraj Korisnika\"" +msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Izradi Osoblje\"" #. Description of the 'Registration Details' (Code) field in DocType 'Company' #: erpnext/setup/doctype/company/company.json @@ -13477,15 +13477,15 @@ msgstr "Kreiraj Dostavni Put" #: erpnext/utilities/activation.py:139 msgid "Create Employee" -msgstr "Kreiraj Personal" +msgstr "Izradi Osoblje" #: erpnext/utilities/activation.py:137 msgid "Create Employee Records" -msgstr "Kreiraj Personalni Registar" +msgstr "Izradi Registar Osoblja" #: erpnext/utilities/activation.py:138 msgid "Create Employee records." -msgstr "Kreiraj Personalni Registar" +msgstr "Izradi Registar Osoblja." #. Title of an Onboarding Step #. Label of an action in the Onboarding Step 'Create Existing Asset' @@ -13902,7 +13902,7 @@ msgstr "Kreirano {0} tablica bodova za {1} između:" #. 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Creates a User account for this employee using the Preferred, Company, or Personal email." -msgstr "Kreira korisnički račun za personal koristeći preferiranu, poduzeća ili ličnu e-poštu." +msgstr "Izradi korisnički račun za Osoblje koristeći Preferiranu, Poduzeća ili Ličnu adresu e-pošte." #. Description of the 'Create Grouped Asset' (Check) field in DocType 'Item' #: erpnext/stock/doctype/item/item.json @@ -18895,44 +18895,44 @@ msgstr "Hitni Telefon" #: erpnext/stock/doctype/serial_no/serial_no.json #: erpnext/telephony/doctype/call_log/call_log.json msgid "Employee" -msgstr "Personal" +msgstr "Osoblje" #. Label of the employee_link (Link) field in DocType 'Supplier Scorecard #. Scoring Standing' #: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json msgid "Employee " -msgstr "Personal " +msgstr "Osoblje " #. Option for the 'Reference Type' (Select) field in DocType 'Journal Entry #. Account' #: erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json msgid "Employee Advance" -msgstr "Predujam Personala" +msgstr "Predujam Osoblja" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:26 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:37 msgid "Employee Advances" -msgstr "Predujam Personala" +msgstr "Predujam Osoblja" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:188 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:327 msgid "Employee Benefits Obligation" -msgstr "Obaveza Beneficija Personala" +msgstr "Obaveza Pogodnosti Osoblja" #. Label of the employee_detail (Section Break) field in DocType 'Timesheet' #: erpnext/projects/doctype/timesheet/timesheet.json msgid "Employee Detail" -msgstr "Detalji Personala" +msgstr "Detalji Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_education/employee_education.json msgid "Employee Education" -msgstr "Obuka Personala" +msgstr "Obuka Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_external_work_history/employee_external_work_history.json msgid "Employee External Work History" -msgstr "Eksterna Radna Historija Personala" +msgstr "Vanjska Radna Historija Osoblja" #. Label of the employee_group (Link) field in DocType 'Communication Medium #. Timeslot' @@ -18940,12 +18940,12 @@ msgstr "Eksterna Radna Historija Personala" #: erpnext/communication/doctype/communication_medium_timeslot/communication_medium_timeslot.json #: erpnext/setup/doctype/employee_group/employee_group.json msgid "Employee Group" -msgstr "Grupa Personala" +msgstr "Grupa Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_group_table/employee_group_table.json msgid "Employee Group Table" -msgstr "Tabela Grupe Personala" +msgstr "Tabela Grupe Osoblja" #: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:33 msgid "Employee ID" @@ -18954,7 +18954,7 @@ msgstr "ID Personala" #. Name of a DocType #: erpnext/setup/doctype/employee_internal_work_history/employee_internal_work_history.json msgid "Employee Internal Work History" -msgstr "Eksterna Radna Historija Personala" +msgstr "Unutarnja Radna Historija Osoblja" #. Label of the employee_name (Data) field in DocType 'Activity Cost' #. Label of the employee_name (Data) field in DocType 'Timesheet' @@ -18965,50 +18965,50 @@ msgstr "Eksterna Radna Historija Personala" #: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:53 #: erpnext/setup/doctype/employee_group_table/employee_group_table.json msgid "Employee Name" -msgstr "Ime Personala" +msgstr "Ime Osoblja" #. Label of the employee_number (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Employee Number" -msgstr "Broj Personala" +msgstr "Broj Osoblja" #. Label of the employee_user_id (Link) field in DocType 'Call Log' #: erpnext/telephony/doctype/call_log/call_log.json msgid "Employee User Id" -msgstr "Korisnički ID Personala" +msgstr "Korisnički ID Osoblja" #: erpnext/setup/doctype/employee/employee.py:333 msgid "Employee cannot report to himself." -msgstr "Personal ne može da izvještava sam sebe." +msgstr "Osoblje ne može da izvještava samo sebe." #: erpnext/setup/doctype/employee/employee.py:583 msgid "Employee is required" -msgstr "Potreban je Personal" +msgstr "Osoblje je obavezno" #: erpnext/assets/doctype/asset_movement/asset_movement.py:109 msgid "Employee is required while issuing Asset {0}" -msgstr "Personal je obavezan prilikom izdavanja Imovine {0}" +msgstr "Osoblje je obavezno prilikom izdavanja Imovine {0}" #: erpnext/setup/doctype/employee/employee.py:440 msgid "Employee {0} already has a linked user" -msgstr "Personal {0} već ima povezanog korisnika" +msgstr "Osoblje {0} već ima povezanog korisnika" #: erpnext/assets/doctype/asset_movement/asset_movement.py:92 #: erpnext/assets/doctype/asset_movement/asset_movement.py:113 msgid "Employee {0} does not belong to the company {1}" -msgstr "Personal {0} ne pripada {1}" +msgstr "Osoblje {0} ne pripada {1}" #: erpnext/manufacturing/doctype/job_card/job_card.py:411 msgid "Employee {0} is currently working on another workstation. Please assign another employee." -msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugi personal." +msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugo osoblje." #: erpnext/setup/doctype/employee/employee.py:608 msgid "Employee {0} not found" -msgstr "Personal {0} nije pronađen" +msgstr "Osoblje {0} nije pronađeno" #: erpnext/public/js/shop_floor/shop_floor.js:684 msgid "Employees" -msgstr "Personal" +msgstr "Osoblje" #: erpnext/stock/doctype/batch/batch_list.js:16 msgid "Empty" @@ -21779,11 +21779,11 @@ msgstr "Od Datuma Dospijeća" #. Label of the from_employee (Link) field in DocType 'Asset Movement Item' #: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json msgid "From Employee" -msgstr "Od Personala" +msgstr "Od Osoblja" #: erpnext/assets/doctype/asset_movement/asset_movement.py:98 msgid "From Employee is required while issuing Asset {0}" -msgstr "Personal je obavezan prilikom izdavanja Imovine {0}" +msgstr "Osoblje je obavezano prilikom izdavanja Imovine {0}" #. Label of the from_external_ecomm_platform (Check) field in DocType 'Coupon #. Code' @@ -23088,7 +23088,7 @@ msgstr "Hand" #: erpnext/accounts/report/accounts_payable/accounts_payable.js:161 msgid "Handle Employee Advances" -msgstr "Rukovanje Predujmom Personala" +msgstr "Rukovanje Predujmom Osoblja" #: erpnext/setup/setup_wizard/operations/install_fixtures.py:228 msgid "Hardware" @@ -24169,7 +24169,7 @@ msgstr "Zanemari Šablon Standard Uslova Plaćanja" #. Settings' #: erpnext/projects/doctype/projects_settings/projects_settings.json msgid "Ignore Employee Time Overlap" -msgstr "Zanemari preklapanje vremena Personala" +msgstr "Zanemari preklapanje vremena Osoblja" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js:145 msgid "Ignore Empty Stock" @@ -24303,7 +24303,7 @@ msgstr "Uvoz Podataka" #: erpnext/setup/doctype/employee/employee_list.js:16 msgid "Import Employees" -msgstr "Uvoz Personala" +msgstr "Uvezi Osoblje" #: erpnext/edi/doctype/code_list/code_list.js:7 #: erpnext/edi/doctype/code_list/code_list_list.js:3 @@ -32013,7 +32013,7 @@ msgstr "N/A" #. Person' #: erpnext/setup/doctype/sales_person/sales_person.json msgid "Name and Employee ID" -msgstr "Ime i Personalni ID" +msgstr "Ime i ID Osoblja" #. Label of the name_of_beneficiary (Data) field in DocType 'Bank Guarantee' #: erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json @@ -32922,7 +32922,7 @@ msgstr "Nije pronađena e-pošta za {0} {1}" #: erpnext/telephony/doctype/call_log/call_log.py:119 msgid "No employee was scheduled for call popup" -msgstr "Personal nije zakazao poziv" +msgstr "Osoblje nije zakazalo poziv" #: banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx:235 #: banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx:225 @@ -32993,7 +32993,7 @@ msgstr "Broj Dokumenata" #: erpnext/crm/doctype/lead/lead.json #: erpnext/crm/doctype/opportunity/opportunity.json msgid "No of Employees" -msgstr "Personalni Broj" +msgstr "Broj Osoblja" #: erpnext/crm/report/lead_conversion_time/lead_conversion_time.py:62 msgid "No of Interactions" @@ -33210,7 +33210,7 @@ msgstr "Nije pronađen {0} za transakcije među poduzećima." #. Label of the no_of_employees (Select) field in DocType 'Prospect' #: erpnext/crm/doctype/prospect/prospect.json msgid "No. of Employees" -msgstr "Personalni Broj" +msgstr "Broj Osoblja" #: erpnext/manufacturing/doctype/workstation/workstation.js:63 msgid "No. of parallel job cards which can be allowed on this workstation. Example: 2 would mean this workstation can process production for two Work Orders at a time." @@ -33477,7 +33477,7 @@ msgstr "Obavijesti klijente putem e-pošte" #: erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.json #: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json msgid "Notify Employee" -msgstr "Obavijesti Personal" +msgstr "Obavijesti Osoblje" #. Label of the notify_employee (Check) field in DocType 'Supplier Scorecard #. Standing' @@ -37674,7 +37674,7 @@ msgstr "Lični Detalji" #. Label of the personal_email (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Personal Email" -msgstr "Liöna e-pošta" +msgstr "Lična adresa e-pošte" #: erpnext/setup/setup_wizard/setup_wizard.py:33 msgid "Personalizing your setup" @@ -37874,7 +37874,7 @@ msgstr "Quart Liquid (US)" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8 msgid "Pipeline By" -msgstr "Lijevak prema" +msgstr "Proces Prema" #. Label of the place_of_issue (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json @@ -38347,7 +38347,7 @@ msgstr "Unesi Datum Dostave" #: erpnext/setup/doctype/sales_person/sales_person_tree.js:9 msgid "Please enter Employee Id of this sales person" -msgstr "Unesi Personal Id ovog Prodavača" +msgstr "Unesi Osobni ID ovog Prodavača" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1103 msgid "Please enter Expense Account" @@ -48526,7 +48526,7 @@ msgstr "Sažetak Transakcije Prodaje po Prodavaču" #: erpnext/selling/page/sales_funnel/sales_funnel.js:50 #: erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline" -msgstr "Prodajni Cjevovod" +msgstr "Prodajni Proces" #. Name of a report #. Label of a Link in the CRM Workspace @@ -48534,11 +48534,11 @@ msgstr "Prodajni Cjevovod" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline Analytics" -msgstr "Analiza Prodaje" +msgstr "Analiza Procesa Prodaje" #: erpnext/selling/page/sales_funnel/sales_funnel.js:157 msgid "Sales Pipeline by Stage" -msgstr "Prodaja po Fazama" +msgstr "Proces Prodaje po Fazama" #: erpnext/stock/report/item_prices/item_prices.py:58 msgid "Sales Price List" @@ -49230,7 +49230,7 @@ msgstr "Odaberite Klijente po" #: erpnext/setup/doctype/employee/employee.js:244 msgid "Select Date of Birth. This will validate Employees age and prevent hiring of under-age staff." -msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob personala i spriječiti zapošljavanje maloljetnih osoba." +msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob Osoblja i spriječiti zapošljavanje maloljetnih osoba." #: erpnext/setup/doctype/employee/employee.js:251 msgid "Select Date of joining. It will have impact on the first salary calculation, Leave allocation on pro-rata bases." @@ -49256,7 +49256,7 @@ msgstr "Odaberi Otpremnu Adresu " #: erpnext/manufacturing/doctype/job_card/job_card.js:705 msgid "Select Employees" -msgstr "Navedi Personal" +msgstr "Odaberi Osoblje" #: erpnext/buying/doctype/purchase_order/purchase_order.js:174 #: erpnext/selling/doctype/sales_order/sales_order.js:862 @@ -49378,7 +49378,7 @@ msgstr "Odaberi Poduzeće" #: erpnext/setup/doctype/employee/employee.js:239 msgid "Select a Company this Employee belongs to." -msgstr "Navedi Poduzeće kojoj ovaj personal pripada." +msgstr "Odaberi Poduzeće kojoj ovo Osoblje pripada." #: erpnext/buying/doctype/supplier/supplier.js:221 msgid "Select a Customer" @@ -50788,7 +50788,7 @@ msgstr "Postavljanje Tipa Računa pomaže pri odabiru Računa u transakcijama." #: erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py:129 msgid "Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}" -msgstr "Postavljanje Događaja na {0}, budući da Personal vezan za ispod navedene Prodavače nema Korisnički ID{1}" +msgstr "Postavljanje Događaja na {0}, budući da Osoblje vezano za ispod navedene Prodavače nema Korisnički ID {1}" #: erpnext/stock/doctype/pick_list/pick_list.js:98 msgid "Setting Item Locations..." @@ -55984,7 +55984,7 @@ msgstr "Sljedeći izbrisani atributi postoje u varijantama, ali ne i u šablonu. #: erpnext/setup/doctype/employee/employee.py:286 msgid "The following employees are currently still reporting to {0}:" -msgstr "Sljedeći personal još uvijek podnose izvještaj {0}:" +msgstr "Sljedeće Osoblje još uvijek podnosi izvještaj {0}:" #: erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py:185 msgid "The following invalid Pricing Rules are deleted:{0}" @@ -57117,7 +57117,7 @@ msgstr "Do Datuma isteka roka" #. Label of the to_employee (Link) field in DocType 'Asset Movement Item' #: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json msgid "To Employee" -msgstr "Za Personal" +msgstr "Za Osoblje" #. Label of the to_fiscal_year (Link) field in DocType 'Budget' #: erpnext/accounts/doctype/budget/budget.json @@ -60113,11 +60113,11 @@ msgstr "Korisnik {0} je onemogućen. Odaberi važećeg korisnika/blagajnika" #: erpnext/setup/doctype/employee/employee.py:365 msgid "User {0}: Removed Employee Self Service role as there is no mapped employee." -msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja zaposlenika jer nema mapiranog zaposlenika." +msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja Osoblja jer nema mapiranog Osoblja." #: erpnext/setup/doctype/employee/employee.py:360 msgid "User {0}: Removed Employee role as there is no mapped employee." -msgstr "Korisnik {0}: Uklonjena uloga personala jer nema mapiranog personala." +msgstr "Korisnik {0}: Uklonjena uloga Osoblja jer nema mapiranog Osoblja." #. Description of the 'Set Landed Cost Based on Purchase Invoice Rate' (Check) #. field in DocType 'Buying Settings' diff --git a/erpnext/locale/sv.po b/erpnext/locale/sv.po index a1ee00d2258..df31715d13a 100644 --- a/erpnext/locale/sv.po +++ b/erpnext/locale/sv.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2026-07-05 10:19+0000\n" -"PO-Revision-Date: 2026-07-06 21:26\n" +"PO-Revision-Date: 2026-07-09 21:42\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Swedish\n" "MIME-Version: 1.0\n" @@ -34582,12 +34582,12 @@ msgstr "Möjlighet Källa" #. Label of a Workspace Sidebar Item #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Opportunity Summary by Sales Stage" -msgstr "Möjlighet Översikt efter Försäljning Fas" +msgstr "Möjlighet Översikt efter Försäljning Steg" #. Name of a report #: erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.json msgid "Opportunity Summary by Sales Stage " -msgstr "Möjlighet Översikt efter Försäljning Fas " +msgstr "Möjlighet Översikt efter Försäljning Steg " #. Label of the opportunity_type (Link) field in DocType 'Opportunity' #. Name of a DocType @@ -37880,7 +37880,7 @@ msgstr "Pint, Liquid (US)" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8 msgid "Pipeline By" -msgstr "Tratt Efter" +msgstr "Process Efter" #. Label of the place_of_issue (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json @@ -48541,11 +48541,11 @@ msgstr "Försäljning" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline Analytics" -msgstr "Försäljning Statistik" +msgstr "Försäljning Process Statistik" #: erpnext/selling/page/sales_funnel/sales_funnel.js:157 msgid "Sales Pipeline by Stage" -msgstr "Försäljning efter Fas" +msgstr "Försäljning Process efter Steg" #: erpnext/stock/report/item_prices/item_prices.py:58 msgid "Sales Price List" @@ -48578,7 +48578,7 @@ msgstr "Försäljning Retur" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py:69 #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Stage" -msgstr "Försäljning Fas" +msgstr "Försäljning Steg" #: erpnext/accounts/doctype/pos_closing_entry/closing_voucher_details.html:8 msgid "Sales Summary" @@ -52005,7 +52005,7 @@ msgstr "Kvadratyard" #. Label of the stage_name (Data) field in DocType 'Sales Stage' #: erpnext/crm/doctype/sales_stage/sales_stage.json msgid "Stage Name" -msgstr "Fas Namn" +msgstr "Försäljning Steg Namn" #. Label of the stale_days (Int) field in DocType 'Accounts Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -56228,11 +56228,11 @@ msgstr "System kommer att skapa Försäljning Faktura eller Kassa Faktura från #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1239 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Draft stage" -msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast status." +msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast steg" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1250 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Submitted stage" -msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd status" +msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd steg" #: erpnext/stock/doctype/material_request/material_request.py:352 msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}"