From 1ff297e9da87ec633ea5b621eac215be5b008b03 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 12:32:51 +0530 Subject: [PATCH 01/37] fix: seed cancelled voucher replay from before its posting datetime On cancel, update_entries_after replays every live SLE at the voucher's posting datetime, but get_previous_sle_of_current_voucher seeded the replay with the reversal SLE's creation, which resolves to the bucket's own closing row. The bucket's net qty got double-counted into every same-datetime row, so later submissions passed negative stock validation against inflated balances, and the queued repost then rewrote correct values with allow_negative_stock forced on, silently creating negative stock. Backports the missing guard from eca71dce54f. --- .../doctype/stock_entry/test_stock_entry.py | 44 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 79a353ace30b..b344b772cbb7 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2751,6 +2751,50 @@ def test_process_loss_qty_derived_from_percentage_when_qty_blank(self): self.assertEqual(se.process_loss_qty, 50) + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_cancel_seeds_replay_from_before_posting_datetime_bucket(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item = make_item().name + warehouse = create_warehouse("Cancel Replay Warehouse", company="_Test Company") + + def stock_entry(qty, posting_date, **kwargs): + return make_stock_entry( + item_code=item, + qty=qty, + company="_Test Company", + posting_date=posting_date, + posting_time="10:00:00", + **kwargs, + ) + + stock_entry(25, add_days(today(), -3), to_warehouse=warehouse, rate=10) + + bucket_date = add_days(today(), -2) + stock_entry(20, bucket_date, from_warehouse=warehouse) + receipt = stock_entry(75, bucket_date, to_warehouse=warehouse, rate=10) + duplicate_issue = stock_entry(20, bucket_date, from_warehouse=warehouse) + + frappe.flags.dont_execute_stock_reposts = True + self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts") + + duplicate_issue.reload() + duplicate_issue.cancel() + + self.assertEqual( + flt( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": receipt.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + ), + 80, + ) + + overdraw = stock_entry(100, add_days(today(), -1), from_warehouse=warehouse, do_not_submit=True) + self.assertRaises(NegativeStockError, overdraw.submit) + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ba578f698144..ddaaf702c864 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1762,7 +1762,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc voucher_no = args.get("voucher_no") voucher_condition = f"and voucher_no != '{voucher_no}'" - elif args.get("creation") and args.get("sle_id"): + elif args.get("creation") and args.get("sle_id") and not args.get("cancelled"): creation = args.get("creation") operator = "<=" voucher_condition = f"and creation < '{creation}'" From d9373850ade992fe4f84322122f605760c37cb58 Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Thu, 23 Jul 2026 09:54:23 +0530 Subject: [PATCH 02/37] fix: restore Save button on reverse journal entry --- erpnext/accounts/doctype/journal_entry/journal_entry.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 232c33d4defc..d2081bcde594 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -41,7 +41,7 @@ frappe.ui.form.on("Journal Entry", { refresh: function (frm) { if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) { - frm.set_read_only(); + erpnext.journal_entry.lock_reversal_entry(frm); } erpnext.toggle_naming_series(); @@ -513,6 +513,13 @@ $.extend(erpnext.journal_entry, { }); }, + lock_reversal_entry: function (frm) { + frm.fields + .filter((field) => field.has_input) + .forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1)); + frm.set_df_property("accounts", "read_only", 1); + }, + set_debit_credit_in_company_currency: function (frm, cdt, cdn) { var row = locals[cdt][cdn]; From c5235af6bf47ceb1be983cf8c02330b714954b60 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:48:45 +0530 Subject: [PATCH 03/37] fix: respect selected BOM when creating work order for variant item (#57359) * fix: respect selected BOM when creating work order for variant item * fix: add type hints to make_work_order (cherry picked from commit 1132eb1a0f9b4c2853c095edd61156c7186d4787) --- .../manufacturing/doctype/work_order/work_order.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index c28e0054c992..d1bfa8edc0dc 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1461,7 +1461,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True): @frappe.whitelist() def make_work_order( - bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None + bom_no: str, + item: str, + qty: float = 0, + company: str | None = None, + project: str | None = None, + variant_items: str | list | None = None, + use_multi_level_bom: bool | None = None, ): from erpnext import get_default_company @@ -1470,7 +1476,8 @@ def make_work_order( item_details = get_item_details(item, project) - if frappe.db.get_value("Item", item, "variant_of"): + # selected BOM already belongs to this variant — keep it + if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item: if variant_bom := frappe.db.get_value( "BOM", {"item": item, "is_default": 1, "docstatus": 1}, From 745513d0c214857ea66325f57f5ca8d2495ea023 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Dec 2025 16:53:02 +0530 Subject: [PATCH 04/37] fix: check if item is variant when creating WO from MR --- erpnext/stock/doctype/material_request/material_request.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index ceae67114f97..28978748a242 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -820,7 +820,10 @@ def raise_work_orders(material_request): for d in mr.items: if (d.stock_qty - d.ordered_qty) > 0: - if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}): + if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}) or ( + (variant_of := frappe.get_value("Item", d.item_code, "variant_of")) + and frappe.db.exists("BOM", {"item": variant_of, "is_default": 1, "is_active": 1}) + ): wo_order = frappe.new_doc("Work Order") wo_order.update( { From bb1320f8df5df11281e1dc0f427edac34c5f19c2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Dec 2025 21:53:11 +0530 Subject: [PATCH 05/37] fix: add is_active filter --- erpnext/stock/doctype/material_request/material_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 28978748a242..eaf28dd4b961 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -820,7 +820,7 @@ def raise_work_orders(material_request): for d in mr.items: if (d.stock_qty - d.ordered_qty) > 0: - if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}) or ( + if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1, "is_active": 1}) or ( (variant_of := frappe.get_value("Item", d.item_code, "variant_of")) and frappe.db.exists("BOM", {"item": variant_of, "is_default": 1, "is_active": 1}) ): From 48082020e8fd020b3b274e38c595dfc4263a3c4d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 15:56:42 +0530 Subject: [PATCH 06/37] fix: rebalance batch slot values at the pooled rate when driven negative A batch is one valuation pool, so consumption is valued at the pooled rate while slots may carry stale intra-batch detail (e.g. units reconciled at zero and later merged). Consuming such a slot leaves a negative value on positive qty. Spread the pool value across the batch's slots when that happens; non-batchwise slots pool per warehouse. --- .../stock/report/stock_ageing/stock_ageing.py | 28 ++++++++++ .../report/stock_ageing/test_stock_ageing.py | 52 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e745a10ea7e6..ebe91dbefb9e 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -325,6 +325,7 @@ def generate(self) -> dict: del stock_ledger_entries self._recompute_moving_average_slots() + self._rebalance_negative_batch_slots() if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) @@ -346,6 +347,33 @@ def _recompute_moving_average_slots(self) -> None: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _rebalance_negative_batch_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_batch_no"): + self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"]) + + def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None: + """A batch is one valuation pool, so a slot driven negative by consumption + at the pooled rate is stale detail: spread the pool value over its slots.""" + groups = {} + for slot in fifo_queue: + if is_batch_slot(slot): + key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None + groups.setdefault(key, []).append(slot) + + for slots in groups.values(): + has_negative_slot = any( + flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0 + for slot in slots + ) + total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) + if not has_negative_slot or total_qty <= 0: + continue + + rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty + for slot in slots: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_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({}) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index fb488c47eff6..658056afcd7c 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -620,6 +620,58 @@ def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stoc ], ) + def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5] + Consuming the zero-valued head slot at the pooled rate drives it + negative; slot values are then rebalanced to the batch pool rate.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Rebalance", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-REBALANCE-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 10, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + make_sle("2021-12-03", "003", -4, 16, -20), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 6.0, "2021-12-01", 30.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], + ], + ) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From 6fa522d031c7b6ae1f8988ff0dbaef60b8ba7b5d Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 23 Jul 2026 14:06:23 +0530 Subject: [PATCH 07/37] fix: guard against missing is_your_company_address custom field on address (cherry picked from commit ea3ed8b836e933e32fee0f345c77fa24d42576e1) --- erpnext/accounts/custom/address.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index ef57a6325069..246aee3b0ec9 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -15,7 +15,7 @@ def validate(self): def link_address(self): """Link address based on owner""" - if self.is_your_company_address: + if self.get("is_your_company_address"): return return super().link_address() @@ -26,7 +26,9 @@ def update_compnay_address(self): self.is_your_company_address = 1 def validate_reference(self): - if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]: + if self.get("is_your_company_address") and not [ + row for row in self.links if row.link_doctype == "Company" + ]: frappe.throw( _( "Address needs to be linked to a Company. Please add a row for Company in the Links table." From 56bd024f39385222c41ffa20631c5307b43d50e6 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 23 Jul 2026 17:43:57 +0530 Subject: [PATCH 08/37] fix: typeerror in get_batches_by_oldest for mixed batch expiry sort on (expiry is none, expiry) so a null expiry_date is never order-compared against a datetime.date, which raised typeerror in python 3 when a warehouse held both dated and never-expiring batches. (cherry picked from commit 62c9f8ee3e7f8a6452f4bab9e5cc58f699f41e2d) --- erpnext/stock/doctype/batch/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index b3a85b0bdd6d..f7147f0fa59a 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -298,7 +298,7 @@ def get_batches_by_oldest(item_code, warehouse): """Returns the oldest batch and qty for the given item_code and warehouse""" batches = get_batch_qty(item_code=item_code, warehouse=warehouse) batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches] - batches_dates.sort(key=lambda tup: tup[1]) + batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1])) return batches_dates From 0dc5894499999167407eebcad17cc0d721ee57dc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 23 Jul 2026 21:29:36 +0530 Subject: [PATCH 09/37] fix: map pick list customer to delivery note when no sales order backport of #57412 --- erpnext/stock/doctype/pick_list/pick_list.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 06e190c30f74..7a2dd32291aa 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1339,6 +1339,9 @@ def create_dn_wo_so(pick_list, delivery_note=None): delivery_note.company = pick_list.company + if not delivery_note.customer: + delivery_note.customer = pick_list.customer + item_table_mapper_without_so = { "doctype": "Delivery Note Item", "field_map": { From 348555b127cb1f4afb6095c0fbc216630fb56c6d Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 24 Jul 2026 12:22:08 +0530 Subject: [PATCH 10/37] fix: respect user permissions in party dashboard company list use frappe.get_list instead of frappe.get_all in get_dashboard_info so the company list honors user permissions. previously, a party with invoices across multiple companies would raise "User don't have permissions to select/read this account" for users restricted to a subset of companies, since get_party_account was called for companies the user could not access. fixes frappe/erpnext#57428 (cherry picked from commit 903c87bcaa705ca997e28ec53132e308502ac6eb) --- erpnext/accounts/party.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 2edba2e5c5df..ca66235cff33 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -853,7 +853,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" - companies = frappe.get_all( + companies = frappe.get_list( doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"] ) From 4e8f5de5cbaf7deab98b5a8b642c04fb6b7f412c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 25 Jul 2026 00:21:06 +0530 Subject: [PATCH 11/37] fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (#57458) --- erpnext/stock/report/stock_balance/stock_balance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index fa3b977a1216..89f01b9862ac 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -130,7 +130,7 @@ frappe.query_reports["Stock Balance"] = { fieldname: "include_zero_stock_items", label: __("Include Zero Stock Items"), fieldtype: "Check", - default: 0, + default: 1, }, { fieldname: "show_dimension_wise_stock", From 4fe91bd8b82a7c99032a9ac3ff7fec6b37c8c437 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 26 Jul 2026 13:11:11 +0530 Subject: [PATCH 12/37] fix: recalculate operating cost on hour rate change in routing (cherry picked from commit 598f6f0f4e1060235ce96a782bbc11631ea6ba27) --- erpnext/manufacturing/doctype/routing/routing.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index fe67fe3feb63..374de33331ce 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -69,6 +69,11 @@ frappe.ui.form.on("BOM Operation", { const d = locals[cdt][cdn]; frm.events.calculate_operating_cost(frm, d); }, + + hour_rate: function (frm, cdt, cdn) { + const d = locals[cdt][cdn]; + frm.events.calculate_operating_cost(frm, d); + }, }); frappe.tour["Routing"] = [ From 60b4e6053d7bba89c577325ea693c57d07c088e7 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 24 Jul 2026 15:32:51 +0530 Subject: [PATCH 13/37] fix: rename misleading filter labels in AR/AP reports (cherry picked from commit e99425b7c47afc0e9395f39e1b06ca70477f9cb2) --- .../accounts/report/accounts_payable/accounts_payable.js | 8 ++++---- .../accounts_payable_summary/accounts_payable_summary.js | 8 ++++---- .../report/accounts_receivable/accounts_receivable.js | 8 ++++---- .../report/accounts_receivable/accounts_receivable.py | 3 +-- .../accounts_receivable_summary.js | 8 ++++---- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 5b8a9195d268..4da827f1a81d 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index 3f603b628331..0b3bc0776984 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 02bb54abc79c..e0444a0af1ef 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 42b3991194b5..db74275238eb 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -55,8 +55,7 @@ def __init__(self, filters=None): self.filters.report_date = getdate(self.filters.report_date or nowdate()) self.age_as_on = ( getdate(nowdate()) - if "calculate_ageing_with" not in self.filters - or self.filters.calculate_ageing_with == "Today Date" + if "age_as_on" not in self.filters or self.filters.age_as_on == "Today" else self.filters.report_date ) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index 46585071174b..c15ec8b01249 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { From e2319c3ffeadfec2a5ee16b264ac50d6c5afb11c Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 24 Jul 2026 18:45:07 +0530 Subject: [PATCH 14/37] fix: migrate stored AR/AP ageing filter to renamed field (cherry picked from commit f13cd004945207d724f967b490c6a5952fb7ad15) # Conflicts: # erpnext/patches.txt --- erpnext/patches.txt | 7 +++ .../v16_0/rename_ar_ap_ageing_filter.py | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7adc55d57c41..21caa602e42c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -443,3 +443,10 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.backfill_pick_list_transferred_qty erpnext.patches.v16_0.access_control_for_project_users +<<<<<<< HEAD +======= +erpnext.patches.v16_0.enable_book_stock_expense_gl_entries +execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0) +erpnext.patches.v16_0.recompute_production_plan_reserved_qty +erpnext.patches.v16_0.rename_ar_ap_ageing_filter +>>>>>>> f13cd00494 (fix: migrate stored AR/AP ageing filter to renamed field) diff --git a/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py b/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py new file mode 100644 index 000000000000..8252c3b0aac7 --- /dev/null +++ b/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py @@ -0,0 +1,45 @@ +import frappe + +REPORTS = ( + "Accounts Receivable", + "Accounts Payable", + "Accounts Receivable Summary", + "Accounts Payable Summary", +) + + +def execute(): + # filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today" + _migrate("Auto Email Report", "filters", "report") + _migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type") + _migrate("Number Card", "filters_json", "report_name", type_field="type") + + +def _migrate(doctype, filter_field, report_field, type_field=None): + conditions = {report_field: ("in", REPORTS)} + if type_field: + conditions[type_field] = "Report" + + for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]): + updated = _rewrite(row.get(filter_field)) + if updated is not None: + frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False) + + +def _rewrite(raw): + if not raw: + return None + + try: + filters = frappe.parse_json(raw) + except ValueError: + return None + + if not isinstance(filters, dict) or "calculate_ageing_with" not in filters: + return None + + filters["age_as_on"] = filters.pop("calculate_ageing_with") + if filters["age_as_on"] == "Today Date": + filters["age_as_on"] = "Today" + + return frappe.as_json(filters, indent=None) From 7a858be920e674808b85163d41813357344d99d0 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 26 Jul 2026 19:19:03 +0530 Subject: [PATCH 15/37] chore: resolve patches.txt conflict for backport --- erpnext/patches.txt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 21caa602e42c..4be98e72eea9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -443,10 +443,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.backfill_pick_list_transferred_qty erpnext.patches.v16_0.access_control_for_project_users -<<<<<<< HEAD -======= -erpnext.patches.v16_0.enable_book_stock_expense_gl_entries -execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0) -erpnext.patches.v16_0.recompute_production_plan_reserved_qty erpnext.patches.v16_0.rename_ar_ap_ageing_filter ->>>>>>> f13cd00494 (fix: migrate stored AR/AP ageing filter to renamed field) From 97fa5435bb8f0eb150f319396ee44971c885d027 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 27 Jul 2026 10:21:53 +0530 Subject: [PATCH 16/37] refactor: configurable date in reverse ERR journals (cherry picked from commit 0be33e4132a5f56669a3cf1c909a80545cb3a92b) --- .../exchange_rate_revaluation.py | 27 +++++++++++++------ .../doctype/journal_entry/journal_entry.js | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) 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 b87e8e009512..7f078c67f5d8 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -619,15 +619,26 @@ def make_reverse_journal(self): if journals: from erpnext.accounts.doctype.journal_entry.journal_entry 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) - ) + if drafts := frappe.db.get_all( + "Journal Entry", + filters={"docstatus": 0, "reversal_of": ["in", journals]}, + pluck="name", + ): + part = "journals are" if len(drafts) > 1 else "journal is" + doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts]) + frappe.throw( + msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links), ) + else: + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.save() + frappe.msgprint( + _("A draft reverse 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/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index d2081bcde594..47ba392802e9 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -516,6 +516,7 @@ $.extend(erpnext.journal_entry, { lock_reversal_entry: function (frm) { frm.fields .filter((field) => field.has_input) + .filter((field) => field.df.fieldname != "posting_date") .forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1)); frm.set_df_property("accounts", "read_only", 1); }, From 435fe19398e71239339a324b20d84783f38b94cd Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 27 Jul 2026 11:18:41 +0530 Subject: [PATCH 17/37] refactor(test): manually submit reverse err journal (cherry picked from commit 1a558ce6419f5b00c9f50067bd919d3a9a4d46c5) --- .../test_exchange_rate_revaluation.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 4329b6078ecf..c59fcbef33ce 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 @@ -361,6 +361,14 @@ def test_05_revaluation_journal_reversal(self): self.assertFalse(ret.get("reversals_posted")) err.make_reverse_journal() + # submit + draft = frappe.db.get_all( + "Journal Entry", + filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"}, + pluck="name", + ) + self.assertIsNotNone(draft) + frappe.get_doc("Journal Entry", draft[0]).submit() ret = err.check_journal_and_reversal() self.assertTrue(ret.get("journals_posted")) self.assertTrue(ret.get("reversals_posted")) From d9706271ff32d8082e0deacbd045f8716865f06d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 25 Jul 2026 23:54:52 +0530 Subject: [PATCH 18/37] fix(subcontracting): release raw-material reservation when closing a subcontracting order the bin reserved-qty recalc filtered out closed purchase orders but not closed subcontracting orders, so closing a partially-received sco kept the reservation for the unreceived qty and left projected qty understated. apply the same closed-status filter to the subcontracting order path. (cherry picked from commit db91a79d3189482dcbe9ed9154ba40eeb7597464) # Conflicts: # erpnext/stock/doctype/bin/bin.py --- erpnext/stock/doctype/bin/bin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 2af1f6454944..a2c0f09d3877 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -162,7 +162,7 @@ def update_reserved_qty_for_sub_contracting( & (subcontract_order.docstatus == 1) ) if subcontract_doctype == "Purchase Order" - else (subcontract_order.docstatus == 1) + else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1)) ) ) @@ -199,6 +199,7 @@ def update_reserved_qty_for_sub_contracting( else ( (Coalesce(se.subcontracting_order, "") != "") & (subcontract_order.name == se.subcontracting_order) + & (subcontract_order.status != "Closed") ) ) ) From 92a7dca67ce7f0b1e474db6c8a50a9ed53de1341 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 25 Jul 2026 23:54:54 +0530 Subject: [PATCH 19/37] test(subcontracting): cover reservation release on closing a subcontracting order close a partially-received sco with a reserve warehouse and assert the raw-material reservation is released and projected qty recovers. (cherry picked from commit e4b8065a69e085bfd825d4e01885b4d4de903950) --- .../test_subcontracting_order.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 6094229c32d8..1a54343a1b3e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -336,6 +336,85 @@ def test_update_reserved_qty_for_subcontracting(self): bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract ) + def test_close_subcontracting_order_releases_reserved_qty(self): + # RM in stock at the reserve warehouse for transfer + make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100) + make_stock_entry( + target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100 + ) + + bin_before_sco = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname="reserved_qty_for_sub_contract", + as_dict=1, + ) + + # Create SCO with a reserve warehouse on the supplied items + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": "_Test FG Item", + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + + # Transfer only 90% of the raw materials to the supplier warehouse + ste = frappe.get_doc(make_rm_stock_entry(sco.name)) + for item in ste.items: + item.qty *= 0.9 + ste.save() + ste.submit() + sco.load_from_db() + self.assertEqual(sco.status, "Partial Material Transferred") + + # Receive only a partial qty so the order stays open (per_received < 100) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty -= 1 + scr.save() + scr.submit() + sco.load_from_db() + self.assertEqual(sco.status, "Partially Received") + + # Keep another SCO open so transfers from the closed SCO must not reduce its reservation + open_sco = get_subcontracting_order(service_items=service_items) + self.assertEqual(open_sco.status, "Open") + + bin_before_close = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname=["reserved_qty_for_sub_contract", "projected_qty"], + as_dict=1, + ) + + # One unit remains reserved for the partially transferred SCO, plus ten for the open SCO + self.assertEqual( + bin_before_close.reserved_qty_for_sub_contract, + bin_before_sco.reserved_qty_for_sub_contract + 11, + ) + + # Close the partially-received order + sco.update_status("Closed") + self.assertEqual(sco.status, "Closed") + + bin_after_close = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname=["reserved_qty_for_sub_contract", "projected_qty"], + as_dict=1, + ) + + # Closing releases the remaining unit without applying its transfer against the open SCO + self.assertEqual( + bin_after_close.reserved_qty_for_sub_contract, + bin_before_sco.reserved_qty_for_sub_contract + 10, + ) + self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1) + def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self): """A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the From f38b3b422dff038d1435035405385941ef1c6762 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 27 Jul 2026 13:37:08 +0530 Subject: [PATCH 20/37] fix: pool batch slot values on every run, not only when negative A batch is one valuation pool, so any per-slot value difference within a batch is stale detail from the report's own age slots, not real valuation. The rebalance only ran when consumption had already driven a slot negative, so a batch whose receipts landed at different rates kept a skewed split across age buckets (one bucket free, another double-priced) while the total stayed correct. Drop the negative-slot precondition and always spread a batch's pooled value over its slots in proportion to qty. Redistribution preserves group totals, so buckets still sum to Stock Balance; only the split across ages changes. (cherry picked from commit cedaaa3a000be2f8647bd12a57fa33dd6c6ff349) --- .../stock/report/stock_ageing/stock_ageing.py | 18 +++--- .../report/stock_ageing/test_stock_ageing.py | 62 +++++++++++++++++-- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index ebe91dbefb9e..8ab475334d70 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -325,7 +325,7 @@ def generate(self) -> dict: del stock_ledger_entries self._recompute_moving_average_slots() - self._rebalance_negative_batch_slots() + self._rebalance_batch_slots() if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) @@ -347,14 +347,14 @@ def _recompute_moving_average_slots(self) -> None: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) - def _rebalance_negative_batch_slots(self) -> None: + def _rebalance_batch_slots(self) -> None: for item_dict in self.item_details.values(): if item_dict.get("has_batch_no"): - self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"]) + self._rebalance_batch_slot_values(item_dict["fifo_queue"]) - def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None: - """A batch is one valuation pool, so a slot driven negative by consumption - at the pooled rate is stale detail: spread the pool value over its slots.""" + def _rebalance_batch_slot_values(self, fifo_queue: list) -> None: + """A batch is one valuation pool, so per-slot value differences are stale + detail: spread the pool value over its slots in proportion to qty.""" groups = {} for slot in fifo_queue: if is_batch_slot(slot): @@ -362,12 +362,8 @@ def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None: groups.setdefault(key, []).append(slot) for slots in groups.values(): - has_negative_slot = any( - flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0 - for slot in slots - ) total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - if not has_negative_slot or total_qty <= 0: + if total_qty <= 0: continue rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 658056afcd7c..3ad69095cef8 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -569,10 +569,11 @@ def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stoc ], ) - def test_partial_batch_reco_keeps_existing_slot_values(self): + def test_partial_batch_reco_pools_slot_values(self): """Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12] The reco entry qty (delta 2) does not cover the whole batch, so - stock_value_difference / qty is not the batch rate: skip the rescale.""" + stock_value_difference / qty is not the batch rate: skip the rescale. + The batch total (1400) is untouched, then pooled across both slots.""" from erpnext.stock.doctype.item.test_item import make_item item_code = make_item( @@ -612,11 +613,64 @@ def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stoc slots = FIFOSlots(self.filters, sle).generate() queue = slots[item_code]["fifo_queue"] + self.assertEqual( + [slot[:4] for slot in queue], + [ + [batch_no, 1, 10.0, "2021-12-01"], + [batch_no, 1, 2.0, "2021-12-01"], + ], + ) + self.assertAlmostEqual(queue[0][4], 1166.67, places=2) + self.assertAlmostEqual(queue[1][4], 233.33, places=2) + + def test_batch_receipts_at_differing_rates_pool_slot_values(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue. + Nothing goes negative, but the batch is one valuation pool, so both + age slots carry the pooled rate instead of their receipt value.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Split", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-SPLIT-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 10, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + self.assertEqual( queue, [ - [batch_no, 1, 10.0, "2021-12-01", 1000.0], - [batch_no, 1, 2.0, "2021-12-01", 400.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], ], ) From 168ec661e460edd4b086bbf7dfb067a4de4bbcfc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 27 Jul 2026 13:42:25 +0530 Subject: [PATCH 21/37] test: assert batch pooling preserves the group total on a repeating rate (cherry picked from commit 545262c5d4c265d5f41fbb84aa2a0764c28c2db3) # Conflicts: # erpnext/stock/report/stock_ageing/test_stock_ageing.py --- .../report/stock_ageing/test_stock_ageing.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 3ad69095cef8..17136da98301 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -7,10 +7,19 @@ from frappe.tests.utils import FrappeTestCase from erpnext.stock.report.stock_ageing.stock_ageing import ( +<<<<<<< HEAD +======= + BATCH_SLOT_QTY_INDEX, + BATCH_SLOT_VALUE_INDEX, +>>>>>>> 545262c5d4 (test: assert batch pooling preserves the group total on a repeating rate) FIFOSlots, format_report_data, get_average_age, ) +<<<<<<< HEAD +======= +from erpnext.tests.utils import ERPNextTestSuite +>>>>>>> 545262c5d4 (test: assert batch pooling preserves the group total on a repeating rate) class TestStockAgeing(FrappeTestCase): @@ -674,6 +683,53 @@ def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_differ ], ) + def test_batch_pooling_preserves_total_on_repeating_rate(self): + """Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0] + The pooled rate does not terminate, so assert the redistributed + slot values still add back to the batch total.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Residual", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-RESIDUAL-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 3, 3, 100), + make_sle("2021-12-02", "002", 6, 9, 0), + make_sle("2021-12-03", "003", 2, 11, 0), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0]) + self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0) + def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self): """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5] Consuming the zero-valued head slot at the pooled rate drives it From a2ace9d39428cc88f74ae7ecc0ea8312d586ff3d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 27 Jul 2026 13:57:01 +0530 Subject: [PATCH 22/37] fix: resolve backport conflict in stock ageing test imports --- erpnext/stock/report/stock_ageing/test_stock_ageing.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 17136da98301..9feca367e9e1 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -7,19 +7,12 @@ from frappe.tests.utils import FrappeTestCase from erpnext.stock.report.stock_ageing.stock_ageing import ( -<<<<<<< HEAD -======= BATCH_SLOT_QTY_INDEX, BATCH_SLOT_VALUE_INDEX, ->>>>>>> 545262c5d4 (test: assert batch pooling preserves the group total on a repeating rate) FIFOSlots, format_report_data, get_average_age, ) -<<<<<<< HEAD -======= -from erpnext.tests.utils import ERPNextTestSuite ->>>>>>> 545262c5d4 (test: assert batch pooling preserves the group total on a repeating rate) class TestStockAgeing(FrappeTestCase): From 53d9d1c50d5be82b9731e89d8924006375fdec04 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:22:44 +0200 Subject: [PATCH 23/37] fix(crm): align Opportunity status checks with Quotation statuses (backport #57489) (#57490) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/crm/doctype/opportunity/opportunity.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index b68ab28ee82c..a1b03d88f704 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -283,7 +283,11 @@ def has_active_quotation(self): if not self.get("items", []): return frappe.get_all( "Quotation", - {"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, + { + "opportunity": self.name, + "status": ("not in", ["Lost", "Cancelled", "Expired"]), + "docstatus": 1, + }, "name", ) else: @@ -292,14 +296,20 @@ def has_active_quotation(self): select q.name from `tabQuotation` q, `tabQuotation Item` qi where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s - and q.status not in ('Lost', 'Closed')""", + and q.status not in ('Lost', 'Cancelled', 'Expired')""", self.name, ) def has_ordered_quotation(self): if not self.get("items", []): return frappe.get_all( - "Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name" + "Quotation", + { + "opportunity": self.name, + "status": ("in", ["Ordered", "Partially Ordered"]), + "docstatus": 1, + }, + "name", ) else: return frappe.db.sql( @@ -307,7 +317,7 @@ def has_ordered_quotation(self): select q.name from `tabQuotation` q, `tabQuotation Item` qi where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s - and q.status = 'Ordered'""", + and q.status in ('Ordered', 'Partially Ordered')""", self.name, ) From 98a0fd814e28737461e59fd47e5672361ee81c11 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:00:42 +0200 Subject: [PATCH 24/37] fix(crm): clarify the reason why an opportunity cannot be declared as lost (backport #57495) (#57497) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/crm/doctype/opportunity/opportunity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index a1b03d88f704..7d4d1bb10bfe 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -277,7 +277,7 @@ def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=N self.save() else: - frappe.throw(_("Cannot declare as lost, because Quotation has been made.")) + frappe.throw(_("Cannot declare as Lost because an active Quotation exists.")) def has_active_quotation(self): if not self.get("items", []): From c70cf8e554601d6636155ec749ee5371aeb728c1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:10:12 +0530 Subject: [PATCH 25/37] fix(stock): narrow legacy serial ledger lookup by item (backport #57499) (#57505) fix(stock): narrow legacy serial ledger lookup by item (#57499) Filter legacy Stock Ledger Entry lookups by item code so the existing item and warehouse index can reduce rows scanned during serial valuation. (cherry picked from commit 425191e57e555b3d9b6db9914930d32bbadb5c1c) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- erpnext/stock/deprecated_serial_batch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index b57b683c2d8e..c911dd9eeaf1 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -64,6 +64,7 @@ def get_incoming_value_for_serial_nos(self, serial_nos): | (table.serial_no.like("%\n" + serial_no)) | (table.serial_no.like("%\n" + serial_no + "\n%")) ) + & (table.item_code == self.sle.item_code) & (table.company == self.sle.company) & (table.warehouse == self.sle.warehouse) & (table.serial_and_batch_bundle.isnull()) From ae0cd164f3cddaa0afe077f533ed432e481c67c9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:22:46 +0000 Subject: [PATCH 26/37] fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (backport #57380) (#57396) * fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (#57380) * fix: shift same-timestamp sibling SLEs when cancelling an entry update_qty_in_future_sle compared against the reversal SLE's own creation and skipped same-posting_datetime siblings on cancel, leaving their qty_after_transaction stale and causing false negative stock errors. * fix: revert update_qty_in_future_sle cancel tie-break, it double-counted (cherry picked from commit 8c0ec3c179538eb15cbae26384a54de216f52a20) # Conflicts: # erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py * chore: fix conflicts Fix test cases related to stock ledger entry cancellations and ensure correct handling of same timestamp entries. * test: use named item in test_cancel_shifts_same_timestamp_delivery_notes * chore: fix typo --------- Co-authored-by: rohitwaghchaure --- .../test_stock_ledger_entry.py | 75 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 9 +++ 2 files changed, 84 insertions(+) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 6aea0ef53374..d917d5914bf1 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1357,6 +1357,81 @@ def qty_after(voucher): # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). self.assertEqual(qty_after(receipt2), 10) + def test_cancel_shifts_same_timestamp_delivery_notes(self): + item = make_item("Test Shifts Same Timestamp Multiple DNs").name + warehouse = "_Test Warehouse - _TC" + posting_date = today() + posting_time = "10:00:00" + + make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date=posting_date, + posting_time="09:00:00", + ) + + dns = [] + for i in range(5): + dns.append( + create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10 * i, + posting_date=posting_date, + posting_time=posting_time, + ) + ) + time.sleep(1) + + dn = dns[2] + dn.cancel() + + expected_qty_after_transaction_of_dns3 = 40 + qty_after_transaction_of_dns3 = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": dns[3].name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3) + + def test_cancel_updates_bin_stock_value_when_no_sle_shares_timestamp(self): + item = make_item("Test Cancel Bin Stock Value Lone Timestamp").name + warehouse = "_Test Warehouse - _TC" + posting_date = today() + + make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date=posting_date, + posting_time="09:00:00", + ) + + dn = create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date=posting_date, + posting_time="10:00:00", + ) + + dn.cancel() + + # Nothing else sits on the delivery note's posting datetime, so the cancellation leaves no + # live SLE to reprocess. The bin must still fall back to the receipt's stock value. + bin_qty, bin_stock_value = frappe.db.get_value( + "Bin", {"item_code": item, "warehouse": warehouse}, ["actual_qty", "stock_value"] + ) + + self.assertEqual(bin_qty, 100) + self.assertEqual(bin_stock_value, 1000) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ddaaf702c864..a2cce420e3aa 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -799,9 +799,18 @@ def has_stock_reco_with_serial_batch(self, sle): def process_sle_against_current_timestamp(self): sl_entries = get_sle_against_current_voucher(self.args) + if self.args.get("cancelled") and sl_entries: + self.seed_previous_sle_for_cancellation(sl_entries[0]) for sle in sl_entries: self.process_sle(sle) + def seed_previous_sle_for_cancellation(self, anchor_sle): + args = frappe._dict(anchor_sle) + args["sle_id"] = args.name + prev_sle = get_previous_sle_of_current_voucher(args) + if prev_sle: + self.prev_sle_dict[(anchor_sle.item_code, anchor_sle.warehouse)] = prev_sle + def get_future_entries_to_fix(self): # includes current entry! args = self.data[self.args.warehouse].previous_sle or frappe._dict( From ceb677844f30ebcb4c340b8d7c00d7c95ce6eea5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:42 +0000 Subject: [PATCH 27/37] fix(quotation): carry forward communications from opportunity (backport #57507) (#57508) Co-authored-by: Diptanil Saha --- erpnext/selling/doctype/quotation/quotation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index d1a61ad34a3f..c25ab500dc78 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -292,6 +292,7 @@ def on_submit(self): # update enquiry status self.update_opportunity("Quotation") self.update_lead() + self.carry_forward_communication() def on_cancel(self): if self.lost_reasons: @@ -303,6 +304,18 @@ def on_cancel(self): self.update_opportunity("Open") self.update_lead() + def carry_forward_communication(self): + from erpnext.crm.utils import copy_comments, link_communications + + if not ( + self.opportunity + and frappe.get_single_value("CRM Settings", "carry_forward_communication_and_comments") + ): + return + + copy_comments("Opportunity", self.opportunity, self) + link_communications("Opportunity", self.opportunity, self) + def print_other_charges(self, docname): print_lst = [] for d in self.get("taxes"): From 8f68b7ed205050439cbf1eab3d6cfa520a550c13 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:26:47 +0530 Subject: [PATCH 28/37] fix: update operating cost when propagating workstation hour rate to routing (#57504) (cherry picked from commit 39d5fd84db5579a934c344e57a9321f3aef9c750) --- .../doctype/workstation/test_workstation.py | 13 ++++++++++++- .../doctype/workstation/workstation.py | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 1eb47ae577b2..406d08f2e112 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -72,7 +72,7 @@ def test_update_bom_operation_rate(self): test_routing_operations = [ {"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60}, - {"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60}, + {"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30}, ] routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations) bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR") @@ -94,6 +94,17 @@ def test_update_bom_operation_rate(self): self.assertEqual(bom_doc.operations[0].hour_rate, 250) self.assertEqual(bom_doc.operations[1].hour_rate, 250) + # hour_rate propagation must also refresh operating_cost (hour_rate * time_in_mins / 60) + # on the Routing's BOM Operation rows; the 30-min op exercises the arithmetic. + for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)): + hour_rate, operating_cost = frappe.db.get_value( + "BOM Operation", + {"parent": routing_doc.name, "parenttype": "Routing", "operation": operation}, + ["hour_rate", "operating_cost"], + ) + self.assertEqual(hour_rate, 250) + self.assertEqual(operating_cost, expected_operating_cost) + def make_workstation(*args, **kwargs): args = args if args else kwargs diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index c45fd7852f44..1e65ea7bedfc 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -152,9 +152,10 @@ def update_bom_operation(self): for bom_no in bom_list: frappe.db.sql( - """update `tabBOM Operation` set hour_rate = %s + """update `tabBOM Operation` + set hour_rate = %s, operating_cost = %s * time_in_mins / 60 where parent = %s and workstation = %s""", - (self.hour_rate, bom_no[0], self.name), + (self.hour_rate, self.hour_rate, bom_no[0], self.name), ) def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False): From d96999de7f992ae41c54d2c472c44d99638d7115 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 28 Jul 2026 10:56:06 +0530 Subject: [PATCH 29/37] fix: stop storing "{supplier_name}" / "{customer_name}" as the document title Purchase Order, Sales Order and Subcontracting Order point title_field at the party name field, so Document.set_title_field() never rendered their title template and every new record stored the placeholder verbatim. On Purchase Order the field is also mandatory, so the junk value is guaranteed. Drop the dead defaults (and Purchase Order's reqd, which would otherwise make an always-empty field mandatory) and backfill the affected rows. --- .../purchase_order/purchase_order.json | 6 ++---- .../doctype/purchase_order/purchase_order.py | 2 +- erpnext/patches.txt | 1 + erpnext/patches/v15_0/fix_titles.py | 20 +++++++++++++++++++ .../doctype/sales_order/sales_order.json | 3 +-- .../subcontracting_order.json | 3 +-- erpnext/tests/test_init.py | 15 ++++++++++++++ 7 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 erpnext/patches/v15_0/fix_titles.py diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index cfea482d2174..7d92a197a829 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -171,14 +171,12 @@ }, { "allow_on_submit": 1, - "default": "{supplier_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, "label": "Title", "no_copy": 1, - "print_hide": 1, - "reqd": 1 + "print_hide": 1 }, { "fieldname": "naming_series", @@ -1309,7 +1307,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-07-31 17:19:40.816883", + "modified": "2026-07-28 12:20:11.284370", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 95b67d49429b..651c781f4f69 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -162,7 +162,7 @@ class PurchaseOrder(BuyingController): taxes_and_charges_deducted: DF.Currency tc_name: DF.Link | None terms: DF.TextEditor | None - title: DF.Data + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_net_weight: DF.Float diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 4be98e72eea9..02103c8071a4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -444,3 +444,4 @@ erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.backfill_pick_list_transferred_qty erpnext.patches.v16_0.access_control_for_project_users erpnext.patches.v16_0.rename_ar_ap_ageing_filter +erpnext.patches.v15_0.fix_titles diff --git a/erpnext/patches/v15_0/fix_titles.py b/erpnext/patches/v15_0/fix_titles.py new file mode 100644 index 000000000000..32352413c3ae --- /dev/null +++ b/erpnext/patches/v15_0/fix_titles.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + """ + These doctypes point `title_field` at the party name field, so their `title` + default was never rendered and got stored as the literal template string. + """ + + for doctype, source_field in ( + ("Purchase Order", "supplier_name"), + ("Subcontracting Order", "supplier_name"), + ("Sales Order", "customer_name"), + ): + table = frappe.qb.DocType(doctype) + ( + frappe.qb.update(table) + .set(table.title, table[source_field]) + .where(table.title == f"{{{source_field}}}") + ).run() diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index e649b8e93836..876cc5bb7609 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -187,7 +187,6 @@ }, { "allow_on_submit": 1, - "default": "{customer_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -1680,7 +1679,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-03-06 15:33:49.059029", + "modified": "2026-07-28 12:20:44.130918", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json index 206e3135dfb6..cbcf1ddc03a2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json @@ -66,7 +66,6 @@ "fields": [ { "allow_on_submit": 1, - "default": "{supplier_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -465,7 +464,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2024-12-06 15:21:49.924146", + "modified": "2026-07-28 12:21:09.663812", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order", diff --git a/erpnext/tests/test_init.py b/erpnext/tests/test_init.py index 2b4ea9fa8dc8..1509fb16c2dc 100644 --- a/erpnext/tests/test_init.py +++ b/erpnext/tests/test_init.py @@ -49,3 +49,18 @@ def test_patches(self): from frappe.tests.test_patches import check_patch_files check_patch_files("erpnext") + + def test_no_unrendered_title_templates(self): + modules = frappe.get_all("Module Def", filters={"app_name": "erpnext"}, pluck="name") + for doctype in frappe.get_all("DocType", filters={"module": ("in", modules)}, pluck="name"): + meta = frappe.get_meta(doctype) + field = meta.get_field("title") + if not field or not field.default or "{" not in field.default: + continue + + self.assertEqual( + meta.title_field, + "title", + f"{doctype}: title default {field.default!r} is stored verbatim because " + "Document.set_title_field() only renders it when title_field is 'title'", + ) From 0ad0d7733b3001a67b540d4ac9cd94cb3a93ffb6 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 28 Jul 2026 11:13:08 +0530 Subject: [PATCH 30/37] fix: use company currency instead of global default in report (backport #56561) Reports like Sales Order Trends and Purchase Order Trends showed the global default currency symbol instead of the transacting company's currency. Threads the company currency through conditions["company_currency"] in trends.get_columns and uses it for both the chart's currency and the Total row. The chart now skips the grand-total row by its label instead of by a falsy first periodic cell, so the already-summed Total row is not added into the datapoints a second time. Backport of #56561 (frappe/erpnext). Two parts of the original PR are not included: the Landed Cost Report does not exist on this branch, and the trends report test files do not exist either. --- erpnext/accounts/report/cash_flow/cash_flow.py | 1 + .../accounts/report/gross_profit/gross_profit.py | 2 ++ .../purchase_order_trends.py | 13 ++++++++++--- erpnext/controllers/trends.py | 16 +++++++++++++--- .../report/quotation_trends/quotation_trends.py | 13 ++++++++++--- .../sales_order_trends/sales_order_trends.py | 12 ++++++++++-- .../delivery_note_trends/delivery_note_trends.py | 6 ++++-- .../purchase_receipt_trends.py | 6 ++++-- 8 files changed, 54 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index c4b894a34b82..d476fd4b3efd 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -73,6 +73,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 8ecfe51d2445..f557635aac1a 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 a7b4a7207c6a..dcfc9ad70765 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -14,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 @@ -39,9 +38,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]] @@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 28ff84c83fd9..38e37f6ddbed 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.utils import DateTimeLikeObject, getdate, today +import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -42,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 @@ -206,7 +210,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"], conditions.get("company_currency")) data.append(total_row) else: data = frappe.db.sql( @@ -231,20 +235,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"], conditions.get("company_currency")) data.append(total_row) return data -def calculate_total_row(data, columns): +def calculate_total_row(data, columns, company_currency=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 "Link/Currency" in col: + currency_col_idx = i for row in data: for i in total_values.keys(): @@ -254,6 +261,9 @@ def wrap_in_quotes(label): 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_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 92f9d17a9c7c..e5b625693944 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - from frappe import _ 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]] @@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("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 0827110ae5d6..fb35df14a653 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -39,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]] @@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("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 a456bad72d7e..1365a02ba256 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -14,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}'" @@ -52,4 +52,6 @@ def wrap_in_quotes(label): }, "type": "bar", "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } 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 9d313b477a35..4210d1a36045 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py @@ -14,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}'" @@ -53,4 +53,6 @@ def wrap_in_quotes(label): "type": "bar", "colors": ["#5e64ff"], "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } From 8cceb6af101e5f2af2f3575b1f0a2abf0e9136e3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 28 Jul 2026 11:27:46 +0530 Subject: [PATCH 31/37] fix: detect the currency column by fieldtype in trends total row calculate_total_row tested each column with `"Link/Currency" in col`, but based-on and group-by columns are dicts, so the test checked the dict's keys and never matched. currency_col_idx stayed None and the grand-total row's currency cell was left unset, so Total(Amt) rendered with the global default currency instead of the company's. Match the dict's fieldtype/options instead. Dict columns are never numeric and string columns are never Link columns, so the two branches are now mutually exclusive. --- erpnext/controllers/trends.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 38e37f6ddbed..76a809c1b8a5 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -248,10 +248,12 @@ def wrap_in_quotes(label): total_values = {} currency_col_idx = None for i, col in enumerate(columns): - if "Float" in col or "Currency/currency" in col: + # based-on and group-by columns are dicts, periodic and total columns are strings + if isinstance(col, dict): + if col.get("fieldtype") == "Link" and col.get("options") == "Currency": + currency_col_idx = i + elif "Float" in col or "Currency/currency" in col: total_values[i] = 0 - if "Link/Currency" in col: - currency_col_idx = i for row in data: for i in total_values.keys(): From 25e5b107be25f42c883e0d17a627ebf6db6193c0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 28 Jul 2026 12:49:23 +0530 Subject: [PATCH 32/37] fix(manufacturing): update cost of BOMs created via BOM Creator `calculate_rm_cost` skipped rate refresh whenever `bom_creator` was set, so neither the Update Cost button nor the BOM Update Tool could ever refresh those BOMs. Every BOM in a multi-level tree carries the field, so whole trees stayed frozen at their creation rates. The guard replaced the removed `rm_cost_as_per == "Manual"` check in 0b63dbf, on the assumption that BOM Creator rows hold manual rates. They do not: BOM Creator recomputes every row from `rm_cost_as_per` on save. --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 1bbc23afeeaf..5976cb4fa945 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -787,7 +787,7 @@ def calculate_rm_cost(self, save=False): for d in self.get("items"): old_rate = d.rate - if not self.bom_creator and d.is_stock_item: + if d.is_stock_item: d.rate = self.get_rm_rate( { "company": self.company, From 20b6dd3d0f59ec09a250312239f41b2489b69549 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:51:53 +0530 Subject: [PATCH 33/37] fix: respect child warehouse account override in Stock and Account Value Comparison (#57552) fix: respect child warehouse account override in stock vs account value comparison (cherry picked from commit 5fc20d6b8ed97ec56dba5327588e5a94f3975ef2) --- erpnext/stock/doctype/warehouse/warehouse.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 45d7a459fb5a..b9600fddc9bb 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -8,7 +8,7 @@ from frappe.utils.nestedset import NestedSet from pypika.terms import ExistsCriterion -from erpnext.stock import get_warehouse_account +from erpnext.stock import get_warehouse_account, get_warehouse_account_map class Warehouse(NestedSet): @@ -195,11 +195,19 @@ def get_child_warehouses(warehouse): def get_warehouses_based_on_account(account, company=None): warehouses = [] + warehouse_account_map = None for d in frappe.get_all( "Warehouse", fields=["name", "is_group"], filters={"account": account, "disabled": 0} ): if d.is_group: - warehouses.extend(get_child_warehouses(d.name)) + # Keep only children whose effective account matches; a child can override the group's account + if warehouse_account_map is None: + warehouse_account_map = get_warehouse_account_map(company) + warehouses.extend( + w + for w in get_child_warehouses(d.name) + if (warehouse_account_map.get(w) or {}).get("account") == account + ) else: warehouses.append(d.name) From 12b4c134ca4ab2624963a0efde4da4208ae87632 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 28 Jul 2026 17:20:58 +0530 Subject: [PATCH 34/37] fix(manufacturing): fall back to UOM Conversion Factor in Production Plan (backport #57553) (#57555) fix(manufacturing): fall back to UOM Conversion Factor in Production Plan Production Plan read the conversion factor straight off the item's own UOM child table, so an item with a purchase UOM but no matching row threw "UOM Conversion factor not found" while Stock Entry silently resolved it from the item's variant template or the UOM Conversion Factor doctype. Resolve it the same way, and keep returning None when nothing is configured anywhere so the missing-setup error still fires. --- .../production_plan/production_plan.py | 10 +- .../production_plan/test_production_plan.py | 112 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 52f301bad117..0382d0e201a8 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -29,6 +29,7 @@ from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.utils import get_or_make_bin from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -1228,9 +1229,16 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p def get_uom_conversion_factor(item_code, uom): - return frappe.db.get_value( + item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True) + conversion_factor = frappe.db.get_value( "UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor" ) + if not conversion_factor and item.variant_of: + conversion_factor = frappe.db.get_value( + "UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor" + ) + + return conversion_factor or get_uom_conv_factor(uom, item.stock_uom) def get_subitems( diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index b1f1866a12c6..72fb5debb97d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1606,6 +1606,118 @@ def test_transfer_and_purchase_mrp_for_purchase_uom(self): self.assertTrue(row.warehouse == mrp_warhouse) self.assertEqual(row.quantity, 12.0) + def test_purchase_uom_falls_back_to_uom_conversion_factor(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}): + frappe.get_doc( + doctype="UOM Conversion Factor", + category="Mass", + from_uom="Kg", + to_uom="Gram", + value=1000, + ).insert() + + rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"}) + rm.purchase_uom = "Kg" + rm.save() + self.assertFalse([row for row in rm.uoms if row.uom == "Kg"]) + + bom_tree = {"Test FG Item Global CF": {rm.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=2000, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + row = frappe._dict(next(item for item in items if item["item_code"] == rm.name)) + self.assertEqual(row.uom, "Kg") + self.assertEqual(row.conversion_factor, 1000) + self.assertEqual(row.quantity, 2) + + def test_variant_inherits_purchase_uom_conversion_factor_of_template(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + template = make_item( + "TRMVCF", + { + "is_stock_item": 1, + "stock_uom": "Nos", + "has_variants": 1, + "attributes": [{"attribute": "Colour"}], + }, + ) + if not [row for row in template.uoms if row.uom == "Box"]: + template.purchase_uom = "Box" + template.append("uoms", {"uom": "Box", "conversion_factor": 12}) + template.save() + + if not frappe.db.exists("Item", "TRMVCF-RED"): + create_variant("TRMVCF", {"Colour": "Red"}).insert() + + variant = frappe.get_doc("Item", "TRMVCF-RED") + variant.uoms = [row for row in variant.uoms if row.uom != "Box"] + variant.purchase_uom = "Box" + variant.save() + + bom_tree = {"Test FG Item Variant CF": {variant.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=24, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + row = frappe._dict(next(item for item in items if item["item_code"] == variant.name)) + self.assertEqual(row.conversion_factor, 12) + self.assertEqual(row.quantity, 2) + + def test_missing_purchase_uom_conversion_factor_throws(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"}) + rm.purchase_uom = "Box" + rm.save() + + bom_tree = {"Test FG Item Missing CF": {rm.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=10, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + with self.assertRaises(frappe.ValidationError) as error: + get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + self.assertIn("UOM Conversion factor", str(error.exception)) + def test_mr_qty_for_same_rm_with_different_sub_assemblies(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom From 41effcf7543095575cec7ff7b66ee06113882253 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:22:13 +0530 Subject: [PATCH 35/37] fix: add permission check for `get_item_details` (backport #57515) (#57550) Co-authored-by: Diptanil Saha --- .../doctype/item_default/item_default.json | 11 +- .../doctype/item_reorder/item_reorder.json | 217 ++++++------------ erpnext/stock/get_item_details.py | 1 + 3 files changed, 74 insertions(+), 155 deletions(-) diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 04212b31795d..b542c7529a75 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -38,6 +38,7 @@ { "fieldname": "default_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Default Warehouse", "options": "Warehouse", @@ -63,7 +64,8 @@ "fieldname": "buying_cost_center", "fieldtype": "Link", "label": "Default Buying Cost Center", - "options": "Cost Center" + "options": "Cost Center", + "ignore_user_permissions": 1 }, { "fieldname": "default_supplier", @@ -90,6 +92,7 @@ "fieldname": "selling_cost_center", "fieldtype": "Link", "label": "Default Selling Cost Center", + "ignore_user_permissions": 1, "options": "Cost Center" }, { @@ -99,12 +102,14 @@ { "fieldname": "income_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Income Account", "options": "Account" }, { "fieldname": "default_discount_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Discount Account", "options": "Account" }, @@ -123,6 +128,7 @@ "depends_on": "eval: parent.enable_deferred_expense", "fieldname": "deferred_expense_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Deferred Expense Account", "options": "Account" }, @@ -130,6 +136,7 @@ "depends_on": "eval: parent.enable_deferred_revenue", "fieldname": "deferred_revenue_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Deferred Revenue Account", "options": "Account" }, @@ -140,7 +147,7 @@ ], "istable": 1, "links": [], - "modified": "2025-03-17 13:46:09.719105", + "modified": "2026-07-28 15:39:44.848087", "modified_by": "Administrator", "module": "Stock", "name": "Item Default", diff --git a/erpnext/stock/doctype/item_reorder/item_reorder.json b/erpnext/stock/doctype/item_reorder/item_reorder.json index a03bd458d4f6..85b9d942fc15 100644 --- a/erpnext/stock/doctype/item_reorder/item_reorder.json +++ b/erpnext/stock/doctype/item_reorder/item_reorder.json @@ -1,161 +1,72 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-03-07 11:42:59", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 1, + "actions": [], + "autoname": "hash", + "creation": "2013-03-07 11:42:59", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "warehouse", + "warehouse_group", + "warehouse_reorder_level", + "warehouse_reorder_qty", + "material_request_type" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Check in (group)", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 3, + "fieldname": "warehouse_group", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Check Availability in Warehouse", + "options": "Warehouse" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Request for", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Request for", + "options": "Warehouse", + "reqd": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_reorder_level", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Re-order Level", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "warehouse_reorder_level", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Re-order Level" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_reorder_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Re-order Qty", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "warehouse_reorder_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Re-order Qty" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "material_request_type", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Material Request Type", - "length": 0, - "no_copy": 0, - "options": "Purchase\nTransfer\nMaterial Issue\nManufacture", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "material_request_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Material Request Type", + "options": "Purchase\nTransfer\nMaterial Issue\nManufacture", + "reqd": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 1, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2023-06-21 15:13:38.270046", - "modified_by": "Administrator", - "module": "Stock", - "name": "Item Reorder", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_order": "ASC", - "track_seen": 0 -} + ], + "idx": 1, + "in_create": 1, + "istable": 1, + "links": [], + "modified": "2026-07-28 17:05:12.778047", + "modified_by": "Administrator", + "module": "Stock", + "name": "Item Reorder", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "ASC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 5b2854b7b8d6..aecc20d8aa21 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -63,6 +63,7 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru for_validate = process_string_args(for_validate) overwrite_warehouse = process_string_args(overwrite_warehouse) item = frappe.get_cached_doc("Item", args.item_code) + item.check_permission() validate_item_details(args, item) if isinstance(doc, str): From 1d60ab449c7856744d6e947d0fa09f5b63dfe732 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:59:17 +0530 Subject: [PATCH 36/37] fix(tnc): `get_terms_and_conditions` render_template with `safe_exec` (backport #56944) (backport #56977) (#57106) Co-authored-by: Diptanil Saha --- .../terms_and_conditions/terms_and_conditions.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 127517a1e3f3..d3d056f38624 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -30,7 +30,7 @@ class TermsandConditions(Document): def validate(self): if self.terms: - validate_template(self.terms) + validate_template(self.terms, restrict_globals=True) if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled): throw(_("At least one of the Applicable Modules should be selected")) @@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc): if isinstance(doc, str): doc = json.loads(doc) - terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name) + tnc = frappe.get_cached_doc("Terms and Conditions", template_name) + tnc.check_permission() - if terms_and_conditions.terms: - return frappe.render_template(terms_and_conditions.terms, doc) + if not tnc.terms: + return + + return frappe.render_template(tnc.terms, doc, restrict_globals=1) From abc53b0d3942c16e689a2df67c6316b60b4e9b27 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Wed, 29 Jul 2026 00:18:06 +0530 Subject: [PATCH 37/37] fix(ppcv): replace incorrect usage of `frappe.in_test` with `frappe.flags.in_test` in version-15 (#57579) --- .../process_period_closing_voucher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 6315560b89f5..ecedc8dc089a 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -124,7 +124,7 @@ def initialize_parallel_threads(docname: str): ) # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep else: frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -272,7 +272,7 @@ def schedule_next_date(docname: str): ) # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep frappe.enqueue( @@ -449,7 +449,7 @@ def summarize_and_post_ledger_entries(docname): # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") @@ -599,7 +599,7 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie "Completed", ) # commit heavy computation before touching PPCV or PPCVD - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep # chain call