From a51750db56a1e92794413c8c9cd09074c303b264 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 10 Jul 2026 11:37:50 +0530 Subject: [PATCH 01/51] fix(stock): pick list serial batch posting date (#57015) * fix(stock): fall back to current date/time for serial and batch bundle posting datetime Pick List has no posting_date/posting_time fields, so creating or updating a Serial and Batch Bundle from a Pick List row crashed with "TypeError: combine() argument 1 must be datetime.date, not None". Fall back to today/now when the parent voucher doesn't carry its own posting date. Fixes #56951 * fix(stock): accept a plain dict for add_serial_batch_ledgers' doc and child_row The whitelisted add_serial_batch_ledgers only converted child_row into an attribute-accessible frappe._dict when it arrived as a JSON string, and doc's type hint only allowed Document | str. Frappe's JSON API delivers both as plain dicts (see frappe.app.make_form_dict, which parses the request body with orjson and only wraps the top-level dict, not nested values), so every real request was rejected before the handler body ever ran: first with a FrappeTypeError on doc, and once that's fixed, with an AttributeError on child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in frappe._dict (and leaves a real Document instance untouched), so routing child_row through it unconditionally fixes both. (cherry picked from commit 7e46be2a33266f149a331e39ff4e40fd1fd0ade7) --- .../serial_and_batch_bundle.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4fa630fb8a8..e3428c98add 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -26,6 +26,7 @@ from frappe.utils import ( ) from frappe.utils.csvutils import build_csv_response +from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem from erpnext.stock.serial_batch_bundle import ( BatchNoValuation, SerialNoValuation, @@ -2092,9 +2093,14 @@ def get_reference_serial_and_batch_bundle(child_row): @frappe.whitelist() -def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object: - if isinstance(child_row, str): - child_row = frappe._dict(parse_json(child_row)) +def add_serial_batch_ledgers( + entries: list | str, + child_row: PurchaseReceiptItem | dict | str, + doc: Document | dict | str, + warehouse: str | None = None, + do_not_save: bool = False, +): + child_row = parse_json(child_row) if isinstance(entries, str): entries = parse_json(entries) @@ -2126,7 +2132,9 @@ def create_serial_batch_no_ledgers( if parent_doc.get("doctype") == "Stock Entry": warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse - posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc = frappe.get_doc( { @@ -2243,7 +2251,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh ) doc.voucher_detail_no = child_row.name - doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc.posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc.warehouse = warehouse or doc.warehouse doc.set("entries", []) From 94d63ebb49e3f2d35d754ffac0079ab390b7bcf9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:02:41 +0000 Subject: [PATCH 02/51] fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57510) fix(stock): value batched packed-item returns from the original bundle (#57327) * fix(stock): value batched packed-item returns from the original bundle when a return delivery note or sales invoice bundle is built via the use_serial_batch_fields / sle-driven path, its voucher_detail_no keeps the packed item instead of being remapped to the parent dn/si item. the return valuation lookup then misses and the bundle values at zero, so the sle stock_value_difference stays wrong even after a repost. resolve the original dn/si item via the packed item's parent_detail_docname when the direct lookup fails, so the return values from the original outward bundle on both submit and repost. * test(stock): cover batched packed-item return valuation on repost (cherry picked from commit d37e905322978b2d0e5ff0452c2a00db9f8be12f) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- .../delivery_note/test_delivery_note.py | 70 +++++++++++++++++++ .../serial_and_batch_bundle.py | 15 ++++ 2 files changed, 85 insertions(+) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index e77940b1661..25c86fee7c9 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(gle_warehouse_amount, 1400) + def test_return_bundle_voucher_detail_no_as_packed_item(self): + """Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + warehouse = "_Test Warehouse - _TC" + packed_item = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-DN-RET-VDN-.#####", + } + ).name + bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name + make_product_bundle(bundle_item, [packed_item], qty=20) + + make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35) + + dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3) + + return_dn = make_sales_return(dn.name) + return_dn.items[0].qty = -2 + return_dn.submit() + return_dn.reload() + + packed_row = return_dn.packed_items[0] + bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle) + + # Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0. + bundle.db_set("voucher_detail_no", packed_row.name) + bundle.db_set({"avg_rate": 0, "total_amount": 0}) + for entry in bundle.entries: + entry.db_set({"incoming_rate": 0, "stock_value_difference": 0}) + packed_row.db_set("incoming_rate", 0) + frappe.db.set_value( + "Stock Ledger Entry", + { + "voucher_type": "Delivery Note", + "voucher_no": return_dn.name, + "item_code": packed_item, + "is_cancelled": 0, + }, + {"incoming_rate": 0, "stock_value_difference": 0}, + ) + + frappe.get_doc( + doctype="Repost Item Valuation", + based_on="Transaction", + voucher_type="Delivery Note", + voucher_no=return_dn.name, + posting_date=return_dn.posting_date, + posting_time=return_dn.posting_time, + ).submit() + + bundle.reload() + self.assertEqual(flt(bundle.avg_rate), 35) + + incoming_rate, stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Delivery Note", + "voucher_no": return_dn.name, + "item_code": packed_item, + "is_cancelled": 0, + }, + ["incoming_rate", "stock_value_difference"], + ) + self.assertEqual(flt(incoming_rate), 35) + self.assertEqual(flt(stock_value_difference), 1400) + def test_bin_details_of_packed_item(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index fd958d55c61..ca18baac969 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -505,6 +505,11 @@ class SerialandBatchBundle(Document): self.child_table, self.voucher_detail_no, field ) + if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"): + # Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item + # as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses. + return_against_voucher_detail_no = self.get_return_against_packed_item(field) + filters = [ ["Serial and Batch Bundle", "voucher_no", "=", return_against], ["Serial and Batch Entry", "docstatus", "=", 1], @@ -548,6 +553,16 @@ class SerialandBatchBundle(Document): return valuation_details + def get_return_against_packed_item(self, field): + """Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item.""" + parent_detail_docname = frappe.db.get_value( + "Packed Item", self.voucher_detail_no, "parent_detail_docname" + ) + if not parent_detail_docname: + return + + return frappe.db.get_value(self.child_table, parent_detail_docname, field) + def get_legacy_valuation_rate_for_return_entry( self, return_against, return_against_voucher_detail_no, return_warehouse=None ): From 7cecff9fa41522a75c7db725d3778ea713110263 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:41:53 +0530 Subject: [PATCH 03/51] fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57602) fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592) on_cancel pre-blocked cancellation with its own "Purchase Invoice is already submitted" guard, duplicating the check Frappe already runs for any submitted linked document. Drop the guard and the unused check_next_docstatus() method it mirrored so the receipt defers to the framework: the Cancel All Documents flow cancels the invoice first and then the receipt, and a direct cancel is still rejected by Frappe's linked-document check. Add a regression test that a direct cancel of a receipt with a submitted invoice is rejected and rolls back, leaving no stray stock or GL entries. (cherry picked from commit cfe18e842739ee7c3f032f2fd007fce52e434fa9) # Conflicts: # erpnext/stock/doctype/purchase_receipt/purchase_receipt.py # erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- .../purchase_receipt/purchase_receipt.py | 19 -------------- .../purchase_receipt/test_purchase_receipt.py | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 10099631a75..08fe7feff56 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController): self.set_consumed_qty_in_subcontract_order() self.reserve_stock_for_sales_order() - def check_next_docstatus(self): - submit_rv = frappe.db.sql( - """select t1.name - from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2 - where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""", - (self.name), - ) - if submit_rv: - frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0])) - def on_cancel(self): super().on_cancel() self.check_on_hold_or_closed_status() - # Check if Purchase Invoice has been submitted against current Purchase Order - submitted = frappe.db.sql( - """select t1.name - from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2 - where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""", - self.name, - ) - if submitted: - frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0])) self.update_prevdoc_status() self.update_billing_status() diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index edde28a04e6..9d51a20f605 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5446,6 +5446,32 @@ class TestPurchaseReceipt(FrappeTestCase): srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) + def test_cancel_blocked_by_submitted_invoice_rolls_back(self): + """A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink + check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole + transaction has to roll back: the receipt stays submitted with no leaked ledger entries.""" + pr = make_purchase_receipt() + pi = make_purchase_invoice(pr.name) + pi.insert() + pi.submit() + + pr.reload() + status_before = pr.status + sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}) + gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name}) + + frappe.db.savepoint("before_blocked_cancel") + with self.assertRaises(frappe.LinkExistsError) as cm: + pr.cancel() + self.assertIn(pi.name, str(cm.exception)) + frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback + + pr.reload() + self.assertEqual(pr.docstatus, 1) + self.assertEqual(pr.status, status_before) + self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before) + self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier From 455d6d4ac15bd7163977adae5184e2861d7341be Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 30 Jul 2026 14:31:54 +0530 Subject: [PATCH 04/51] fix: source manually created asset value from valuation rate --- erpnext/assets/doctype/asset/asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 7637192ba9b..292ef1631ef 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype): return { "company": purchase_doc.company, "purchase_date": purchase_doc.get("posting_date"), - "gross_purchase_amount": flt(first_item.base_net_amount), + "gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty), "asset_quantity": first_item.qty, "cost_center": first_item.cost_center or purchase_doc.get("cost_center"), "asset_location": first_item.get("asset_location"), From 848335086c5a710442a0f859acbe71bcf95c49e2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 30 Jul 2026 13:08:28 +0530 Subject: [PATCH 05/51] fix: seed standard Item Groups under the existing tree root install_fixtures always inserted "All Item Groups" as a parentless group. On a site where another app had already created the root, ItemGroup.validate re-parented it, leaving a second group-root that held the standard groups while the real root held everything else. This is reproducible with the healthcare app on a non-English site: its after_install seeds the root as _("All Item Groups"), so a pt-BR site gets "Todos os Grupos de Itens" as the root before the setup wizard runs. The split predates #57390 -- the old translated-name lookup resolved to the same root and produced an identical tree. Resolve the root once with get_root_of (falling back to the canonical English name on fresh installs) and use it for the root record's exists-guard and the standard groups' parent, matching Company.create_default_departments. Patch merges an already-seeded "All Item Groups" into the root it sits under, lifting its children and repointing every link. Closes #57581 (cherry picked from commit e7088d89812aaca79cedc66e818206a9fad712c5) # Conflicts: # erpnext/setup/doctype/item_group/test_item_group.py # erpnext/setup/setup_wizard/operations/install_fixtures.py --- erpnext/patches.txt | 1 + .../v16_0/merge_seeded_item_group_root.py | 23 ++++++ .../doctype/item_group/test_item_group.py | 76 +++++++++++++++++++ .../operations/install_fixtures.py | 19 +++-- 4 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 erpnext/patches/v16_0/merge_seeded_item_group_root.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 022b33cac10..9ca27a734d8 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users erpnext.patches.v16_0.rename_ar_ap_ageing_filter erpnext.patches.v15_0.fix_titles erpnext.patches.v16_0.backfill_repost_accounting_ledger_status +erpnext.patches.v16_0.merge_seeded_item_group_root diff --git a/erpnext/patches/v16_0/merge_seeded_item_group_root.py b/erpnext/patches/v16_0/merge_seeded_item_group_root.py new file mode 100644 index 00000000000..95683fc96f0 --- /dev/null +++ b/erpnext/patches/v16_0/merge_seeded_item_group_root.py @@ -0,0 +1,23 @@ +import frappe +from frappe.utils.nestedset import get_root_of + +SEEDED_ROOT = "All Item Groups" + + +def execute(): + """Collapse the "All Item Groups" node seeded under a pre-existing root. + + Setup seeding always inserted "All Item Groups" as a parentless group. On a + site where another app had already created the root (under a translated + name), it was re-parented instead, leaving a second group-root holding the + standard Item Groups. + """ + root = get_root_of("Item Group") + if not root or root == SEEDED_ROOT: + return + + seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True) + if not seeded or not seeded.is_group or seeded.parent_item_group != root: + return + + frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False) diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index a579fb703da..2f14d1ec925 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -1,8 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +<<<<<<< HEAD import unittest +======= +from unittest.mock import patch +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) import frappe from frappe.utils.nestedset import ( @@ -16,6 +20,8 @@ from frappe.utils.nestedset import ( test_records = frappe.get_test_records("Item Group") +TRANSLATED_ROOT = "Todos os Grupos de Itens" + class TestItem(unittest.TestCase): def test_basic_tree(self, records=None): @@ -234,3 +240,73 @@ class TestItem(unittest.TestCase): "_Test Item Group B - 3", merge=True, ) +<<<<<<< HEAD +======= + + def test_preset_records_use_existing_root(self): + from erpnext.setup.setup_wizard.operations import install_fixtures + + with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT): + records = [ + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ] + + root_record, *child_records = records + self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT) + self.assertTrue(root_record["__condition"]()) + self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT}) + + with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"): + root_record = next( + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ) + self.assertFalse(root_record["__condition"]()) + + def test_patch_merges_seeded_root_into_existing_root(self): + from erpnext.patches.v16_0.merge_seeded_item_group_root import execute + + self._nest_root_under(TRANSLATED_ROOT) + self.assertEqual( + frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT + ) + + execute() + + self.assertFalse(frappe.db.exists("Item Group", "All Item Groups")) + self.assertEqual( + frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"), + [TRANSLATED_ROOT], + ) + self.assertEqual( + frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT + ) + self.test_basic_tree() + + def _nest_root_under(self, new_root): + """Recreate the tree left behind by seeding a root under a pre-existing one.""" + frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert() + + ig = frappe.qb.DocType("Item Group") + frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run() + frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run() + rebuild_tree("Item Group") + + def _move_it_back(self): + group_b = frappe.get_doc("Item Group", "_Test Item Group B") + group_b.parent_item_group = "All Item Groups" + group_b.save() + self.test_basic_tree() + + def _get_no_of_children(self, item_group): + def get_no_of_children(item_groups, no_of_children): + children = [] + for ig in item_groups: + children += frappe.get_all("Item Group", filters={"parent_item_group": ig}, pluck="name") + + if len(children): + return get_no_of_children(children, no_of_children + len(children)) + else: + return no_of_children + + return get_no_of_children([item_group], 0) +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 0f3356ffa50..8bc1aa515ac 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import ( ) from frappe.desk.page.setup_wizard.setup_wizard import make_records from frappe.utils import cstr, getdate +from frappe.utils.nestedset import get_root_of from erpnext.accounts.doctype.account.account import RootNotEditable from erpnext.regional.address_template.setup import set_up_address_templates @@ -23,47 +24,53 @@ def read_lines(filename: str) -> list[str]: return (Path(__file__).parent.parent / "data" / filename).read_text().splitlines() +<<<<<<< HEAD def install(country=None): +======= +def get_preset_records(country=None): + root_item_group = get_root_of("Item Group") or _("All Item Groups") +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) records = [ # ensure at least an empty Address Template exists for this Country {"doctype": "Address Template", "country": country}, # item group { "doctype": "Item Group", - "item_group_name": _("All Item Groups"), + "item_group_name": root_item_group, "is_group": 1, "parent_item_group": "", + "__condition": lambda: not frappe.db.exists("Item Group", root_item_group), }, { "doctype": "Item Group", "item_group_name": _("Products"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, "show_in_website": 1, }, { "doctype": "Item Group", "item_group_name": _("Raw Material"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Services"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Sub Assemblies"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Consumable"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, # Stock Entry Type { From 1602639a8065c44301b3d1d18a533f9b1cb7c6b4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 30 Jul 2026 19:22:29 +0530 Subject: [PATCH 06/51] fix: resolve backport conflicts for version-15 install() holds the preset list inline on this branch, so the root is resolved there instead of in get_preset_records. Dropping the preset-record test with it -- there is no seam to call without running the whole installer. The patch test is adapted to this branch: TestItem does not roll back between tests, so it restores the original root name, and it passes parent_item_group explicitly since ItemGroup.validate skips root-defaulting under frappe.flags.in_test. --- .../doctype/item_group/test_item_group.py | 73 ++++++------------- .../operations/install_fixtures.py | 4 - 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index 2f14d1ec925..09a5e64acd4 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -1,12 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -<<<<<<< HEAD import unittest -======= -from unittest.mock import patch ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) import frappe from frappe.utils.nestedset import ( @@ -240,32 +236,11 @@ class TestItem(unittest.TestCase): "_Test Item Group B - 3", merge=True, ) -<<<<<<< HEAD -======= - - def test_preset_records_use_existing_root(self): - from erpnext.setup.setup_wizard.operations import install_fixtures - - with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT): - records = [ - r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" - ] - - root_record, *child_records = records - self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT) - self.assertTrue(root_record["__condition"]()) - self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT}) - - with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"): - root_record = next( - r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" - ) - self.assertFalse(root_record["__condition"]()) def test_patch_merges_seeded_root_into_existing_root(self): from erpnext.patches.v16_0.merge_seeded_item_group_root import execute - self._nest_root_under(TRANSLATED_ROOT) + self.nest_root_under(TRANSLATED_ROOT) self.assertEqual( frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT ) @@ -273,40 +248,34 @@ class TestItem(unittest.TestCase): execute() self.assertFalse(frappe.db.exists("Item Group", "All Item Groups")) - self.assertEqual( - frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"), - [TRANSLATED_ROOT], - ) + self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT]) self.assertEqual( frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT ) self.test_basic_tree() - def _nest_root_under(self, new_root): + # restore the original root name for the tests that follow + frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups") + self.assertEqual(self.get_root_names(), ["All Item Groups"]) + self.test_basic_tree() + + def nest_root_under(self, new_root): """Recreate the tree left behind by seeding a root under a pre-existing one.""" - frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert() + frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": new_root, + "is_group": 1, + "parent_item_group": "All Item Groups", + } + ).insert() ig = frappe.qb.DocType("Item Group") frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run() frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run() - rebuild_tree("Item Group") + rebuild_tree("Item Group", "parent_item_group") - def _move_it_back(self): - group_b = frappe.get_doc("Item Group", "_Test Item Group B") - group_b.parent_item_group = "All Item Groups" - group_b.save() - self.test_basic_tree() - - def _get_no_of_children(self, item_group): - def get_no_of_children(item_groups, no_of_children): - children = [] - for ig in item_groups: - children += frappe.get_all("Item Group", filters={"parent_item_group": ig}, pluck="name") - - if len(children): - return get_no_of_children(children, no_of_children + len(children)) - else: - return no_of_children - - return get_no_of_children([item_group], 0) ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) + def get_root_names(self): + return frappe.db.sql_list( + """select name from `tabItem Group` where ifnull(parent_item_group, '')=''""" + ) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 8bc1aa515ac..6197873c6fb 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -24,12 +24,8 @@ def read_lines(filename: str) -> list[str]: return (Path(__file__).parent.parent / "data" / filename).read_text().splitlines() -<<<<<<< HEAD def install(country=None): -======= -def get_preset_records(country=None): root_item_group = get_root_of("Item Group") or _("All Item Groups") ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) records = [ # ensure at least an empty Address Template exists for this Country {"doctype": "Address Template", "country": country}, From 68c24f37675c2f9c5d70dcda8b830118a9511f6b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:59:07 +0530 Subject: [PATCH 07/51] feat: status based bar colors in Work Order gantt view (backport #57634) (#57635) feat: status based bar colors in Work Order gantt view (#57634) (cherry picked from commit d59c5e36bcb53be84ec46bd5d29b5c0b2f46f929) Co-authored-by: rohitwaghchaure --- .../doctype/work_order/work_order_calendar.js | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js index 90ce74ce232..9173212f941 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js +++ b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js @@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = { ], get_events_method: "frappe.desk.calendar.get_events", }; + +const WORK_ORDER_GANTT_COLORS = { + Draft: "red", + Stopped: "red", + "Not Started": "red", + "In Process": "orange", + Completed: "green", + "Stock Reserved": "blue", + "Stock Partially Reserved": "orange", + Cancelled: "gray", +}; + +if (!frappe.views.GanttView.prototype._work_order_status_colors) { + frappe.views.GanttView.prototype._work_order_status_colors = true; + + const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks; + frappe.views.GanttView.prototype.prepare_tasks = function () { + prepare_tasks.call(this); + if (this.doctype === "Work Order") { + set_work_order_bar_classes(this); + } + }; + + const set_colors = frappe.views.GanttView.prototype.set_colors; + frappe.views.GanttView.prototype.set_colors = function () { + set_colors.call(this); + if (this.doctype === "Work Order") { + set_work_order_bar_styles(this); + } + }; +} + +function set_work_order_bar_classes(view) { + view.tasks.forEach((task, idx) => { + const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status]; + if (color) { + task.custom_class = "wo-" + color; + } + }); +} + +function set_work_order_bar_styles(view) { + const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))] + .map( + (color) => ` + .gantt .bar-wrapper.wo-${color} .bar { + fill: var(--${color}-300); + } + .gantt .bar-wrapper.wo-${color} .bar-progress { + fill: var(--${color}-300); + } + ` + ) + .join(""); + + view.$result.prepend(``); +} From 310b9d4e65250b9b9f89113ab6a96c5307755dd0 Mon Sep 17 00:00:00 2001 From: nareshkannasln Date: Thu, 30 Jul 2026 17:14:44 +0530 Subject: [PATCH 08/51] fix: validate account frozen date (cherry picked from commit b3c2ba538154077bacf1cc28754cc4646388896c) --- .../period_closing_voucher/period_closing_voucher.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index b416e5b8394..8671213c3cc 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) +from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled from erpnext.accounts.utils import get_account_currency, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController @@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController): self.block_if_future_closing_voucher_exists() self.check_closing_account_type() self.check_closing_account_currency() + self.validate_accounts_not_frozen() + + def validate_accounts_not_frozen(self, for_cancellation=False): + posting_date = self.period_end_date + if for_cancellation and is_immutable_ledger_enabled(): + posting_date = getdate() + + check_freezing_date(posting_date, self.company) def validate_start_and_end_date(self): self.fy_start_date, self.fy_end_date = frappe.db.get_value( @@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController): "Process Period Closing Voucher", ) self.block_if_future_closing_voucher_exists() + self.validate_accounts_not_frozen(for_cancellation=True) if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"): self.cancel_process_pcv_docs() From 972a990b01dd8b57184eb4cead8e3590899afce7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:11:02 +0000 Subject: [PATCH 09/51] fix: do not fetch a random inventory account when multiple inventory accounts exist (backport #57626) (#57631) * fix: do not fetch a random inventory account when multiple inventory accounts exist (#57626) (cherry picked from commit 386a4ac1f09d184a9fc39f91c340cb0dc4539d0e) # Conflicts: # erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py * chore: fix conflicts Removed redundant test for valuation taxes in purchase receipt. * fix: build warehouse account map only when perpetual inventory needs it For asset purchase receipts or provisional accounting with perpetual inventory disabled, GL entries do not use warehouse accounts. Building the full warehouse account map in that case now throws when a company has multiple inventory accounts and no default, breaking asset receipt submission. Mirrors the gating on develop. Co-Authored-By: Claude Fable 5 * test: set default inventory account in valuation taxes LCV test The conflict resolution kept the pre-backport copy of test_valuation_taxes_lcv_repost_after_billing, which enables perpetual inventory on _Test Company without configuring a default inventory account. The test then failed on submit and leaked the perpetual inventory flag, breaking every stock test that ran after it in the same process. Restore the cherry-picked version from #57626. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: rohitwaghchaure Co-authored-by: Claude Fable 5 --- .../sales_invoice/test_sales_invoice.py | 8 +++- erpnext/controllers/stock_controller.py | 7 +++- erpnext/stock/__init__.py | 7 +++- .../test_landed_cost_voucher.py | 8 +++- .../purchase_receipt/test_purchase_receipt.py | 18 +++++---- .../stock/doctype/warehouse/test_warehouse.py | 38 +++++++++++++++++++ 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 0b1f1e922bf..bca0d58a57d 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2802,12 +2802,15 @@ class TestSalesInvoice(FrappeTestCase): old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company 1") frappe.local.enable_perpetual_inventory["_Test Company 1"] = 1 + old_inventory_account = frappe.db.get_value("Company", "_Test Company 1", "default_inventory_account") frappe.db.set_value( "Company", "_Test Company 1", - "stock_received_but_not_billed", - "Stock Received But Not Billed - _TC1", + { + "stock_received_but_not_billed": "Stock Received But Not Billed - _TC1", + "default_inventory_account": "Stock In Hand - _TC1", + }, ) frappe.db.set_value( "Company", @@ -2852,6 +2855,7 @@ class TestSalesInvoice(FrappeTestCase): # tear down frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory + frappe.db.set_value("Company", "_Test Company 1", "default_inventory_account", old_inventory_account) frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock) def test_sle_for_target_warehouse(self): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index b4218b85f0e..269f85ffcbb 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -177,13 +177,18 @@ class StockController(AccountsController): ) is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items")) + need_inventory_map = (self.get_stock_items() or self.get("packed_items")) and cint( + erpnext.is_perpetual_inventory_enabled(self.company) + ) if ( cint(erpnext.is_perpetual_inventory_enabled(self.company)) or provisional_accounting_for_non_stock_items or is_asset_pr ): - warehouse_account = get_warehouse_account_map(self.company) + warehouse_account = frappe._dict() + if need_inventory_map: + warehouse_account = get_warehouse_account_map(self.company) if self.docstatus == 1: if not gl_entries: diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 242bdcf8b55..aa556c62434 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None): account = get_company_default_inventory_account(warehouse.company) if not account and warehouse.company: - account = frappe.db.get_value( - "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name" + inventory_accounts = frappe.get_all( + "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name" ) + if len(inventory_accounts) == 1: + account = inventory_accounts[0] + if not account and warehouse.company and not warehouse.is_group: frappe.throw( _("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format( diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index fd65b7f60e7..3412d818e31 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -194,8 +194,10 @@ class TestLandedCostVoucher(FrappeTestCase): epi = is_perpetual_inventory_enabled(company_a) company_doc = frappe.get_doc("Company", company_a) + old_inventory_account = company_doc.default_inventory_account company_doc.enable_perpetual_inventory = 1 company_doc.stock_received_but_not_billed = srbnb + company_doc.default_inventory_account = "Stock In Hand - _TC" company_doc.save() pr = make_purchase_receipt( @@ -223,7 +225,11 @@ class TestLandedCostVoucher(FrappeTestCase): distribute_landed_cost_on_items(lcv) lcv.submit() - frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi) + frappe.db.set_value( + "Company", + company_a, + {"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account}, + ) frappe.local.enable_perpetual_inventory = {} def test_landed_cost_voucher_for_zero_purchase_rate(self): diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 9d51a20f605..471de86f0a7 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -3263,11 +3263,14 @@ class TestPurchaseReceipt(FrappeTestCase): old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company") frappe.local.enable_perpetual_inventory["_Test Company"] = 1 + old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account") frappe.db.set_value( "Company", "_Test Company", - "stock_received_but_not_billed", - "Stock Received But Not Billed - _TC", + { + "stock_received_but_not_billed": "Stock Received But Not Billed - _TC", + "default_inventory_account": "Stock In Hand - _TC", + }, ) pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1) @@ -3296,13 +3299,14 @@ class TestPurchaseReceipt(FrappeTestCase): gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=False) warehouse_account = get_warehouse_account_map("_Test Company") expected_gle = ( - ("Stock Received But Not Billed - _TC", 0, 10000, "Main - _TC"), - ("Freight and Forwarding Charges - _TC", 0, 2000, "Main - _TC"), - ("Expenses Included In Valuation - _TC", 0, 2000, "Main - _TC"), - (warehouse_account[pr.items[0].warehouse]["account"], 14000, 0, "Main - _TC"), + ("Stock Received But Not Billed - _TC", 0.0, 10000.0, "Main - _TC"), + ("Freight and Forwarding Charges - _TC", 0.0, 2000.0, "Main - _TC"), + ("Expenses Included In Valuation - _TC", 0.0, 2000.0, "Main - _TC"), + (warehouse_account[pr.items[0].warehouse]["account"], 14000.0, 0.0, "Main - _TC"), ) - self.assertSequenceEqual(expected_gle, gl_entries) + self.assertCountEqual(expected_gle, gl_entries) frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory + frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account) def test_manufacturing_and_expiry_date_for_batch(self): item = make_item( diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 02d64cadfe6..5b6f8f727fb 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -103,6 +103,44 @@ class TestWarehouse(FrappeTestCase): children = get_children("Warehouse", parent=company, company=company, is_root=True) self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children)) + def test_inventory_account_fallback_with_multiple_stock_accounts(self): + from erpnext.stock import get_warehouse_account + + company = create_inventory_fallback_company() + frappe.db.set_value("Company", company, "default_inventory_account", None) + if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"): + frappe.delete_doc("Account", "Extra Inventory Account - _TCIF") + + warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0}) + single_account = frappe.db.get_value( + "Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name" + ) + self.assertEqual(get_warehouse_account(warehouse), single_account) + + create_account( + account_name="Extra Inventory Account", + parent_account=frappe.db.get_value("Account", single_account, "parent_account"), + account_type="Stock", + company=company, + ) + self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse) + + +def create_inventory_fallback_company(): + company = "_Test Company Inventory Fallback" + if not frappe.db.exists("Company", company): + frappe.get_doc( + { + "doctype": "Company", + "company_name": company, + "abbr": "_TCIF", + "default_currency": "INR", + "enable_perpetual_inventory": 0, + "country": "India", + } + ).insert(ignore_permissions=True) + return company + def create_warehouse(warehouse_name, properties=None, company=None): if not company: From 478426b436dcc7ea0a083c7edabebd0e4d5b35dc Mon Sep 17 00:00:00 2001 From: Poovetha Date: Wed, 15 Jul 2026 16:39:12 +0530 Subject: [PATCH 10/51] fix(projects): include on hold status in project filters and reports (cherry picked from commit 51a9fc031680d1b1c32af56ec822c0c12ee364ff) --- erpnext/controllers/queries.py | 6 ++++-- erpnext/projects/report/project_summary/project_summary.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 3a5e7168034..88a40eb72b1 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -306,7 +306,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_project_name(doctype, txt, searchfield, start, page_len, filters): +def get_project_name( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None +): proj = qb.DocType("Project") qb_filter_and_conditions = [] qb_filter_or_conditions = [] @@ -321,7 +323,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): if filters.get("company"): qb_filter_and_conditions.append(proj.company == filters.get("company")) - qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"])) + qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"])) q = qb.from_(proj) diff --git a/erpnext/projects/report/project_summary/project_summary.js b/erpnext/projects/report/project_summary/project_summary.js index 072098d5db5..e9ff05857ae 100644 --- a/erpnext/projects/report/project_summary/project_summary.js +++ b/erpnext/projects/report/project_summary/project_summary.js @@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = { fieldname: "status", label: __("Status"), fieldtype: "Select", - options: "\nOpen\nCompleted\nCancelled", + options: "\nOpen\nOn hold\nCompleted\nCancelled", default: "Open", }, { From 82850fb44740107bfaa271cd1e3d348aefa943d0 Mon Sep 17 00:00:00 2001 From: Poovetha Date: Wed, 15 Jul 2026 16:40:59 +0530 Subject: [PATCH 11/51] test(projects): add test to ensure on hold project retains status (cherry picked from commit 79e5ccd37050a67cec2bfffbd38e1cad12a63705) --- .../projects/doctype/project/test_project.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 75e1eba9a16..bf2165c0584 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -282,6 +282,23 @@ class TestProject(FrappeTestCase): project.save() self.assertEqual(project.percent_complete, 100) + def test_on_hold_project_keeps_status(self): + project, tasks = self._project_with_tasks("Task Completion", 4) + + # an On hold project is not auto-flipped to Completed even at 100% + project.status = "On hold" + for task in tasks: + frappe.db.set_value("Task", task, "status", "Completed") + project.update_percent_complete() + self.assertEqual(project.percent_complete, 100) + self.assertEqual(project.status, "On hold") + + # nor auto-flipped back to Open when below 100% + frappe.db.set_value("Task", tasks[0], "status", "Open") + project.update_percent_complete() + self.assertEqual(project.percent_complete, 75) + self.assertEqual(project.status, "On hold") + def _create_portal_user(self, email): """A user with no Project-related role, so read access can only come from control_access_for_project_users() sharing the doc with them.""" From 54390bdeb930d7a79a30ef76ce26db794fca04fe Mon Sep 17 00:00:00 2001 From: Poovetha Date: Wed, 15 Jul 2026 16:42:22 +0530 Subject: [PATCH 12/51] fix(projects): add project filter (cherry picked from commit 724896156841533519f9f0e8d89f72c2964b57e7) --- erpnext/projects/doctype/task/task.js | 6 ++++++ erpnext/projects/doctype/timesheet/timesheet.js | 2 ++ 2 files changed, 8 insertions(+) diff --git a/erpnext/projects/doctype/task/task.js b/erpnext/projects/doctype/task/task.js index c56c998a518..2f284296953 100644 --- a/erpnext/projects/doctype/task/task.js +++ b/erpnext/projects/doctype/task/task.js @@ -15,6 +15,12 @@ frappe.ui.form.on("Task", { }, onload: function (frm) { + frm.set_query("project", function () { + return { + query: "erpnext.controllers.queries.get_project_name", + }; + }); + frm.set_query("task", "depends_on", function () { let filters = { name: ["!=", frm.doc.name], diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index e9d868e108a..ca4c808011d 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", { return { filters: { company: frm.doc.company, + status: "Open", }, }; }; @@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", { return { filters: { customer: doc.customer, + status: "Open", }, }; }); From ebf5a462b3563fab9a465e72310d261d832aaa9d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:05:44 +0530 Subject: [PATCH 13/51] fix: filter Accounts Receivable by invoice sales partner (backport #57628) (#57646) fix: filter Accounts Receivable by invoice sales partner (#57628) Filter Accounts Receivable and AR Summary on the Sales Invoice's own sales_partner instead of the customer's default_sales_partner, and read the Sales Partner column from the invoice. Returns are attributed to the invoice they settle, matching how the Sales Person filter works. (cherry picked from commit fd7765ac02c22133a95b60a6d9f997f5df0cf5a2) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- .../accounts_receivable.py | 39 +++++++----- .../test_accounts_receivable.py | 59 +++++++++++++++++++ .../accounts_receivable_summary.py | 6 +- .../test_accounts_receivable_summary.py | 39 ++++++++++++ 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index db74275238e..5405bafab07 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -106,6 +106,7 @@ class ReceivablePayableReport: def get_data(self): self.get_sales_invoices_or_customers_based_on_sales_person() + self.get_invoices_based_on_sales_partner() # Get invoice details like bill_no, due_date etc for all invoices self.get_invoice_details() @@ -241,6 +242,12 @@ class ReceivablePayableReport: ): return + if self.filters.get("sales_partner"): + # a return is folded onto the invoice it settles, so match that invoice's + # partner (like the sales_person filter above), not the return's own + if ple.against_voucher_no not in self.sales_partner_invoices: + return + if self.filters.get("ignore_accounts"): key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) else: @@ -469,7 +476,7 @@ class ReceivablePayableReport: "company": self.filters.company, "docstatus": 1, }, - fields=["name", "due_date", "po_no"], + fields=["name", "due_date", "po_no", "sales_partner"], ) for d in si_list: self.invoice_details.setdefault(d.name, d) @@ -903,6 +910,22 @@ class ReceivablePayableReport: for d in records: self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent) + def get_invoices_based_on_sales_partner(self): + if not self.filters.get("sales_partner"): + return + + self.sales_partner_invoices = set( + frappe.get_all( + "Sales Invoice", + filters={ + "sales_partner": self.filters.get("sales_partner"), + "docstatus": 1, + "company": self.filters.company, + }, + pluck="name", + ) + ) + def prepare_conditions(self): self.qb_selection_filter = [] self.or_filters = [] @@ -1005,15 +1028,6 @@ class ReceivablePayableReport: self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt])) - if self.filters.get("sales_partner"): - self.qb_selection_filter.append( - self.ple.party.isin( - qb.from_(self.customer) - .select(self.customer.name) - .where(self.customer.default_sales_partner == self.filters.get("sales_partner")) - ) - ) - def exclude_employee_transaction(self): self.qb_selection_filter.append(self.ple.party_type != "Employee") @@ -1113,9 +1127,6 @@ class ReceivablePayableReport: if self.account_type == "Receivable": fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"] - if self.filters.get("sales_partner"): - fields.append("default_sales_partner") - self.party_details[party] = frappe.db.get_value( "Customer", party, @@ -1242,7 +1253,7 @@ class ReceivablePayableReport: self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") if self.filters.sales_partner: - self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data") if self.filters.account_type == "Payable": self.add_column( diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 4a73d62ee2e..7354b48e4a2 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order @@ -1303,3 +1304,61 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): self.assertIn(original_customer, parties) self.assertNotIn(second_customer, parties) self.assertEqual(allowed_invoice.customer, original_customer) + + def test_receivable_filtered_by_sales_partner(self): + frappe.set_user("Administrator") + partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B" + for partner in (partner_a, partner_b): + if not frappe.db.exists("Sales Partner", partner): + frappe.get_doc( + { + "doctype": "Sales Partner", + "partner_name": partner, + "commission_rate": 0, + "territory": "All Territories", + } + ).insert() + + def _si(sales_partner): + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2) + si.sales_partner = sales_partner + return si.save().submit() + + partner_a_si = _si(partner_a) + partner_b_si = _si(partner_b) + no_partner_si = _si(None) + + # a return is folded onto the invoice it settles, so it nets against that + # invoice's partner even when the return's own partner is cleared + no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name) + no_partner_return.sales_partner = None + no_partner_return.items[0].qty = -1 + no_partner_return.update_outstanding_for_self = 0 + no_partner_return.save().submit() + + filters = { + "company": self.company, + "party_type": "Customer", + "report_date": today(), + "range": "30, 60, 90, 120", + } + + def rows_for(partner): + return { + r.voucher_no: r + for r in execute({**filters, "sales_partner": partner})[1] + if r.get("voucher_no") + } + + rows_a = rows_for(partner_a) + self.assertIn(partner_a_si.name, rows_a) + self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a) + self.assertNotIn(partner_b_si.name, rows_a) + self.assertNotIn(no_partner_si.name, rows_a) + self.assertNotIn(no_partner_return.name, rows_a) + self.assertEqual(rows_a[partner_a_si.name].credit_note, 100) + self.assertEqual(rows_a[partner_a_si.name].outstanding, 100) + + rows_b = rows_for(partner_b) + self.assertIn(partner_b_si.name, rows_b) + self.assertNotIn(partner_a_si.name, rows_b) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 19d2faddf44..7ebbd26c69a 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport): if row.sales_person: self.party_total[row.party].sales_person.append(row.get("sales_person", "")) - if self.filters.sales_partner: - self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "") + if self.filters.sales_partner and row.get("sales_partner"): + self.party_total[row.party]["sales_partner"] = row.get("sales_partner") def get_columns(self): self.columns = [] @@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") if self.filters.sales_partner: - self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data") else: self.add_column( diff --git a/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py index a98cc6af7a3..02dbe214ecb 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py @@ -193,3 +193,42 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): report = execute(filters) rpt_output = report[1] self.assertEqual(len(rpt_output), 0) + + def test_03_summary_sales_partner_column(self): + partner = "_Test AR Summary Sales Partner" + if not frappe.db.exists("Sales Partner", partner): + frappe.get_doc( + { + "doctype": "Sales Partner", + "partner_name": partner, + "commission_rate": 0, + "territory": "All Territories", + } + ).insert() + + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=200, + price_list_rate=200, + do_not_submit=True, + ) + si.sales_partner = partner + si.save().submit() + + filters = { + "company": self.company, + "customer": self.customer, + "posting_date": today(), + "range": "30, 60, 90, 120", + "sales_partner": partner, + } + + rpt_output = execute(filters)[1] + self.assertEqual(len(rpt_output), 1) + self.assertEqual(rpt_output[0].get("sales_partner"), partner) From bb36a4fd0858a96a4442bc7b3f6ebb6d4566cac9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:38:58 +0530 Subject: [PATCH 14/51] feat: auto-fill subscription accounting dimensions from plan with item fallback (backport #57615) (#57621) * feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615) When a plan is selected in the Subscription's Plans table, the Subscription's accounting dimensions (cost center and any custom dimensions) auto-fill from the plan, falling back to the plan item's company default (selling cost center for a Customer, buying for a Supplier). Only empty fields are filled. Stale async responses are ignored so a quick re-pick of the plan can't be overwritten. (cherry picked from commit 7febc28ed6cb4cd15ecd172a9fc8ff77ecda18cb) # Conflicts: # erpnext/accounts/doctype/subscription/subscription.js # erpnext/accounts/doctype/subscription/subscription.py # erpnext/accounts/doctype/subscription/test_subscription.py * fix: resolve backport merge conflicts for #57615 --------- Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Co-authored-by: Jatin3128 --- .../doctype/subscription/subscription.js | 26 +++++++++++ .../doctype/subscription/subscription.py | 34 ++++++++++++++ .../doctype/subscription/test_subscription.py | 44 ++++++++++++++++++- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index 629d118080a..71d3929e9cc 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", { }); }, }); + +frappe.ui.form.on("Subscription Plan Detail", { + plan: function (frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (!row.plan) return; + const requested_plan = row.plan; + + frappe.call({ + method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions", + args: { + plan: requested_plan, + company: frm.doc.company, + party_type: frm.doc.party_type, + }, + callback: function (r) { + if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return; + // Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten. + for (const [dimension, value] of Object.entries(r.message)) { + if (frm.fields_dict[dimension] && !frm.doc[dimension]) { + frm.set_value(dimension, value); + } + } + }, + }); + }, +}); diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 623073c7e45..3665bf34bf2 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( ) from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate from erpnext.accounts.party import get_party_account_currency +from erpnext.stock.doctype.item.item import get_item_defaults class InvoiceCancelled(frappe.ValidationError): @@ -747,6 +748,39 @@ def get_prorata_factor( return diff / plan_days +@frappe.whitelist() +def get_plan_dimensions( + plan: str, company: str | None = None, party_type: str | None = None +) -> dict[str, str]: + """Resolve a plan's accounting dimensions, falling back to the plan item's company defaults.""" + plan_doc = frappe.get_cached_doc("Subscription Plan", plan) + + dimensions = {} + for dimension in ["cost_center", *get_accounting_dimensions()]: + value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type) + if value: + dimensions[dimension] = value + + return dimensions + + +def get_item_dimension( + item_code: str, dimension: str, company: str | None, party_type: str | None +) -> str | None: + if not company: + return None + + item_defaults = get_item_defaults(item_code, company) + if dimension != "cost_center": + return item_defaults.get(dimension) + + selling = item_defaults.get("selling_cost_center") + buying = item_defaults.get("buying_cost_center") + if party_type == "Supplier": + return buying or selling + return selling or buying + + def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None: """ Task to updates the status of all `Subscription` apart from those that are cancelled diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index aba51ac5c6a..41ada4c804f 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -17,7 +17,7 @@ from frappe.utils.data import ( nowdate, ) -from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor +from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor test_dependencies = ("UOM", "Item Group", "Item") @@ -583,6 +583,48 @@ class TestSubscription(FrappeTestCase): subscription.process(nowdate()) self.assertEqual(len(subscription.invoices), 1) + def test_plan_dimensions_resolve_from_plan_then_item(self): + from erpnext.stock.doctype.item.test_item import make_item + + # Plan-level cost center takes precedence. + create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR") + frappe.db.set_value( + "Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC" + ) + self.assertEqual( + get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"), + "_Test Cost Center - _TC", + ) + + # No plan cost center: fall back to the item's company default (selling vs buying by party type). + item = make_item( + "_Test Sub Dimension Item", + { + "is_stock_item": 0, + "item_defaults": [ + { + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "selling_cost_center": "_Test Cost Center - _TC", + "buying_cost_center": "_Test Cost Center 2 - _TC", + } + ], + }, + ) + create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name) + + self.assertEqual( + get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"), + "_Test Cost Center - _TC", + ) + self.assertEqual( + get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"), + "_Test Cost Center 2 - _TC", + ) + + # Without a company the item fallback is skipped. + self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC")) + def make_plans(): create_plan(plan_name="_Test Plan Name", cost=900, currency="INR") From b2918b8bb32171b9f6b039bc3cbfd3b5a21655aa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:51:43 +0530 Subject: [PATCH 15/51] feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57402) feat: make Shipping Rule Cost Center optional with company default fallback (#57355) Cost Center on Shipping Rule is no longer mandatory. When left blank, the applied shipping tax row falls back to the company default cost center, avoiding the 'Cost Center is required for Profit and Loss account' error on submit. The rule's project is also applied to the tax row. (cherry picked from commit a47f25896b095aea90308801a8fa91f77fc0c759) Co-authored-by: Jatin3128 --- .../doctype/shipping_rule/shipping_rule.json | 22 +++++++++++-------- .../doctype/shipping_rule/shipping_rule.py | 7 +++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json index 8277c92d829..8ae850f78e3 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json @@ -80,8 +80,7 @@ "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", - "options": "Cost Center", - "reqd": 1 + "options": "Cost Center" }, { "fieldname": "shipping_amount_section", @@ -139,18 +138,20 @@ "fieldtype": "Column Break" }, { - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } ], "icon": "fa fa-truck", "idx": 1, - "modified": "2019-05-25 23:12:26.156405", + "links": [], + "modified": "2026-07-22 14:53:27.315435", "modified_by": "Administrator", "module": "Accounts", "name": "Shipping Rule", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -196,5 +197,8 @@ "write": 1 } ], - "sort_order": "ASC" -} \ No newline at end of file + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "ASC", + "states": [] +} diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index a2db95d03c9..e636367bc68 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -36,18 +36,17 @@ class ShippingRule(Document): from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import ( ShippingRuleCondition, ) - from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ( - ShippingRuleCountry, - ) + from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry account: DF.Link calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"] company: DF.Link conditions: DF.Table[ShippingRuleCondition] - cost_center: DF.Link + cost_center: DF.Link | None countries: DF.Table[ShippingRuleCountry] disabled: DF.Check label: DF.Data + project: DF.Link | None shipping_amount: DF.Currency shipping_rule_type: DF.Literal["Selling", "Buying"] # end: auto-generated types From b826b7c3e6ad627d439e74a5ebba6ed36f66f970 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:35:48 +0000 Subject: [PATCH 16/51] fix: use payment entry posting date for received amount exchange rate (backport #57660) (#57662) Co-authored-by: Diptanil Saha --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 09f7d5c7d51..ad692f1557d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3316,13 +3316,11 @@ def set_paid_amount_and_received_amount( company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency") if bank and company_currency != bank.account_currency: # doc currency can be different from bank currency - posting_date = doc.get("posting_date") or doc.get("transaction_date") - conversion_rate = get_exchange_rate( - bank.account_currency, party_account_currency, posting_date - ) + conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency) received_amount = paid_amount / conversion_rate else: - received_amount = paid_amount * doc.get("conversion_rate", 1) + conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency) + received_amount = paid_amount * conversion_rate # if payment type is pay, then paid amount and received amount are swapped if payment_type == "Pay": From 72f293f131cc7b8a50b1d4bbd07fe4360ca38fbb Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 31 Jul 2026 13:54:53 +0530 Subject: [PATCH 17/51] fix: guard against None row in get_stock_balance_for (backport #57567) get_stock_balance_for() takes row=None by default, but the batch-tracked branch dereferenced it unconditionally while the two neighbouring row accesses already guard. Calling it with a batch_no and no row raised AttributeError: 'NoneType' object has no attribute 'use_serial_batch_fields'. semgrep's missing-argument-type-hint rule matches the whole function body, so touching any line inside it re-fingerprints the pre-existing untyped arguments and reports them as introduced by this PR. Silenced with nosemgrep instead of annotating: on a whitelisted method the hints are enforced at runtime by pydantic, which is not a risk worth taking on v15. --- .../stock/doctype/stock_reconciliation/stock_reconciliation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index ab1358e8293..9f84909b432 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1292,6 +1292,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d return flt(result[0][0]) if result and result[0][0] else 0.0 +# nosemgrep: missing-argument-type-hint @frappe.whitelist() def get_stock_balance_for( item_code: str, @@ -1364,7 +1365,7 @@ def get_stock_balance_for( or 0 ) - if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty): + if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty): rate = get_incoming_rate( frappe._dict( { From 42d53783bbb4e769e105ceef5aa8607d37ad75aa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:26:15 +0000 Subject: [PATCH 18/51] fix(plant_floor): add missing perm check on `get_stock_summary` (backport #57667) (#57669) Co-authored-by: Diptanil Saha --- erpnext/manufacturing/doctype/plant_floor/plant_floor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/manufacturing/doctype/plant_floor/plant_floor.py b/erpnext/manufacturing/doctype/plant_floor/plant_floor.py index e6fcf1af9cc..71482a28aae 100644 --- a/erpnext/manufacturing/doctype/plant_floor/plant_floor.py +++ b/erpnext/manufacturing/doctype/plant_floor/plant_floor.py @@ -67,6 +67,14 @@ class PlantFloor(Document): @frappe.whitelist() def get_stock_summary(warehouse, start=0, item_code=None, item_group=None): + frappe.has_permission("Warehouse", doc=warehouse, throw=True) + + if item_code: + frappe.has_permission("Item", doc=item_code, throw=True) + + if item_group: + frappe.has_permission("Item Group", doc=item_group, throw=True) + stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group) max_count = 0.0 From cf42c5253052e19ba1a2cc11ba2a18c6567ab2e1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:56:17 +0530 Subject: [PATCH 19/51] fix(quotation): carry forward communications from opportunity at after_insert (backport #57639) (#57642) Co-authored-by: Diptanil Saha --- erpnext/selling/doctype/quotation/quotation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index c25ab500dc7..8366f61c6b3 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -147,6 +147,9 @@ class Quotation(SellingController): make_packing_list(self) + def after_insert(self): + self.carry_forward_communication() + def before_submit(self): self.set_has_alternative_item() @@ -292,7 +295,6 @@ class Quotation(SellingController): # update enquiry status self.update_opportunity("Quotation") self.update_lead() - self.carry_forward_communication() def on_cancel(self): if self.lost_reasons: From a5ed3a59450aae98bf3d9a59a8c7a8eddbb58f6b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 1 Aug 2026 14:37:12 +0530 Subject: [PATCH 20/51] fix: respect quantity precision in material transfer validation (cherry picked from commit eb969a586661834ef118dd97ff21ccc9d62828b9) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f93c41bacc5..c50671d9d3f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1222,9 +1222,11 @@ class StockEntry(StockController): first_row_by_item.setdefault(key, item) for key, transfer_qty in transfer_by_item.items(): - pending_qty = max(0.0, pending_by_item[key]) + item = first_row_by_item[key] + precision = item.precision("qty") + transfer_qty = flt(transfer_qty, precision) + pending_qty = max(0.0, flt(pending_by_item[key], precision)) if transfer_qty > pending_qty: - item = first_row_by_item[key] frappe.throw( _( "Row #{0}: Cannot transfer {1} {2} of Item {3}. " From f9381cc8f93e702831ab495199687780776837d1 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 1 Aug 2026 14:37:15 +0530 Subject: [PATCH 21/51] test: cover material transfer quantity precision (cherry picked from commit 59bb56aa8d77cb1747fffd1225dfbb799f2b683f) --- .../doctype/stock_entry/test_stock_entry.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index bcaa90e104f..f9e1f415789 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -934,6 +934,38 @@ class TestStockEntry(FrappeTestCase): fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item 2", stock_entry.get("items"))).amount self.assertEqual(fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2)) + @change_settings("System Settings", {"float_precision": 3}) + @change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"}) + def test_material_transfer_for_manufacture_qty_precision(self): + work_order = frappe.new_doc("Work Order") + work_order.append( + "required_items", + { + "item_code": "_Test Item", + "required_qty": 33.876, + "transferred_qty": 33.875, + }, + ) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.work_order = "Test Work Order" + stock_entry.append( + "items", + { + "item_code": "_Test Item", + "s_warehouse": "_Test Warehouse - _TC", + "qty": 0.001, + "uom": "Nos", + }, + ) + + stock_entry.pro_doc = work_order + stock_entry._validate_no_excess_transfer() + + stock_entry.items[0].qty = 0.002 + with self.assertRaises(frappe.ValidationError): + stock_entry._validate_no_excess_transfer() + @change_settings("Manufacturing Settings", {"material_consumption": 1}) def test_work_order_manufacture_with_material_consumption(self): from erpnext.manufacturing.doctype.work_order.work_order import ( From 338fff20db3499307f114b836ab8c3337a18da5a Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 16 Jul 2026 00:47:56 +0530 Subject: [PATCH 22/51] fix(assets): add permission checks on whitelisted methods on `asset_capitalization` (cherry picked from commit 09d721d1be80eca48edbc6ed52676febc329cc90) --- .../doctype/asset_capitalization/asset_capitalization.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index d234b162ba2..15128607e5b 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -734,6 +734,7 @@ def get_target_asset_details(asset=None, company=None): @frappe.whitelist() def get_consumed_stock_item_details(args): + frappe.has_permission("Stock Ledger Entry", throw=True) if isinstance(args, str): args = json.loads(args) @@ -743,6 +744,7 @@ def get_consumed_stock_item_details(args): item = frappe._dict() if args.item_code: item = frappe.get_cached_doc("Item", args.item_code) + item.check_permission() out.item_name = item.item_name out.batch_no = None @@ -752,6 +754,8 @@ def get_consumed_stock_item_details(args): out.stock_uom = item.stock_uom out.warehouse = get_item_warehouse(item, args, overwrite_warehouse=True) if item else None + if out.warehouse: + frappe.has_permission("Warehouse", doc=out.warehouse, throw=True) # Cost Center item_defaults = get_item_defaults(item.name, args.company) @@ -792,6 +796,9 @@ def get_warehouse_details(args): out = {} if args.warehouse and args.item_code: + frappe.has_permission("Item", doc=args.item_code, throw=True) + frappe.has_permission("Warehouse", doc=args.warehouse, throw=True) + frappe.has_permission("Stock Ledger Entry", throw=True) out = { "actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0, "valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False), From 9cd5997500a3342dcbe0202b16394451c3f45b96 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 15 Jul 2026 23:23:48 +0530 Subject: [PATCH 23/51] fix(item_variant): added permission checks on `enqueue_multiple_variant_creation` (cherry picked from commit 3b0cbc972eae6b1c062b42c03406406b3de687cd) --- erpnext/controllers/item_variant.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index a05ff7f3b7c..c801290049a 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -336,6 +336,7 @@ def create_variant(item, args, use_template_image=False): @frappe.whitelist() def enqueue_multiple_variant_creation(item, args, use_template_image=False): + frappe.has_permission("Item", ptype="create", throw=True) use_template_image = frappe.parse_json(use_template_image) # There can be innumerable attribute combinations, enqueue if isinstance(args, str): From c38c9d5d9b864dafedf84877c9fa0ec8381cd931 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 16 Jul 2026 01:10:50 +0530 Subject: [PATCH 24/51] fix(payment_request): added permission checks on `resend_payment_email` (cherry picked from commit 0659bd704968543d95af0ba225c5e3aa4a29d987) --- .../payment_request/payment_request.js | 22 +++++++------- .../payment_request/payment_request.py | 29 +++++++++++++++---- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index 5cca11ae2fd..93682430fcd 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -33,6 +33,8 @@ frappe.ui.form.on("Payment Request", "onload", function (frm, dt, dn) { }); frappe.ui.form.on("Payment Request", "refresh", function (frm) { + let sending_email = false; + if ( frm.doc.payment_request_type == "Inward" && frm.doc.payment_channel !== "Phone" && @@ -41,16 +43,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) { frm.doc.docstatus == 1 ) { frm.add_custom_button(__("Resend Payment Email"), function () { - frappe.call({ - method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email", - args: { docname: frm.doc.name }, - freeze: true, - freeze_message: __("Sending"), - callback: function (r) { - if (!r.exc) { - frappe.msgprint(__("Message Sent")); - } - }, + if (sending_email) { + frappe.show_alert({ message: __("Sending Email"), indicator: "blue" }); + return; + } + sending_email = true; + frappe.show_alert({ message: __("Sending Email"), indicator: "blue" }); + frm.call("resend_payment_email").then((r) => { + const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent."); + frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" }); + sending_email = false; }); }); } diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 01de1e34e21..f5dc2fb479e 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -411,6 +411,18 @@ class PaymentRequest(Document): return payment_entry + @frappe.whitelist(methods=["POST"]) + def resend_payment_email(self): + if not ( + self.docstatus == 1 + and self.payment_request_type == "Inward" + and self.payment_channel != "Phone" + and self.status not in ["Initiated", "Paid"] + ): + frappe.throw(_("Payment Link couldn't be sent.")) + + self.send_email() + def send_email(self): """send email with payment link""" email_args = { @@ -428,7 +440,17 @@ class PaymentRequest(Document): ) ], } - enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args) + job_id = f"send_payment_email::{self.name}" + enqueue( + method=frappe.sendmail, + queue="short", + timeout=300, + is_async=True, + job_id=job_id, + deduplicate=True, + enqueue_after_commit=True, + **email_args, + ) def get_message(self): """return message with payment gateway link""" @@ -827,11 +849,6 @@ def get_print_format_list(ref_doctype): return {"print_format": print_format_list} -@frappe.whitelist() -def resend_payment_email(docname): - return frappe.get_doc("Payment Request", docname).send_email() - - @frappe.whitelist() def make_payment_entry(docname): doc = frappe.get_doc("Payment Request", docname) From 4373e295de8d600a3aef1fc9ed8df831d073f0b6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 21:53:59 +0530 Subject: [PATCH 25/51] fix: preserve UOM conversion factor precision in transactions calculate_item_values rounds every Float field on an item row to the site's Float Precision (3 by default), and conversion_factor was one of them. The factor is a ratio, not a rate: UOM Conversion Factor.value is stored at precision 9, and Material Request keeps the full value because it has no currency field and so never runs the calculation. Mapping a Material Request to a Purchase Order therefore truncated the factor - 0.453592292 for Pound -> Kg became 0.454 - and stock_qty, which is recomputed as qty * conversion_factor, drifted from the quantity that was requested, leaving the Material Request unable to close. Exclude conversion_factor from the rounded fields on the server and on the client. Factors below the site precision would otherwise round to zero outright. (cherry picked from commit 269cc6ee3bcbfed10e487561f829845f40cf2c4e) # Conflicts: # erpnext/controllers/taxes_and_totals.py --- erpnext/controllers/buying_controller.py | 2 +- erpnext/controllers/taxes_and_totals.py | 12 ++++++++++++ .../public/js/controllers/taxes_and_totals.js | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index dea76428d90..5b8df2cf767 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -358,7 +358,7 @@ class BuyingController(SubcontractingController): ) valuation_amount_adjustment -= item.item_tax_amount - self.round_floats_in(item) + self.round_floats_in(item, do_not_round_fields=["conversion_factor"]) if flt(item.conversion_factor) == 0.0: item.conversion_factor = ( get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index e218e9a44cb..bbfc9eba9cb 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -184,8 +184,20 @@ class calculate_taxes_and_totals: if self.doc.get("is_consolidated"): return +<<<<<<< HEAD if not self.discount_amount_applied: do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"] +======= + do_not_round_fields = [ + "valuation_rate", + "incoming_rate", + "sales_incoming_rate", + "conversion_factor", + ] + for item in self.doc.items: + self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) + self.calculate_item_rate(item) +>>>>>>> 269cc6ee3b (fix: preserve UOM conversion factor precision in transactions) for item in self.doc.items: self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 1f091f3934d..4d980d7e277 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -126,11 +126,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } + get_item_fields_to_round() { + const [item] = this.frm.doc.items || []; + if (!item) { + return []; + } + + const do_not_round_fields = ["conversion_factor"]; + return frappe.meta + .get_fieldnames(item.doctype, item.parent, { + fieldtype: ["in", ["Currency", "Float"]], + }) + .filter((fieldname) => !do_not_round_fields.includes(fieldname)); + } + calculate_item_values() { var me = this; if (!this.discount_amount_applied) { + const fields_to_round = this.get_item_fields_to_round(); for (const item of this.frm.doc.items || []) { - frappe.model.round_floats_in(item); + frappe.model.round_floats_in(item, fields_to_round); item.net_rate = item.rate; item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty; From 5a5e20e167a93bff3ae5e78f430cbb410a1bdf40 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 21:54:05 +0530 Subject: [PATCH 26/51] test: fractional conversion factor survives Material Request to Purchase Order Fails before the fix with 0.45 != 0.453592292 on a site with Float Precision 2, and 0.454 on the default of 3. (cherry picked from commit f4d70c2d60f7f2d8a6ff3bc98f436366c94de2d1) --- .../material_request/test_material_request.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index ac11b2fb7d9..6ae289625bc 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -795,6 +795,28 @@ class TestMaterialRequest(FrappeTestCase): mr = frappe.get_doc("Material Request", mr.name) self.assertEqual(mr.per_ordered, 100) + def test_fractional_conversion_factor_for_purchase(self): + item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1) + conversion_factor = 0.453592292 + + mr = make_material_request( + item_code=item.name, + qty=1000, + uom="Pound", + conversion_factor=conversion_factor, + ) + mr.reload() + + self.assertEqual(mr.items[0].conversion_factor, conversion_factor) + + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.insert() + po.reload() + + self.assertEqual(po.items[0].conversion_factor, conversion_factor) + self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty) + def test_customer_provided_parts_mr(self): create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0) existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC") From 00df8652e341bd7755da312546c6f7fd711c23ef Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:03:47 +0530 Subject: [PATCH 27/51] fix: resolve version 15 backport conflict --- erpnext/controllers/taxes_and_totals.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index bbfc9eba9cb..a8a48140bdd 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -184,20 +184,13 @@ class calculate_taxes_and_totals: if self.doc.get("is_consolidated"): return -<<<<<<< HEAD if not self.discount_amount_applied: - do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"] -======= - do_not_round_fields = [ - "valuation_rate", - "incoming_rate", - "sales_incoming_rate", - "conversion_factor", - ] - for item in self.doc.items: - self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) - self.calculate_item_rate(item) ->>>>>>> 269cc6ee3b (fix: preserve UOM conversion factor precision in transactions) + do_not_round_fields = [ + "valuation_rate", + "incoming_rate", + "sales_incoming_rate", + "conversion_factor", + ] for item in self.doc.items: self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) From a6dff3fc4779330e2d117e54a51fd57a2d22de65 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:01:35 +0530 Subject: [PATCH 28/51] fix: prevent duplicate shipping charges without cost center (cherry picked from commit a4134af30b0a0b9625bd684ca4e45a9bf9497197) # Conflicts: # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../doctype/shipping_rule/shipping_rule.py | 11 +++++- .../doctype/sales_order/test_sales_order.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index e636367bc68..c1ec8b26298 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -161,7 +161,16 @@ class ShippingRule(Document): ) shipping_charge["add_deduct_tax"] = "Add" - existing_shipping_charge = doc.get("taxes", filters=shipping_charge) + shipping_charge_filters = shipping_charge.copy() + if not self.cost_center: + # Blank Link values can be None on the server or an empty string from the client. + # Child-table defaults can also resolve a blank value to the company default. + shipping_charge_filters["cost_center"] = ( + "in", + (None, "", erpnext.get_default_cost_center(doc.company)), + ) + + existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters) if existing_shipping_charge: # take the last record found existing_shipping_charge[-1].tax_amount = shipping_amount diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 6f8befeb876..2ac6d005547 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1984,10 +1984,48 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): sales_order.save() self.assertEqual(sales_order.taxes[0].tax_amount, 0) +<<<<<<< HEAD @change_settings( "Accounts Settings", {"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1}, ) +======= + def test_sales_order_with_shipping_rule_without_cost_center(self): + from erpnext import get_default_cost_center + + shipping_rule = frappe.get_doc( + { + "doctype": "Shipping Rule", + "label": "Shipping Rule Without Cost Center - Sales Order Test", + "shipping_rule_type": "Selling", + "company": "_Test Company", + "account": "_Test Account Shipping Charges - _TC", + "calculate_based_on": "Fixed", + "shipping_amount": 50, + } + ).insert() + sales_order = make_sales_order(do_not_save=True) + sales_order.shipping_rule = shipping_rule.name + company_cost_center = get_default_cost_center(sales_order.company) + + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertIsNone(sales_order.taxes[0].cost_center) + + for cost_center in (None, "", company_cost_center): + sales_order.taxes[0].cost_center = cost_center + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertEqual(sales_order.taxes[0].cost_center, cost_center) + + sales_order.taxes[0].cost_center = "" + sales_order.save() + sales_order.reload() + shipping_rule.apply(sales_order) + self.assertEqual(len(sales_order.taxes), 1) + self.assertEqual(sales_order.taxes[0].cost_center, "") + +>>>>>>> a4134af30b (fix: prevent duplicate shipping charges without cost center) def test_sales_order_partial_advance_payment(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_entry, From 42a2674341b4fd57eb61586524d853f11d11a106 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:03:14 +0530 Subject: [PATCH 29/51] chore: remove shipping rule comments (cherry picked from commit 106ecd71202e67400698eab01915af1e20434bc3) --- erpnext/accounts/doctype/shipping_rule/shipping_rule.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index c1ec8b26298..68da0eb519f 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -163,8 +163,6 @@ class ShippingRule(Document): shipping_charge_filters = shipping_charge.copy() if not self.cost_center: - # Blank Link values can be None on the server or an empty string from the client. - # Child-table defaults can also resolve a blank value to the company default. shipping_charge_filters["cost_center"] = ( "in", (None, "", erpnext.get_default_cost_center(doc.company)), From 70da05edb747ba31a1d36fb17b5c38ed504f915f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:26:50 +0530 Subject: [PATCH 30/51] fix: resolve version-15 backport conflict --- .../selling/doctype/sales_order/test_sales_order.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 2ac6d005547..7a796d090ff 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1984,12 +1984,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): sales_order.save() self.assertEqual(sales_order.taxes[0].tax_amount, 0) -<<<<<<< HEAD - @change_settings( - "Accounts Settings", - {"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1}, - ) -======= def test_sales_order_with_shipping_rule_without_cost_center(self): from erpnext import get_default_cost_center @@ -2025,7 +2019,10 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(sales_order.taxes), 1) self.assertEqual(sales_order.taxes[0].cost_center, "") ->>>>>>> a4134af30b (fix: prevent duplicate shipping charges without cost center) + @change_settings( + "Accounts Settings", + {"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1}, + ) def test_sales_order_partial_advance_payment(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_entry, From 8676add8757a6c71743acacc8ec9680244e8b0cd Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:01 +0530 Subject: [PATCH 31/51] fix(purchase): reject purchase returns where every item has zero quantity validate_returned_items() set items_returned=True whenever a row matched a valid item from the original document, even if its qty was 0. This let a Purchase Invoice, Purchase Receipt, or Subcontracting Receipt return be submitted with every line at qty=0 - a no-op document with no stock or financial effect that still consumed a document number and linked back to the original transaction. Scoped to the Purchase side only: items_returned now flips to True for Purchase Invoice/Purchase Receipt/Subcontracting Receipt only when qty (or received_qty) is actually negative, so an all-zero purchase return correctly hits the existing "At least one item should be entered with negative quantity" check. Sales Invoice, Delivery Note, and POS Invoice are unchanged. Also applies a corresponding check to the item_name-only fallback branch (for rows without an item_code - Item Code is not mandatory on Purchase Invoice Item), which previously bypassed this fix entirely and still set items_returned=True unconditionally regardless of quantity. For that branch specifically, only qty is checked (not received_qty): with no linked Item there's no accepted/rejected split, so received_qty carries no independent meaning and a qty=0 row must be rejected regardless of its value. (cherry picked from commit b63066ed4497a68bb6ca8ced6124caf4247e0f7c) --- erpnext/controllers/sales_and_purchase_return.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index c58580739e3..0def40d024e 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -158,10 +158,21 @@ def validate_returned_items(doc): ): frappe.throw(_("Warehouse is mandatory")) - items_returned = True + if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"): + if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0: + items_returned = True + else: + items_returned = True elif d.item_name: - items_returned = True + if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"): + # No item_code here means no linked Item, so there's no accepted/rejected + # split to speak of - received_qty isn't a meaningful independent signal. + # Only a negative qty (i.e. a real negative billing amount) counts. + if flt(d.qty) < 0: + items_returned = True + else: + items_returned = True if not items_returned: frappe.throw(_("Atleast one item should be entered with negative quantity in return document")) From 070a7cfb914b8b0ed59cfb882396bc3c77f30fd6 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 13:31:10 +0530 Subject: [PATCH 32/51] test(purchase): add coverage for zero-qty return rejection (cherry picked from commit cde2963da1875dc8b94e778342685c44b73bf4b0) # Conflicts: # erpnext/controllers/tests/test_sales_and_purchase_return.py --- .../tests/test_sales_and_purchase_return.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 erpnext/controllers/tests/test_sales_and_purchase_return.py diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py new file mode 100644 index 00000000000..4e000b869f2 --- /dev/null +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -0,0 +1,80 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSalesAndPurchaseReturn(ERPNextTestSuite): + @staticmethod + def _cancel_and_delete(doctype, name): + if not frappe.db.exists(doctype, name): + return + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=1) + + def test_sales_return_validates_against_original(self): + # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups + # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both + # converted from raw SQL here. Exercises them on both engines. + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) + self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) + + dn = create_delivery_note(qty=5) + self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) + + self.assertEqual(return_dn.is_return, 1) + self.assertEqual(return_dn.items[0].qty, -5) + + def test_purchase_invoice_zero_qty_return_is_rejected(self): + # A return with every item at qty 0 moves no stock and no value, so it must be + # rejected the same way a return with no items at all would be. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(qty=10) + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + + self.assertRaises(frappe.ValidationError, return_pi.save) + + def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self): + # Item Code is not mandatory on Purchase Invoice Item - a row can have only an + # item_name (e.g. a free-text/non-stock line). Such rows fall through to the + # item_name-only branch, which must also reject an all-zero-qty return instead + # of unconditionally treating the row as returned. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True) + pi.items[0].item_code = "" + pi.save() + pi.submit() + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + item_name="_Test Item", + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + return_pi.items[0].item_code = "" + + self.assertRaises(frappe.ValidationError, return_pi.save) From 198468aa5ad98ac66b7bc42e7df2f220cd0511e7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:42:14 +0530 Subject: [PATCH 33/51] test(purchase): fit the backported test to version-15-hotfix Drop test_sales_return_validates_against_original: it came in with the new file rather than with the change being backported, covers a raw-SQL to query-builder conversion that only exists on develop, and imports erpnext.stock.doctype.delivery_note.mapper, a module this branch does not have. Base the remaining tests on FrappeTestCase, since ERPNextTestSuite does not exist here either. --- .../tests/test_sales_and_purchase_return.py | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 4e000b869f2..36c851de8fd 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -2,11 +2,10 @@ # See license.txt import frappe - -from erpnext.tests.utils import ERPNextTestSuite +from frappe.tests.utils import FrappeTestCase -class TestSalesAndPurchaseReturn(ERPNextTestSuite): +class TestSalesAndPurchaseReturn(FrappeTestCase): @staticmethod def _cancel_and_delete(doctype, name): if not frappe.db.exists(doctype, name): @@ -16,28 +15,6 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): doc.cancel() frappe.delete_doc(doctype, name, force=1) - def test_sales_return_validates_against_original(self): - # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups - # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both - # converted from raw SQL here. Exercises them on both engines. - from erpnext.stock.doctype.delivery_note.mapper import make_sales_return - from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note - from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - - se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) - self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) - - dn = create_delivery_note(qty=5) - self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) - - return_dn = make_sales_return(dn.name) - return_dn.insert() - return_dn.submit() - self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) - - self.assertEqual(return_dn.is_return, 1) - self.assertEqual(return_dn.items[0].qty, -5) - def test_purchase_invoice_zero_qty_return_is_rejected(self): # A return with every item at qty 0 moves no stock and no value, so it must be # rejected the same way a return with no items at all would be. From 48beb2ee23a1a95ae46b8782dfd3749b45e04d2f Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Thu, 30 Jul 2026 12:11:11 +0530 Subject: [PATCH 34/51] fix(sales): reject sales returns where every item has zero quantity validate_returned_items() set items_returned=True whenever a row matched a valid item from the original document, even if its qty was 0. This let a Sales Invoice, Delivery Note, or POS Invoice return be submitted with every line at qty=0 - a no-op document with no stock or financial effect that still consumed a document number and linked back to the original transaction. Scoped to the Sales side only: items_returned now flips to True for Sales Invoice/Delivery Note/POS Invoice only when qty (or received_qty) is actually negative, so an all-zero sales return correctly hits the existing "At least one item should be entered with negative quantity" check. Purchase Invoice, Purchase Receipt, and Subcontracting Receipt are unchanged. (cherry picked from commit a3e9d13da30089467441cf48586e5a6f3e211feb) # Conflicts: # erpnext/controllers/sales_and_purchase_return.py --- erpnext/controllers/sales_and_purchase_return.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index c58580739e3..efca82b425b 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -158,7 +158,22 @@ def validate_returned_items(doc): ): frappe.throw(_("Warehouse is mandatory")) +<<<<<<< HEAD items_returned = True +======= + if doc.doctype in ( + "Purchase Invoice", + "Purchase Receipt", + "Subcontracting Receipt", + "Sales Invoice", + "Delivery Note", + "POS Invoice", + ): + if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0: + items_returned = True + else: + items_returned = True +>>>>>>> a3e9d13da3 (fix(sales): reject sales returns where every item has zero quantity) elif d.item_name: items_returned = True From 51522816180e1bc590f95119af5fc4a29d3bcdfc Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Fri, 31 Jul 2026 12:01:39 +0530 Subject: [PATCH 35/51] test(sales): add coverage for zero-qty return rejection Greptile flagged that the sales-side zero-qty-return fix had no dedicated test proving the behavior - the existing suite happened to pass, but nothing specifically asserted that an all-zero return is rejected while a normal negative-qty return still succeeds. Adds two tests covering the doctypes that rely entirely on this check (no other guard covers them for a non-stock-effect return): - Delivery Note return with qty 0 -> rejected - Sales Invoice return with qty 0 (no update_stock) -> rejected POS Invoice is not covered separately here since it always runs with update_stock=1, which is already guarded by the pre-existing validate_zero_qty_for_return_invoices_with_stock check regardless of this fix. (cherry picked from commit 732c884633acc8ed5862cded0f18f725457a6439) # Conflicts: # erpnext/controllers/tests/test_sales_and_purchase_return.py --- .../tests/test_sales_and_purchase_return.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 erpnext/controllers/tests/test_sales_and_purchase_return.py diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py new file mode 100644 index 00000000000..1063b0d6f8d --- /dev/null +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSalesAndPurchaseReturn(ERPNextTestSuite): + @staticmethod + def _cancel_and_delete(doctype, name): + if not frappe.db.exists(doctype, name): + return + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=1) + + def test_sales_return_validates_against_original(self): + # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups + # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both + # converted from raw SQL here. Exercises them on both engines. + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) + self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) + + dn = create_delivery_note(qty=5) + self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) + + self.assertEqual(return_dn.is_return, 1) + self.assertEqual(return_dn.items[0].qty, -5) + + def test_purchase_invoice_zero_qty_return_is_rejected(self): + # A return with every item at qty 0 moves no stock and no value, so it must be + # rejected the same way a return with no items at all would be. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(qty=10) + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + + self.assertRaises(frappe.ValidationError, return_pi.save) + + def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self): + # Item Code is not mandatory on Purchase Invoice Item - a row can have only an + # item_name (e.g. a free-text/non-stock line). Such rows fall through to the + # item_name-only branch, which must also reject an all-zero-qty return instead + # of unconditionally treating the row as returned. + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True) + pi.items[0].item_code = "" + pi.save() + pi.submit() + self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name) + + return_pi = make_purchase_invoice( + item_name="_Test Item", + is_return=1, + return_against=pi.name, + qty=0, + do_not_save=True, + ) + return_pi.items[0].item_code = "" + + self.assertRaises(frappe.ValidationError, return_pi.save) + + def test_delivery_note_zero_qty_return_is_rejected(self): + # A return with every item at qty 0 moves no stock and no value, so it must be + # rejected the same way a return with no items at all would be. + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) + self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) + + dn = create_delivery_note(qty=5) + self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) + + return_dn = make_sales_return(dn.name) + return_dn.items[0].qty = 0 + + self.assertRaises(frappe.ValidationError, return_dn.insert) + + def test_sales_invoice_zero_qty_return_is_rejected(self): + # Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on + # every row must be rejected, not silently accepted as a no-op credit note. + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + si = create_sales_invoice(qty=10) + self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name) + + return_si = make_return_doc(si.doctype, si.name) + return_si.items[0].qty = 0 + + self.assertRaises(frappe.ValidationError, return_si.save) From 5ec87ae06c679a9065edcd72821c61b5499a0192 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 13:29:37 +0530 Subject: [PATCH 36/51] chore: resolve conflict --- .../controllers/sales_and_purchase_return.py | 4 --- .../tests/test_sales_and_purchase_return.py | 29 ++----------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index efca82b425b..5f469b6a7fd 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -158,9 +158,6 @@ def validate_returned_items(doc): ): frappe.throw(_("Warehouse is mandatory")) -<<<<<<< HEAD - items_returned = True -======= if doc.doctype in ( "Purchase Invoice", "Purchase Receipt", @@ -173,7 +170,6 @@ def validate_returned_items(doc): items_returned = True else: items_returned = True ->>>>>>> a3e9d13da3 (fix(sales): reject sales returns where every item has zero quantity) elif d.item_name: items_returned = True diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py index 1063b0d6f8d..0de679352f7 100644 --- a/erpnext/controllers/tests/test_sales_and_purchase_return.py +++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py @@ -2,11 +2,10 @@ # See license.txt import frappe - -from erpnext.tests.utils import ERPNextTestSuite +from frappe.tests.utils import FrappeTestCase -class TestSalesAndPurchaseReturn(ERPNextTestSuite): +class TestSalesAndPurchaseReturn(FrappeTestCase): @staticmethod def _cancel_and_delete(doctype, name): if not frappe.db.exists(doctype, name): @@ -16,28 +15,6 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): doc.cancel() frappe.delete_doc(doctype, name, force=1) - def test_sales_return_validates_against_original(self): - # Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups - # via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both - # converted from raw SQL here. Exercises them on both engines. - from erpnext.stock.doctype.delivery_note.mapper import make_sales_return - from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note - from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - - se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100) - self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) - - dn = create_delivery_note(qty=5) - self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name) - - return_dn = make_sales_return(dn.name) - return_dn.insert() - return_dn.submit() - self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name) - - self.assertEqual(return_dn.is_return, 1) - self.assertEqual(return_dn.items[0].qty, -5) - def test_purchase_invoice_zero_qty_return_is_rejected(self): # A return with every item at qty 0 moves no stock and no value, so it must be # rejected the same way a return with no items at all would be. @@ -82,7 +59,7 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite): def test_delivery_note_zero_qty_return_is_rejected(self): # A return with every item at qty 0 moves no stock and no value, so it must be # rejected the same way a return with no items at all would be. - from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry From 2993747636b77672fb9398fbdcbad72754cfd3da Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 15:57:35 +0530 Subject: [PATCH 37/51] fix(stock): validate only the variant attributes that changed Disabling an Item Attribute writes `disabled = 1` into every Item Variant Attribute row, including the rows on the template. `validate_variant` runs on every save and walks the whole attribute table, so any later save of an existing variant re-checked its untouched rows against the now-disabled template row and threw. `update_variants` hit the same wall, which made a single template save fail once an attribute was disabled. The flag exists to keep an attribute out of new variants, not to freeze the variants that already use it. item.js only reads it to drop the attribute from the variant creation dialog. Skip rows that are unchanged since the last save. New and edited rows are still checked, so a disabled attribute cannot be added to an existing variant, and the same guard covers the sibling checks for attributes and values that the template no longer offers. (cherry picked from commit 25cd7936176cd946ad4d5899294524125d181f40) --- erpnext/stock/doctype/item/item.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 7f077cfd4dd..fb2a7ee95f8 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -837,7 +837,17 @@ class Item(Document): frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of))) if based_on == "Item Attribute": + previous_doc = self.get_doc_before_save() + saved_attributes = ( + {(row.attribute, row.attribute_value) for row in previous_doc.attributes} + if previous_doc + else set() + ) + for d in self.attributes: + if (d.attribute, d.attribute_value) in saved_attributes: + continue + if not frappe.db.exists( "Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of} ): From 005b6264820466f96000aefb28b9f2994e24326a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 15:57:42 +0530 Subject: [PATCH 38/51] test(stock): cover editing a variant whose attribute is disabled Assert that a variant saves after its attribute is disabled when the edit leaves the attribute rows alone, and that changing an attribute value still throws. (cherry picked from commit 8d5326196e40f2b6e2aa08d7ea02be8127ac823a) --- erpnext/stock/doctype/item/test_item.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 073c8c8be93..05846afbc58 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -360,6 +360,24 @@ class TestItem(FrappeTestCase): self.assertRaises(InvalidItemAttributeValueError, attribute.save) frappe.db.rollback() + def test_disabled_attribute_blocks_only_attribute_changes(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + attribute.disabled = 1 + attribute.save() + + variant.reload() + variant.description = "Edited after the attribute was disabled" + variant.save() + + variant.reload() + variant.attributes[0].attribute_value = "Small" + self.assertRaises(frappe.ValidationError, variant.save) + def test_rename_attribute_value_updates_variants(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) From c488de8f1219ea0a0914fa8ce0207923b4438c92 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 16:43:07 +0530 Subject: [PATCH 39/51] test(stock): isolate the disabled attribute fixtures The test disabled the shared `Test Size` Item Attribute. On version-15 `FrappeTestCase` rolls back once per class instead of once per test, so the flag stayed visible for the rest of `TestItem` and broke the seven tests that build a variant from that attribute. Build a dedicated attribute and template instead. Nothing the test writes is reachable from another test, on either branch, so no cleanup is needed. --- erpnext/stock/doctype/item/test_item.py | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 05846afbc58..deda65c911c 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -361,12 +361,33 @@ class TestItem(FrappeTestCase): frappe.db.rollback() def test_disabled_attribute_blocks_only_attribute_changes(self): - frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template", force=1) + frappe.delete_doc_if_exists("Item Attribute", "_Test Disabled Size", force=1) - variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + attribute = frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": "_Test Disabled Size", + "item_attribute_values": [ + {"attribute_value": "Large", "abbr": "L"}, + {"attribute_value": "Small", "abbr": "S"}, + ], + } + ).insert() + + template = make_item( + "_Test Disabled Attribute Template", + { + "has_variants": 1, + "variant_based_on": "Item Attribute", + "attributes": [{"attribute": attribute.name}], + }, + ) + + variant = create_variant(template.name, {attribute.name: "Large"}) variant.save() - attribute = frappe.get_doc("Item Attribute", "Test Size") attribute.disabled = 1 attribute.save() From ee4e296ce6ae7f875963b21baf22f80d6cb89783 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:33:56 +0000 Subject: [PATCH 40/51] fix(accounts): fetch deferred invoice docs on non-empty `sales_docs` or `purchase_docs` in repost accounting ledger (backport #57753) (#57756) Co-authored-by: Diptanil Saha --- .../repost_accounting_ledger.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py index ebf79077cc8..6bab8ed2e1d 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -473,19 +473,24 @@ def get_child_docs(doc: list) -> list: def validate_docs_for_deferred_accounting(sales_docs, purchase_docs): - docs_with_deferred_revenue = frappe.db.get_all( - "Sales Invoice Item", - filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True}, - fields=["parent"], - as_list=1, - ) + docs_with_deferred_revenue = () + docs_with_deferred_expense = () - docs_with_deferred_expense = frappe.db.get_all( - "Purchase Invoice Item", - filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1}, - fields=["parent"], - as_list=1, - ) + if sales_docs: + docs_with_deferred_revenue = frappe.db.get_all( + "Sales Invoice Item", + filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True}, + fields=["parent"], + as_list=1, + ) + + if purchase_docs: + docs_with_deferred_expense = frappe.db.get_all( + "Purchase Invoice Item", + filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1}, + fields=["parent"], + as_list=1, + ) if docs_with_deferred_revenue or docs_with_deferred_expense: frappe.throw( From faa7c466b1716c47b28e78eb46853134081bda78 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 20 Jul 2026 17:05:21 +0530 Subject: [PATCH 41/51] fix: Ignore permission while deleting user permission (cherry picked from commit 3b10ff7df77af79ee1bfd09c1f26d701e11702b1) --- erpnext/setup/doctype/employee/employee.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index ead82ef8bf3..b4eb03fcbc6 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -53,7 +53,7 @@ class Employee(NestedSet): user = frappe.get_doc("User", existing_user_id) validate_employee_role(user, ignore_emp_check=True) user.save(ignore_permissions=True) - remove_user_permission("Employee", self.name, existing_user_id) + remove_user_permission("Employee", self.name, existing_user_id, ignore_permissions=True) def after_rename(self, old, new, merge): self.db_set("employee", new) @@ -91,11 +91,11 @@ class Employee(NestedSet): ) if employee_user_permission_exists and not self.create_user_permission: - remove_user_permission("Employee", self.name, self.user_id) - remove_user_permission("Company", self.company, self.user_id) + remove_user_permission("Employee", self.name, self.user_id, ignore_permissions=True) + remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True) elif not employee_user_permission_exists and self.create_user_permission: - add_user_permission("Employee", self.name, self.user_id) - add_user_permission("Company", self.company, self.user_id) + add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True) + add_user_permission("Company", self.company, self.user_id, ignore_permissions=True) def update_user(self): # add employee role if missing From 928f984198729d3123b4ce6b28eb1481b28c827e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:56:46 +0000 Subject: [PATCH 42/51] fix: escape data in multiple templates (backport #57742) (#57769) Co-authored-by: diptanilsaha --- .../plant_floor/stock_summary_template.html | 10 +++++----- .../doctype/workstation/workstation.py | 2 +- erpnext/public/js/templates/item_selector.html | 18 ++++++++++-------- .../templates/visual_plant_floor_template.html | 7 ++++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html b/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html index 69c8f44f4e7..d5f252c42c3 100644 --- a/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html +++ b/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html @@ -2,9 +2,9 @@
{% if(row.image) { %} - + {% } else { %} -
{{frappe.get_abbr(row.item_code, 2)}}
+
{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}
{% } %}
@@ -13,7 +13,7 @@ {% } else { %} {{row.item_link}}

- {{row.item_name}} + {{frappe.utils.escape_html(row.item_name)}}

{% } %} @@ -52,10 +52,10 @@
- +
- +
{% }); %} diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 1e65ea7bedf..d996d8a98ea 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -418,6 +418,6 @@ def get_workstations(**kwargs): d.background_color = color_map.get(d.status, "var(--red-600)") d.workstation_link = get_url_to_form("Workstation", d.name) if d.status != "Production": - d.status_image = d.off_status_image + d.status_image = frappe.utils.escape_html(d.off_status_image) return data diff --git a/erpnext/public/js/templates/item_selector.html b/erpnext/public/js/templates/item_selector.html index 86a15f49072..0839077f57d 100644 --- a/erpnext/public/js/templates/item_selector.html +++ b/erpnext/public/js/templates/item_selector.html @@ -1,17 +1,19 @@
{% for (var i=0; i < data.length; i++) { var item = data[i]; %} + {% const item_name = frappe.utils.escape_html(item.name); %} + {% const item_title = frappe.utils.escape_html(item.item_name || item.name); %} {% if (i % 4 === 0) { %}
{% } %} -
+
-
- {%= frappe.get_abbr(item.item_name || item.name) %} + {%= frappe.get_abbr(item_title) %} {% } %} {% if (item.image) { %} - {{item.item_name || item.name}} + {{ item_title }} {% } %}
diff --git a/erpnext/public/js/templates/visual_plant_floor_template.html b/erpnext/public/js/templates/visual_plant_floor_template.html index a1639f07370..a60007a04d5 100644 --- a/erpnext/public/js/templates/visual_plant_floor_template.html +++ b/erpnext/public/js/templates/visual_plant_floor_template.html @@ -1,4 +1,5 @@ {% $.each(workstations, (idx, row) => { %} + {% const row_workstation_name = frappe.utils.escape_html(row.name); %}
{% if(row.status == "Production") { %} @@ -17,14 +18,14 @@ {% if(row.status_image) { %} {% } else { %} -
{{frappe.get_abbr(row.name, 2)}}
+
{{frappe.get_abbr(row_workstation_name, 2)}}
{% } %}
- - {{row.workstation_name}} + + {{row_workstation_name}}
From 9d417da3d85a80dde7b7727380b7d8a9d439923a Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 4 Aug 2026 14:10:55 +0530 Subject: [PATCH 43/51] fix(accounts): skip party dashboard without invoice permission (cherry picked from commit ed78dd37be61dce1bd7ee7f5e9f9758198d6ae6f) --- erpnext/accounts/party.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index ca66235cff3..170a39582af 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -849,9 +849,11 @@ def validate_account_party_type(self): def get_dashboard_info(party_type, party, loyalty_program=None): - current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True) - doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" + if not frappe.has_permission(doctype, "read"): + return None + + current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True) companies = frappe.get_list( doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"] From f47346aa9080a966fe10d431eb6ca9808e1fc9b0 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Mon, 3 Aug 2026 16:41:43 +0530 Subject: [PATCH 44/51] fix(opportunity): add validation for positive item quantities (cherry picked from commit c47cc374411c24c6fb62ca5e8114441e20f78414) --- erpnext/crm/doctype/opportunity/opportunity.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 7d4d1bb10bf..3cd927c938d 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -132,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote): self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() self.map_fields() + self.validate_qty() self.set_exchange_rate() if not self.title: @@ -142,6 +143,15 @@ class Opportunity(TransactionBase, CRMNote): def on_update(self): self.update_prospect() + def validate_qty(self): + for item in self.items: + if item.qty <= 0: + frappe.throw( + _("Row #{0}: Quantity must be greater than 0 for Item {1}").format( + item.idx, item.item_code + ) + ) + def map_fields(self): for field in self.meta.get_valid_columns(): if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field): From 7ef039f6eda7f7e0943a78e7545271e6b1180f35 Mon Sep 17 00:00:00 2001 From: R-Jayaraman Date: Tue, 4 Aug 2026 16:38:49 +0530 Subject: [PATCH 45/51] chore: use flt() in qty check (cherry picked from commit 69de8f2d62c16a96b868885694d6dcc6fd5e5a35) --- 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 3cd927c938d..aa8d75e1826 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -145,7 +145,7 @@ class Opportunity(TransactionBase, CRMNote): def validate_qty(self): for item in self.items: - if item.qty <= 0: + if flt(item.qty) <= 0: frappe.throw( _("Row #{0}: Quantity must be greater than 0 for Item {1}").format( item.idx, item.item_code From 431dc2e5f15954e94a9376eb7000fc1642338a46 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 4 Aug 2026 22:53:56 +0530 Subject: [PATCH 46/51] fix: incorrect batch-wise valuation rate for entries with same posting datetime (#57794) * fix: incorrect batch-wise valuation rate for entries with same posting datetime The tie-breaker in get_batch_no_ledgers compared the bundle's creation against the SLE's creation. These are different timelines - a bundle can be created (drafted) much before its SLE (created at submission). For entries sharing a posting datetime (backdated / amended vouchers), this mis-ordered the entries against the ledger's replay order (SLE creation), causing double counting or omission of batch qty / value and runaway outgoing rates that no repost could heal. Now the tie is broken using the creation of the bundle's own SLE (same timeline on both sides). When the valuation runs through the bundle before its SLE exists, the entry is by definition last in its timestamp group, so all same-timestamp entries already in the ledger precede it. Co-Authored-By: Claude Fable 5 * test: batch-wise valuation ordering for same posting datetime entries Covers both tie-breaking branches of get_batch_no_ledgers: - submission (pre-insertion) branch: same-timestamp inward at a different rate plus a multi-row outward voucher (same item and warehouse), at submission and after a backdated repost - existing-SLE branch: a bundle created after its sibling's SLE, the ordering must follow the SLE creation and not the bundle creation Both tests fail with the previous parent.creation < sle.creation tie-breaker and pass with the fix. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../test_serial_and_batch_bundle.py | 185 +++++++++++++++++- erpnext/stock/serial_batch_bundle.py | 35 +++- 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index a301c1f3017..ff6e9012633 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -5,7 +5,7 @@ import json import frappe from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import flt, nowtime, today +from frappe.utils import add_days, add_to_date, flt, nowtime, today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -1512,3 +1512,186 @@ class TestSerialandBatchBundleLogic(FrappeTestCase): self.assertNotIn(bundles[1], bundle_wise_serial_nos) self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no]) + + @change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}) + def test_batchwise_valuation_for_same_posting_datetime_entries(self): + # an inward at a different rate and multiple outward rows with the same + # item and warehouse share the same posting datetime, the tie-breaking + # must include the same-timestamp entries which are already part of the + # ledger and must not let the outward rows count each other + item_code = make_item( + "Test Batchwise Same Posting Datetime Item 1", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSPD-ITEM1-.#####", + "valuation_method": "FIFO", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + receipt = make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + posting_date=add_days(today(), -5), + posting_time="12:00:00", + ) + + batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle) + self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation")) + + # same posting datetime as the outward rows below, at a different rate + make_stock_entry( + item_code=item_code, + qty=20, + rate=250, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + issue = make_stock_entry( + item_code=item_code, + qty=2, + source=warehouse, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + do_not_save=True, + ) + + for qty in [3, 4]: + issue.append( + "items", + { + "item_code": item_code, + "s_warehouse": warehouse, + "qty": qty, + "conversion_factor": 1, + }, + ) + + issue.save() + issue.submit() + + # (10 * 100 + 20 * 250) / 30 = 200 + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0) + + # backdated receipt reposts the same posting datetime cluster + make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -4), + posting_time="12:00:00", + ) + + # (20 * 100 + 20 * 250) / 40 = 175 + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0) + + @change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}) + def test_batchwise_valuation_when_bundle_created_before_the_sle(self): + # a bundle can be created (drafted) much before / after its SLE, the + # tie-breaking for the same posting datetime entries must follow the + # SLE creation and not the bundle creation + item_code = make_item( + "Test Batchwise Same Posting Datetime Item 2", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSPD-ITEM2-.#####", + "valuation_method": "FIFO", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + receipt = make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + posting_date=add_days(today(), -5), + posting_time="12:00:00", + ) + + batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle) + + # inward at a different rate, same posting datetime as the outward below + inward = make_stock_entry( + item_code=item_code, + qty=10, + rate=200, + target=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + outward = make_stock_entry( + item_code=item_code, + qty=10, + source=warehouse, + posting_date=add_days(today(), -3), + posting_time="12:00:00", + ) + + # simulate the inward's bundle drafted after the outward's SLE, the + # bundle creation timeline no longer matches the SLE creation timeline + outward_sle_creation = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": outward.name, "is_cancelled": 0}, + "creation", + ) + + frappe.db.set_value( + "Serial and Batch Bundle", + inward.items[0].serial_and_batch_bundle, + "creation", + add_to_date(outward_sle_creation, minutes=30), + update_modified=False, + ) + + repost = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "item_code": item_code, + "warehouse": warehouse, + "posting_date": add_days(today(), -6), + "posting_time": "00:00:00", + "allow_negative_stock": 1, + } + ) + + repost.submit() + + # (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as + # per the SLE creation even though its bundle was created afterwards + self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0) + + def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value): + sl_entries = frappe.get_all( + "Stock Ledger Entry", + filters={"item_code": item_code, "is_cancelled": 0}, + fields=["actual_qty", "stock_value_difference", "stock_value"], + order_by="posting_datetime, creation", + ) + + for sle in sl_entries: + if sle.actual_qty > 0: + continue + + self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2)) + + self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2)) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 1b030dbe2fa..4e0d69134bb 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -829,14 +829,43 @@ class BatchNoValuation(DeprecatedBatchNoValuation): parent = frappe.qb.DocType("Serial and Batch Bundle") child = frappe.qb.DocType("Serial and Batch Entry") + sle_creation = self.sle.creation if self.sle.get("name") else None + if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"): + sle_creation = frappe.db.get_value( + "Stock Ledger Entry", + {"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0}, + "creation", + ) + timestamp_condition = "" if self.sle.posting_datetime: timestamp_condition = parent.posting_datetime < self.sle.posting_datetime - if self.sle.creation: - timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & ( - parent.creation < self.sle.creation + sle_table = frappe.qb.DocType("Stock Ledger Entry") + if sle_creation: + # bundle creation and SLE creation are different timelines (a + # bundle can be created much before its SLE), so break the tie + # using the creation of the bundle's own SLE + tie_condition = ExistsCriterion( + frappe.qb.from_(sle_table) + .select(sle_table.name) + .where( + (sle_table.serial_and_batch_bundle == parent.name) + & (sle_table.is_cancelled == 0) + & (sle_table.creation < sle_creation) + ) ) + else: + # the current entry is not yet in the ledger and will get the + # latest creation, so the same-timestamp entries which are + # already in the ledger precede it + tie_condition = ExistsCriterion( + frappe.qb.from_(sle_table) + .select(sle_table.name) + .where((sle_table.serial_and_batch_bundle == parent.name) & (sle_table.is_cancelled == 0)) + ) + + timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & tie_condition query = ( frappe.qb.from_(parent) From 78cd25de04193849fd432c617f204951547b7f7c Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Tue, 4 Aug 2026 23:58:19 +0530 Subject: [PATCH 47/51] fix(payment reconciliation): correct supplier gain/loss posting (cherry picked from commit dc907add4012b29755e5f12edea2d453bb3a6066) --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 365e481890f..7f2afecaf9f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1869,7 +1869,7 @@ class AccountsController(TransactionBase): def is_payable_account(self, reference_doctype, account): if reference_doctype == "Purchase Invoice" or ( - reference_doctype == "Journal Entry" + reference_doctype in ("Journal Entry", "Payment Entry") and frappe.get_cached_value("Account", account, "account_type") == "Payable" ): return True From 387f2b5d01d3a514a919bb1254ceaff51bd23f05 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 2 Aug 2026 16:21:46 +0530 Subject: [PATCH 48/51] test(payment reconciliation): cover supplier exchange gain posting (cherry picked from commit 61154e22ed494daa69d05bbe7441f63e4701d8d7) --- .../test_payment_reconciliation.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index a727e4ab894..0627983d73a 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -2436,6 +2436,86 @@ class TestPaymentReconciliation(FrappeTestCase): self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0) pr.reconcile() + def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self): + transaction_date = nowdate() + self.supplier = "_Test Supplier USD" + amount = 100 + department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name") + + # Pay USD 100 at an exchange rate of 90. + pe = self.create_payment_entry(amount=amount, posting_date=transaction_date) + pe.payment_type = "Pay" + pe.party_type = "Supplier" + pe.party = self.supplier + pe.paid_from = self.cash + pe.paid_from_account_currency = "INR" + pe.target_exchange_rate = 90 + pe.paid_amount = 90 * amount + pe.received_amount = amount + pe.paid_to = self.creditors_usd + pe.paid_to_account_currency = "USD" + pe.department = department + pe = pe.save().submit() + + # Receive USD 100 from the supplier at an exchange rate of 100. + reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date) + reverse_pe.payment_type = "Receive" + reverse_pe.party_type = "Supplier" + reverse_pe.party = self.supplier + reverse_pe.paid_from = self.creditors_usd + reverse_pe.paid_from_account_currency = "USD" + reverse_pe.source_exchange_rate = 100 + reverse_pe.paid_amount = amount + reverse_pe.received_amount = 100 * amount + reverse_pe.paid_to = self.cash + reverse_pe.paid_to_account_currency = "INR" + reverse_pe.department = department + reverse_pe = reverse_pe.save().submit() + + pr = self.create_payment_reconciliation(party_is_customer=False) + pr.party = self.supplier + pr.receivable_payable_account = self.creditors_usd + pr.get_unreconciled_entries() + invoices = [invoice.as_dict() for invoice in pr.invoices] + payments = [payment.as_dict() for payment in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + for row in pr.allocation: + row.department = department + + self.assertEqual(flt(pr.allocation[0].difference_amount), 1000) + pr.reconcile() + + gain_loss_journal = frappe.db.get_value( + "Journal Entry Account", + { + "reference_type": reverse_pe.doctype, + "reference_name": reverse_pe.name, + "party": self.supplier, + "docstatus": 1, + }, + "parent", + ) + party_row = frappe.db.get_value( + "Journal Entry Account", + {"parent": gain_loss_journal, "party": self.supplier}, + ["debit", "credit"], + as_dict=True, + ) + self.assertEqual(flt(party_row.debit), 1000) + self.assertEqual(flt(party_row.credit), 0) + + party_gl_entries = frappe.get_all( + "GL Entry", + filters={ + "voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]], + "account": self.creditors_usd, + "party": self.supplier, + "is_cancelled": 0, + }, + fields=["debit", "credit"], + ) + self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0) + def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self): transaction_date = nowdate() customer = self.customer3 From 25bd2bd4e77ef5b38dca02997bacde8373ac64bb Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Wed, 5 Aug 2026 00:31:29 +0530 Subject: [PATCH 49/51] test: child warehouse account override excluded in stock vs account value comparison --- ...test_stock_and_account_value_comparison.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py index 66120a56b79..6ac9669d50a 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py @@ -7,6 +7,8 @@ from frappe.utils import today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( create_reposting_entries, execute, @@ -55,3 +57,22 @@ class TestStockAndAccountValueComparison(FrappeTestCase): filters={"based_on": "Item and Warehouse", "item_code": item}, ) self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based") + + def test_child_account_override_excluded_from_group_account(self): + # A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override + # it with its own account. get_warehouses_based_on_account must return only warehouses whose + # effective account matches, excluding the overriding child. + group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=PI_COMPANY) + group_account = frappe.get_value("Warehouse", group, "account") + + inheriting = create_warehouse( + "_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=PI_COMPANY + ) + overriding = create_warehouse( + "_Test SAVC Transit WH", {"parent_warehouse": group}, company=PI_COMPANY + ) + + warehouses = get_warehouses_based_on_account(group_account, PI_COMPANY) + + self.assertIn(inheriting, warehouses) + self.assertNotIn(overriding, warehouses) From 35f523e2dde16205c8e883ba49c9ad9804bfc127 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:39:37 +0530 Subject: [PATCH 50/51] fix: set transaction currency on payment entry gl entries (#57613) --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 6 ++++++ .../accounts/doctype/payment_entry/test_payment_entry.py | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index ad692f1557d..2746de2b99b 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1301,8 +1301,14 @@ class PaymentEntry(AccountsController): self.add_deductions_gl_entries(gl_entries) self.add_tax_gl_entries(gl_entries) add_regional_gl_entries(gl_entries, self) + self.set_transaction_currency_and_rate_in_gl_map(gl_entries) return gl_entries + def set_transaction_currency_and_rate_in_gl_map(self, gl_entries): + for gle in gl_entries: + gle.setdefault("transaction_currency", self.transaction_currency) + gle.setdefault("transaction_exchange_rate", self.transaction_exchange_rate) + def make_gl_entries(self, cancel=0, adv_adj=0): gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index c52193cc469..1c010e7d74b 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -1046,14 +1046,17 @@ class TestPaymentEntry(FrappeTestCase): gle.credit_in_account_currency, gle.debit_in_transaction_currency, gle.credit_in_transaction_currency, + gle.transaction_currency, + gle.transaction_exchange_rate, ) .orderby(gle.account) .where(gle.voucher_no == payment_entry.name) .run() ) + # transaction currency/rate come from the paid-from USD account (company currency is INR) expected_gl_entries = ( - (paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0), - ("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0), + (paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4), + ("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4), ) self.assertEqual(gl_entries, expected_gl_entries) From 15c381701d1a00abf703335f78e985c0c5ef9923 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:22:31 +0000 Subject: [PATCH 51/51] fix(accounts): update AU standard chart of accounts (backport #57145) (#57607) fix(accounts): update AU standard chart of accounts (#57145) (cherry picked from commit fee3a6e0fd017a287507d535b0795c28b4fe90ae) Co-authored-by: Diptanil Saha Co-authored-by: Jebajebas --- .../verified/au_standard_chart_of_accounts.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json index 515a1e4de9d..a55dd3a183d 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json @@ -24,7 +24,8 @@ "account_number": "11530" }, "account_number": "115", - "is_group": 1 + "is_group": 1, + "account_type": "Bank" }, "Trade Receivables": { "Trade Debtors": { @@ -529,6 +530,13 @@ "account_number": "630", "is_group": 1 }, + "Accrued Manufacturing Expenses": { + "Accrued Expenses - Manufacturing": { + "account_number": "63510" + }, + "account_number": "635", + "is_group": 1 + }, "account_number": "63", "is_group": 1 }, @@ -814,4 +822,4 @@ "root_type": "Expense" } } -} \ No newline at end of file +}