From a1fae959ed9ef6b02779dee8a0ff5b1b7f39d7c7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 9 Jul 2026 14:14:44 +0530 Subject: [PATCH 01/33] fix: match depreciation schedule rows at currency precision to avoid duplicate JEs (cherry picked from commit 947ed5dfe19a0082cdcc0a387ddf45834afcc894) # Conflicts: # erpnext/accounts/doctype/journal_entry/services/asset_service.py --- .../journal_entry/services/asset_service.py | 201 ++++++++++++++++++ erpnext/assets/doctype/asset/test_asset.py | 40 ++++ 2 files changed, 241 insertions(+) create mode 100644 erpnext/accounts/doctype/journal_entry/services/asset_service.py diff --git a/erpnext/accounts/doctype/journal_entry/services/asset_service.py b/erpnext/accounts/doctype/journal_entry/services/asset_service.py new file mode 100644 index 00000000000..c0b954233af --- /dev/null +++ b/erpnext/accounts/doctype/journal_entry/services/asset_service.py @@ -0,0 +1,201 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, +) + + +class AssetService: + """Keeps Assets in sync with the Journal Entries that depreciate, dispose or + adjust them. + + On submit of a Depreciation Entry it reduces the asset value and links the + depreciation schedule; on submit of an Asset Disposal it marks the asset + disposed. On cancel it reverses those links. It also guards cancellation of + Journal Entries tied to asset scrapping or value adjustments. + """ + + def __init__(self, doc) -> None: + self.doc = doc + + def validate_depr_account_and_depr_entry_voucher_type(self) -> None: + """A depreciation account requires voucher type Depreciation Entry and an Expense account.""" + for d in self.doc.get("accounts"): + if d.account_type == "Depreciation": + if self.doc.voucher_type != "Depreciation Entry": + frappe.throw( + _("Journal Entry type should be set as Depreciation Entry for asset depreciation") + ) + + if frappe.get_cached_value("Account", d.account, "root_type") != "Expense": + frappe.throw(_("Account {0} should be of type Expense").format(d.account)) + + def has_asset_adjustment_entry(self) -> None: + """Block cancellation while a submitted Asset Value Adjustment links to this entry.""" + if self.doc.flags.get("via_asset_value_adjustment"): + return + + asset_value_adjustment = frappe.db.get_value( + "Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name" + ) + if asset_value_adjustment: + frappe.throw( + _( + "Cannot cancel this document as it is linked with the submitted Asset Value Adjustment {0}. Please cancel the Asset Value Adjustment to continue." + ).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment)) + ) + + def update_asset_value(self) -> None: + """Apply the entry's effect to its linked assets on submit (depreciation or disposal).""" + self.update_asset_on_depreciation() + self.update_asset_on_disposal() + + def update_asset_on_depreciation(self) -> None: + """Reduce each depreciated asset's value and link the depreciation schedule row.""" + if self.doc.voucher_type != "Depreciation Entry": + return + + for d in self.doc.get("accounts"): + if ( + d.reference_type == "Asset" + and d.reference_name + and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" + and d.debit + ): + asset = frappe.get_cached_doc("Asset", d.reference_name) + + if asset.calculate_depreciation: + self.update_journal_entry_link_on_depr_schedule(asset, d) + self.update_value_after_depreciation(asset, d.debit) + + asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit) + asset.set_status() + asset.set_total_booked_depreciations() + + def update_value_after_depreciation(self, asset, depr_amount: float) -> None: + """Subtract the depreciation amount from the asset's relevant finance book.""" + fb_idx = 1 + if self.doc.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.doc.finance_book: + fb_idx = fb_row.idx + break + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation -= depr_amount + frappe.db.set_value( + "Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation + ) + + def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None: + """Stamp this entry onto the matching (date + amount) depreciation schedule row.""" + depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book) + precision = je_row.precision("debit") + for d in depr_schedule or []: + if ( + d.schedule_date == self.doc.posting_date + and not d.journal_entry + and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision) + ): + frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name) + + def update_asset_on_disposal(self) -> None: + """Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal.""" + if self.doc.voucher_type == "Asset Disposal": + disposed_assets = [] + for d in self.doc.get("accounts"): + if ( + d.reference_type == "Asset" + and d.reference_name + and d.reference_name not in disposed_assets + ): + frappe.db.set_value( + "Asset", + d.reference_name, + { + "disposal_date": self.doc.posting_date, + "journal_entry_for_scrap": self.doc.name, + }, + ) + asset_doc = frappe.get_doc("Asset", d.reference_name) + asset_doc.set_status() + disposed_assets.append(d.reference_name) + + def unlink_asset_reference(self) -> None: + """On cancel, reverse depreciation links and block cancelling an asset-scrap entry.""" + for d in self.doc.get("accounts"): + if self._is_depreciation_asset_row(d): + self._reverse_asset_depreciation(d) + elif ( + self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name + ): + self._block_scrap_journal_cancel(d) + + def _is_depreciation_asset_row(self, d) -> bool: + return bool( + self.doc.voucher_type == "Depreciation Entry" + and d.reference_type == "Asset" + and d.reference_name + and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" + and d.debit + ) + + def _reverse_asset_depreciation(self, d) -> None: + """Add the depreciation amount back to the asset and unlink its schedule row.""" + asset = frappe.get_doc("Asset", d.reference_name) + + if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit): + self._restore_finance_book_value(asset, d.debit) + + asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit) + asset.set_status() + asset.set_total_booked_depreciations() + + def _restore_scheduled_depreciation(self, asset, debit: float) -> bool: + """Unlink this entry from the depreciation schedule and credit back its finance book. + + Returns True if a matching scheduled depreciation was found. + """ + for fb_row in asset.get("finance_books"): + depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book) + for s in depr_schedule or []: + if s.journal_entry == self.doc.name: + s.db_set("journal_entry", None) + fb_row.value_after_depreciation += debit + fb_row.db_update() + return True + return False + + def _restore_finance_book_value(self, asset, debit: float) -> None: + """Credit the depreciation amount back to the relevant finance book when no schedule matched.""" + fb_idx = 1 + if self.doc.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.doc.finance_book: + fb_idx = fb_row.idx + break + + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation += debit + fb_row.db_update() + + def _block_scrap_journal_cancel(self, d) -> None: + """Prevent cancelling a plain Journal Entry that is an asset's scrap voucher.""" + journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap") + if journal_entry_for_scrap == self.doc.name: + frappe.throw( + _("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.") + ) + + def unlink_asset_adjustment_entry(self) -> None: + """Detach this entry from any Asset Value Adjustment that referenced it.""" + AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment") + ( + frappe.qb.update(AssetValueAdjustment) + .set(AssetValueAdjustment.journal_entry, None) + .where(AssetValueAdjustment.journal_entry == self.doc.name) + ).run() diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 424bf9bac87..b2daddc85df 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1391,6 +1391,46 @@ class TestDepreciationBasics(AssetSetup): self.assertFalse(depr_schedule[1].journal_entry) self.assertFalse(depr_schedule[2].journal_entry) + def test_depr_schedule_link_matches_at_currency_precision(self): + """A Depreciation Schedule row whose amount carries more decimals than the + company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be + matched and stamped with the Journal Entry. Comparing at exact float + equality left the link NULL, so the scheduler treated the row as unposted + and created a duplicate Journal Entry on every run. Regression test for + AssetService.update_journal_entry_link_on_depr_schedule().""" + from unittest.mock import MagicMock, patch + + from erpnext.accounts.doctype.journal_entry.services import asset_service as asset_service_module + from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService + + posting_date = getdate("2021-06-01") + je = frappe._dict(name="JE-DEPR-TEST", finance_book=None, posting_date=posting_date) + service = AssetService(je) + + # JE debit is stored at company currency precision (2 dp)... + je_row = MagicMock() + je_row.debit = 25701.20 + je_row.precision.return_value = 2 + + # ...while the schedule row amount carries a third decimal. + schedule_row = frappe._dict( + name="DS-ROW-1", + schedule_date=posting_date, + journal_entry=None, + depreciation_amount=25701.202, + ) + asset = frappe._dict(name="ASSET-TEST") + + with ( + patch.object(asset_service_module, "get_depr_schedule", return_value=[schedule_row]), + patch.object(frappe.db, "set_value") as mock_set_value, + ): + service.update_journal_entry_link_on_depr_schedule(asset, je_row) + + mock_set_value.assert_called_once_with( + "Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST" + ) + def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self): """Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account.""" From d3a8e91cda03d64e60bd72c70a8685be655eff8c Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 9 Jul 2026 15:25:01 +0530 Subject: [PATCH 02/33] fix: apply precision fix inline for v16-hotfix, drop develop-only asset_service refactor --- .../doctype/journal_entry/journal_entry.py | 3 +- .../journal_entry/services/asset_service.py | 201 ------------------ erpnext/assets/doctype/asset/test_asset.py | 15 +- 3 files changed, 10 insertions(+), 209 deletions(-) delete mode 100644 erpnext/accounts/doctype/journal_entry/services/asset_service.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 4934a0788c1..facb15f15f7 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -417,11 +417,12 @@ class JournalEntry(AccountsController): def update_journal_entry_link_on_depr_schedule(self, asset, je_row): depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book) + precision = je_row.precision("debit") for d in depr_schedule or []: if ( d.schedule_date == self.posting_date and not d.journal_entry - and d.depreciation_amount == flt(je_row.debit) + and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision) ): frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name) diff --git a/erpnext/accounts/doctype/journal_entry/services/asset_service.py b/erpnext/accounts/doctype/journal_entry/services/asset_service.py deleted file mode 100644 index c0b954233af..00000000000 --- a/erpnext/accounts/doctype/journal_entry/services/asset_service.py +++ /dev/null @@ -1,201 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe -from frappe import _ -from frappe.utils import flt - -from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( - get_depr_schedule, -) - - -class AssetService: - """Keeps Assets in sync with the Journal Entries that depreciate, dispose or - adjust them. - - On submit of a Depreciation Entry it reduces the asset value and links the - depreciation schedule; on submit of an Asset Disposal it marks the asset - disposed. On cancel it reverses those links. It also guards cancellation of - Journal Entries tied to asset scrapping or value adjustments. - """ - - def __init__(self, doc) -> None: - self.doc = doc - - def validate_depr_account_and_depr_entry_voucher_type(self) -> None: - """A depreciation account requires voucher type Depreciation Entry and an Expense account.""" - for d in self.doc.get("accounts"): - if d.account_type == "Depreciation": - if self.doc.voucher_type != "Depreciation Entry": - frappe.throw( - _("Journal Entry type should be set as Depreciation Entry for asset depreciation") - ) - - if frappe.get_cached_value("Account", d.account, "root_type") != "Expense": - frappe.throw(_("Account {0} should be of type Expense").format(d.account)) - - def has_asset_adjustment_entry(self) -> None: - """Block cancellation while a submitted Asset Value Adjustment links to this entry.""" - if self.doc.flags.get("via_asset_value_adjustment"): - return - - asset_value_adjustment = frappe.db.get_value( - "Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name" - ) - if asset_value_adjustment: - frappe.throw( - _( - "Cannot cancel this document as it is linked with the submitted Asset Value Adjustment {0}. Please cancel the Asset Value Adjustment to continue." - ).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment)) - ) - - def update_asset_value(self) -> None: - """Apply the entry's effect to its linked assets on submit (depreciation or disposal).""" - self.update_asset_on_depreciation() - self.update_asset_on_disposal() - - def update_asset_on_depreciation(self) -> None: - """Reduce each depreciated asset's value and link the depreciation schedule row.""" - if self.doc.voucher_type != "Depreciation Entry": - return - - for d in self.doc.get("accounts"): - if ( - d.reference_type == "Asset" - and d.reference_name - and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" - and d.debit - ): - asset = frappe.get_cached_doc("Asset", d.reference_name) - - if asset.calculate_depreciation: - self.update_journal_entry_link_on_depr_schedule(asset, d) - self.update_value_after_depreciation(asset, d.debit) - - asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit) - asset.set_status() - asset.set_total_booked_depreciations() - - def update_value_after_depreciation(self, asset, depr_amount: float) -> None: - """Subtract the depreciation amount from the asset's relevant finance book.""" - fb_idx = 1 - if self.doc.finance_book: - for fb_row in asset.get("finance_books"): - if fb_row.finance_book == self.doc.finance_book: - fb_idx = fb_row.idx - break - fb_row = asset.get("finance_books")[fb_idx - 1] - fb_row.value_after_depreciation -= depr_amount - frappe.db.set_value( - "Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation - ) - - def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None: - """Stamp this entry onto the matching (date + amount) depreciation schedule row.""" - depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book) - precision = je_row.precision("debit") - for d in depr_schedule or []: - if ( - d.schedule_date == self.doc.posting_date - and not d.journal_entry - and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision) - ): - frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name) - - def update_asset_on_disposal(self) -> None: - """Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal.""" - if self.doc.voucher_type == "Asset Disposal": - disposed_assets = [] - for d in self.doc.get("accounts"): - if ( - d.reference_type == "Asset" - and d.reference_name - and d.reference_name not in disposed_assets - ): - frappe.db.set_value( - "Asset", - d.reference_name, - { - "disposal_date": self.doc.posting_date, - "journal_entry_for_scrap": self.doc.name, - }, - ) - asset_doc = frappe.get_doc("Asset", d.reference_name) - asset_doc.set_status() - disposed_assets.append(d.reference_name) - - def unlink_asset_reference(self) -> None: - """On cancel, reverse depreciation links and block cancelling an asset-scrap entry.""" - for d in self.doc.get("accounts"): - if self._is_depreciation_asset_row(d): - self._reverse_asset_depreciation(d) - elif ( - self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name - ): - self._block_scrap_journal_cancel(d) - - def _is_depreciation_asset_row(self, d) -> bool: - return bool( - self.doc.voucher_type == "Depreciation Entry" - and d.reference_type == "Asset" - and d.reference_name - and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" - and d.debit - ) - - def _reverse_asset_depreciation(self, d) -> None: - """Add the depreciation amount back to the asset and unlink its schedule row.""" - asset = frappe.get_doc("Asset", d.reference_name) - - if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit): - self._restore_finance_book_value(asset, d.debit) - - asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit) - asset.set_status() - asset.set_total_booked_depreciations() - - def _restore_scheduled_depreciation(self, asset, debit: float) -> bool: - """Unlink this entry from the depreciation schedule and credit back its finance book. - - Returns True if a matching scheduled depreciation was found. - """ - for fb_row in asset.get("finance_books"): - depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book) - for s in depr_schedule or []: - if s.journal_entry == self.doc.name: - s.db_set("journal_entry", None) - fb_row.value_after_depreciation += debit - fb_row.db_update() - return True - return False - - def _restore_finance_book_value(self, asset, debit: float) -> None: - """Credit the depreciation amount back to the relevant finance book when no schedule matched.""" - fb_idx = 1 - if self.doc.finance_book: - for fb_row in asset.get("finance_books"): - if fb_row.finance_book == self.doc.finance_book: - fb_idx = fb_row.idx - break - - fb_row = asset.get("finance_books")[fb_idx - 1] - fb_row.value_after_depreciation += debit - fb_row.db_update() - - def _block_scrap_journal_cancel(self, d) -> None: - """Prevent cancelling a plain Journal Entry that is an asset's scrap voucher.""" - journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap") - if journal_entry_for_scrap == self.doc.name: - frappe.throw( - _("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.") - ) - - def unlink_asset_adjustment_entry(self) -> None: - """Detach this entry from any Asset Value Adjustment that referenced it.""" - AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment") - ( - frappe.qb.update(AssetValueAdjustment) - .set(AssetValueAdjustment.journal_entry, None) - .where(AssetValueAdjustment.journal_entry == self.doc.name) - ).run() diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index b2daddc85df..1b808cedcc1 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1397,15 +1397,16 @@ class TestDepreciationBasics(AssetSetup): matched and stamped with the Journal Entry. Comparing at exact float equality left the link NULL, so the scheduler treated the row as unposted and created a duplicate Journal Entry on every run. Regression test for - AssetService.update_journal_entry_link_on_depr_schedule().""" + JournalEntry.update_journal_entry_link_on_depr_schedule().""" from unittest.mock import MagicMock, patch - from erpnext.accounts.doctype.journal_entry.services import asset_service as asset_service_module - from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService + from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module posting_date = getdate("2021-06-01") - je = frappe._dict(name="JE-DEPR-TEST", finance_book=None, posting_date=posting_date) - service = AssetService(je) + je = frappe.new_doc("Journal Entry") + je.name = "JE-DEPR-TEST" + je.finance_book = None + je.posting_date = posting_date # JE debit is stored at company currency precision (2 dp)... je_row = MagicMock() @@ -1422,10 +1423,10 @@ class TestDepreciationBasics(AssetSetup): asset = frappe._dict(name="ASSET-TEST") with ( - patch.object(asset_service_module, "get_depr_schedule", return_value=[schedule_row]), + patch.object(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]), patch.object(frappe.db, "set_value") as mock_set_value, ): - service.update_journal_entry_link_on_depr_schedule(asset, je_row) + je.update_journal_entry_link_on_depr_schedule(asset, je_row) mock_set_value.assert_called_once_with( "Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST" From 8658039e9a348e0560f3496cdbe24b8ac2f7c1bf Mon Sep 17 00:00:00 2001 From: S Sakthivel Murugan Date: Tue, 26 May 2026 07:36:42 +0530 Subject: [PATCH 03/33] fix(asset): allow asset repair creation for fully depreciated assets (cherry picked from commit c7774a95e5179831b3c3d7bbe4ea55b1d4b30c5f) --- erpnext/assets/doctype/asset/asset.js | 10 +++++++++- erpnext/assets/doctype/asset_repair/asset_repair.js | 9 +++++++++ erpnext/assets/doctype/asset_repair/asset_repair.json | 4 ++-- erpnext/assets/doctype/asset_repair/asset_repair.py | 5 ++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 65f43f36a1f..1a4ae3625f0 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -147,7 +147,15 @@ frappe.ui.form.on("Asset", { __("Actions") ); } - + if (frm.doc.status === "Fully Depreciated") { + frm.add_custom_button( + __("Asset Repair"), + function () { + frm.trigger("create_asset_repair"); + }, + __("Actions") + ); + } frm.add_custom_button( __("Split Asset"), function () { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 4d9ef28ceae..2920ff7e381 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -84,6 +84,15 @@ frappe.ui.form.on("Asset Repair", { }; }; } + if (frm.doc.asset) { + frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => { + frm.set_df_property( + "capitalize_repair_cost", + "read_only", + message && message.status === "Fully Depreciated" + ); + }); + } }, show_general_ledger: function (frm) { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 4fc9a31b875..a1081ecb188 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -130,7 +130,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", - "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", "options": "Asset", "reqd": 1 }, @@ -275,7 +275,7 @@ "link_fieldname": "asset_repair" } ], - "modified": "2026-02-06 14:57:54.257572", + "modified": "2026-06-20 15:43:54.943335", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 476b0187bf1..4fc8981d200 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -70,12 +70,15 @@ class AssetRepair(AccountsController): self.check_repair_status() def validate_asset(self): - if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + if self.asset_doc.status in ("Sold", "Scrapped"): frappe.throw( _("Asset {0} is in {1} status and cannot be repaired.").format( get_link_to_form("Asset", self.asset), self.asset_doc.status ) ) + if self.asset_doc.get_status() == "Fully Depreciated": + self.capitalize_repair_cost = 0 + self.increase_in_asset_life = 0 def validate_dates(self): if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)): From 7b543142a2450bfd10d1fcbbf55dc0a2b49a7680 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 12 Jul 2026 21:10:32 +0530 Subject: [PATCH 04/33] fix: guard company logo lookup in default letterheads (cherry picked from commit 23c09fe0f3c4bf598707278c12313698fbe0fef5) --- .../letter_head/company_letterhead/company_letterhead.json | 4 ++-- .../company_letterhead___grey/company_letterhead___grey.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json index 28b60e313c4..fbb83c7151f 100644 --- a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json +++ b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "creation": "2026-05-15 15:21:48.255627", "custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead", - "modified": "2026-06-24 17:49:52.350750", + "modified": "2026-07-12 21:11:44.765083", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead", diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json index 67c03298195..323b8574578 100644 --- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json +++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", "creation": "2026-05-15 15:21:48.373815", "custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead - Grey", - "modified": "2026-06-24 18:23:05.120521", + "modified": "2026-07-12 21:11:44.765083", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead - Grey", From 0d80fef3bfe433e0a5b23a720793d2c4b45ea54a Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 12 Jul 2026 22:03:32 +0530 Subject: [PATCH 05/33] fix: set explicit table and logo widths in grey letterhead (cherry picked from commit e39ca72997f36d58c1b5ec761f451ba15cac29c2) --- .../company_letterhead___grey/company_letterhead___grey.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json index 323b8574578..dd9035197a2 100644 --- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json +++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", "creation": "2026-05-15 15:21:48.373815", "custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead - Grey", - "modified": "2026-07-12 21:11:44.765083", + "modified": "2026-07-12 22:03:24.525672", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead - Grey", From 703e9a728c9e3f715df46ec2b5632e7039f75cdd Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Sun, 5 Jul 2026 18:06:43 +0530 Subject: [PATCH 06/33] fix(budget-variance): correct month shift in comparison chart The Budget Variance Report chart plotted the actual expense one month earlier than the table (e.g. July actual shown under June). build_comparison_chart_data() collected budget columns using fieldname.startswith("budget_"). The dimension column "budget_against" also matches that prefix, so it was added as an extra leading entry to budget_fields and labels, while actual_fields had no such leading entry. This shifted every actual value one position ahead of its label. Skip the "budget_against" dimension column so budget/actual values and labels stay aligned per month. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 48418eadb04c6687c938a13aa1557d5bd6bb4051) --- .../budget_variance_report/budget_variance_report.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index fb7f8adac70..ba6c9369044 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -428,6 +428,11 @@ def build_comparison_chart_data(filters, columns, data): if not fieldname: continue + # skip the dimension column ("budget_against"), it only matches the + # "budget_" prefix by coincidence and would shift the actual values by one + if fieldname == "budget_against": + continue + if fieldname.startswith("budget_"): budget_fields.append(fieldname) elif fieldname.startswith("actual_"): @@ -439,7 +444,7 @@ def build_comparison_chart_data(filters, columns, data): labels = [ col["label"].replace("Budget", "").strip() for col in columns - if col.get("fieldname", "").startswith("budget_") + if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against" ] budget_values = [0] * len(budget_fields) From ec782ee20df7889b1a9023b0b9dcfeab7e8d72d6 Mon Sep 17 00:00:00 2001 From: SowmyaArunachalam Date: Mon, 29 Jun 2026 21:48:38 +0530 Subject: [PATCH 07/33] fix(journal-entry): fetch outstanding on foreign currency (cherry picked from commit 07f641c48cd2c4d61c5106081b3769b8c82687cc) --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 9293e7fb0b8..c6e8ff410c7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2814,9 +2814,7 @@ def get_reference_details( exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) else: exchange_rate = 1 - outstanding_amount, total_amount = get_outstanding_on_journal_entry( - reference_name, party_type, party - ) + outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party) elif reference_doctype == "Payment Entry": if reverse_payment_details := frappe.db.get_all( From 5281d538ce0227ceb544f3b07c15882e24f068c3 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 13 Jul 2026 12:56:31 +0530 Subject: [PATCH 08/33] Merge pull request #56817 from Soham-ambibuzz/philipinnes_localization_coa_v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: restructure Philippines chart of accounts with amortization sup… (cherry picked from commit 33abc53d7a7ed7b799ad6b6e539e5a493fbb9aec) --- .../verified/philippines.json | 109 +++++++++++++++--- 1 file changed, 92 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json index 38ee277c5d6..312c3832f54 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json @@ -22,12 +22,12 @@ "account_type": "Cash" }, "Petty Cash Fund": { - "account_number": "1200", + "account_number": "1110", "is_group": 1, "root_type": "Asset", "account_type": "Cash", "Petty Cash Fund": { - "account_number": "1201", + "account_number": "1111", "is_group": 0, "root_type": "Asset", "account_type": "Cash" @@ -35,10 +35,16 @@ } }, "Bank Accounts": { - "account_number": "1102", + "account_number": "1200", "is_group": 1, "root_type": "Asset", - "account_type": "Bank" + "account_type": "Bank", + "Cash in Bank - Checking Account": { + "account_number": "1201", + "is_group": 0, + "root_type": "Asset", + "account_type": "Bank" + } }, "Advances to Officers & Employees": { "account_number": "1290", @@ -104,25 +110,20 @@ "account_number": "1511", "is_group": 0, "root_type": "Asset" - }, - "Factory Overhead Variance": { - "account_number": "1512", - "is_group": 0, - "root_type": "Asset" } }, "Finished Goods": { - "account_number": "1520", + "account_number": "1540", "is_group": 1, "root_type": "Asset", "Finished Goods Inventory": { - "account_number": "1531", + "account_number": "1541", "is_group": 0, "root_type": "Asset", "account_type": "Stock" }, "Inventory in Transit": { - "account_number": "1532", + "account_number": "1542", "is_group": 0, "root_type": "Asset", "account_type": "Stock Adjustment" @@ -268,7 +269,7 @@ "root_type": "Asset" } }, - "System Development": { + "Intangible Assets": { "account_number": "1940", "is_group": 1, "root_type": "Asset", @@ -277,6 +278,17 @@ "is_group": 0, "root_type": "Asset" } + }, + "Accumulated Amortization - Intangible Assets": { + "account_number": "1950", + "is_group": 1, + "root_type": "Asset", + "Accum Amortization - System Development": { + "account_number": "1951", + "is_group": 0, + "root_type": "Asset", + "account_type": "Accumulated Depreciation" + } } } }, @@ -562,6 +574,28 @@ "is_group": 0, "root_type": "Income" } + }, + "Exchange Gain": { + "account_number": "6030", + "is_group": 1, + "root_type": "Income", + "Exchange Gain - Detail": { + "account_number": "6031", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } + }, + "Gain on Asset Disposal": { + "account_number": "6040", + "is_group": 1, + "root_type": "Income", + "Gain on Asset Disposal - Detail": { + "account_number": "6041", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } } } }, @@ -574,7 +608,7 @@ "is_group": 1, "root_type": "Expense", "Cost of Goods Sold": { - "account_number": "5010", + "account_number": "5002", "is_group": 0, "root_type": "Expense", "account_type": "Cost of Goods Sold" @@ -827,20 +861,61 @@ "root_type": "Expense" } }, - "Stock Adjustment": { + "Other Expenses": { "account_number": "5200", + "is_group": 1, + "root_type": "Expense", + "Bank Charges": { + "account_number": "5201", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Interest Expenses Bank": { + "account_number": "5202", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Write Off": { + "account_number": "5203", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Exchange Loss": { + "account_number": "5204", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Loss on Asset Disposal": { + "account_number": "5205", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + } + }, + "Provision For Income Tax": { + "account_number": "5300", + "is_group": 0, + "root_type": "Expense", + "account_type": "Tax" + }, + "Stock Adjustment": { + "account_number": "5400", "is_group": 0, "root_type": "Expense", "account_type": "Stock Adjustment" }, "Round Off": { - "account_number": "5300", + "account_number": "5500", "is_group": 0, "root_type": "Expense", "account_type": "Round Off" }, "Expenses Included In Valuation": { - "account_number": "5400", + "account_number": "5600", "is_group": 0, "root_type": "Expense", "account_type": "Expenses Included In Valuation" From bf1b7f2bea3ade502aaa6b929529abefee214872 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:11:12 +0000 Subject: [PATCH 09/33] feat: weekly auto-repost of incorrect stock valuation entries (backport #56637) (#56700) * feat: weekly auto-repost of incorrect stock valuation entries (#56637) (cherry picked from commit adae0bd7329796d663959e94c258590c94f2f044) # Conflicts: # erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py * chore: fix conflicts Removed merge conflict markers and cleaned up code. --------- Co-authored-by: rohitwaghchaure --- erpnext/hooks.py | 1 + .../stock_reposting_settings.json | 18 +- .../stock_reposting_settings.py | 214 +++++++++++++++++- .../test_stock_reposting_settings.py | 123 ++++++++++ .../stock_and_account_value_comparison.py | 42 ++-- 5 files changed, 380 insertions(+), 18 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 65462439c34..d614d8b6356 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -492,6 +492,7 @@ scheduler_events = { ], "weekly": [ "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", + "erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries", ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index ed48522d770..eaab9db4786 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -22,7 +22,9 @@ "column_break_itvd", "enable_separate_reposting_for_gl", "errors_notification_section", - "notify_reposting_error_to_role" + "notify_reposting_error_to_role", + "auto_reposting_section", + "repost_incorrect_valuation_entries" ], "fields": [ { @@ -113,12 +115,24 @@ "fieldname": "do_not_fetch_incoming_rate_from_serial_no", "fieldtype": "Check", "label": "Do not fetch incoming rate from Serial No" + }, + { + "fieldname": "auto_reposting_section", + "fieldtype": "Section Break", + "label": "Auto Reposting of Incorrect Valuation" + }, + { + "default": "0", + "description": "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them.", + "fieldname": "repost_incorrect_valuation_entries", + "fieldtype": "Check", + "label": "Auto Repost Incorrect Valuation Entries (Weekly)" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-05-15 12:59:34.392491", + "modified": "2026-07-01 14:41:51.499245", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 9164f8498cb..f703a694dad 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -4,7 +4,16 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours +from frappe.utils import ( + add_to_date, + get_datetime, + get_link_to_form, + get_time_str, + getdate, + time_diff_in_hours, + today, +) +from frappe.utils.user import get_users_with_role class StockRepostingSettings(Document): @@ -27,6 +36,7 @@ class StockRepostingSettings(Document): ] no_of_parallel_reposting: DF.Int notify_reposting_error_to_role: DF.Link | None + repost_incorrect_valuation_entries: DF.Check start_time: DF.Time | None # end: auto-generated types @@ -117,3 +127,205 @@ def create_repost_item_valuation(item_code, warehouse, posting_date): "status": "Queued", } ).submit() + + +def repost_incorrect_valuation_entries(): + """Weekly scheduler entry point. + + When `repost_incorrect_valuation_entries` is enabled in Stock Reposting Settings, scan each + company's Stock Ledger Variance and Stock and Account Value Comparison reports for incorrect stock + valuation in the current financial year and auto-create reposts to correct them. Journal Entries are + never reposted, and warehouses pointing at a non-'Stock' account are reported to System Managers + instead. Disabled by default; does nothing unless explicitly turned on.""" + if not frappe.db.get_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries"): + return + + for company in frappe.get_all("Company", pluck="name"): + # The Stock Ledger Variance scan runs the invariant check for every item-warehouse, so process + # each company as its own long-running background job rather than blocking the weekly scheduler. + frappe.enqueue( + repost_incorrect_valuation_entries_for_company, + queue="long", + job_id=f"repost_incorrect_valuation::{company}", + deduplicate=True, + company=company, + ) + + +def repost_incorrect_valuation_entries_for_company(company): + """Detect and repost incorrect stock valuation for a single company, limited to the current + financial year, using two reports: + + 1. Stock Ledger Variance - item-warehouses whose ledger valuation is internally inconsistent + (typically a wrong previous-SLE pick). Fixed with an Item & Warehouse repost. + 2. Stock and Account Value Comparison - vouchers whose stock value does not match the accounting + ledger. Reposted via the report's own logic (Journal Entries are excluded - ERPNext does not + repost them). If a voucher's warehouse points at an account that is not of type 'Stock', + reposting can never clear the difference, so System Managers are notified instead.""" + from erpnext.accounts.utils import get_fiscal_year + + fy_start_date = get_fiscal_year(today(), company=company)[1] + + _repost_stock_ledger_variance(company, fy_start_date) + _repost_stock_account_value_comparison(company, fy_start_date) + + +def _repost_stock_ledger_variance(company, fy_start_date): + from erpnext.stock.report.stock_ledger_variance.stock_ledger_variance import ( + get_data as get_stock_ledger_variance, + ) + + created = [] + for row in get_stock_ledger_variance({"company": company}) or []: + row = frappe._dict(row) + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # Avoid piling up duplicate reposts week over week for the same item-warehouse. + if has_pending_valuation_repost(company, row.item_code, row.warehouse): + continue + + create_repost_item_valuation(row.item_code, row.warehouse, row.posting_date) + created.append(row) + + if created: + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(created)} incorrect-valuation item-warehouse(s) for {company}: " + + ", ".join(f"{d.item_code} @ {d.warehouse} from {d.posting_date}" for d in created) + ) + + return created + + +def _repost_stock_account_value_comparison(company, fy_start_date): + import erpnext + + # Stock vs accounting values only exist under perpetual inventory. + if not erpnext.is_perpetual_inventory_enabled(company): + return + + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + ) + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + get_data as get_value_comparison, + ) + + to_repost = [] + misconfigured = [] # (voucher_type, voucher_no, warehouse, account) + + # Scope the report's DB scan to the current financial year (see get_data) instead of loading every + # voucher ever posted and filtering in Python afterwards. + comparison_filters = frappe._dict(company=company, from_date=fy_start_date, as_on_date=today()) + for row in get_value_comparison(comparison_filters) or []: + row = frappe._dict(row) + + # ERPNext does not repost Journal Entries (GL-only postings have no stock ledger to repost). + if row.voucher_type == "Journal Entry": + continue + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # If a warehouse on this voucher is mapped to an account that is not of type 'Stock', reposting + # can never reconcile stock vs accounting value - flag it for a human instead of reposting. + # Only flag accounts with a concrete, non-'Stock' type. An unset/blank account_type is treated as + # "unknown" - reposting may well reconcile it - so we don't skip the voucher or email a false alarm. + wrong_accounts = [ + (warehouse, account) + for warehouse, account, account_type in get_voucher_warehouse_accounts(row.voucher_no, company) + if account_type and account_type != "Stock" + ] + if wrong_accounts: + misconfigured.extend( + (row.voucher_type, row.voucher_no, warehouse, account) + for warehouse, account in wrong_accounts + ) + continue + + to_repost.append(row) + + if to_repost: + # create_reposting_entries reposts Purchase Receipt/Invoice transaction-wise and everything else + # item-warehouse-wise, and de-duplicates against existing reposts. + create_reposting_entries(to_repost, company) + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(to_repost)} stock/account value mismatch voucher(s) for {company}." + ) + + if misconfigured: + notify_incorrect_stock_account(company, misconfigured) + + +def get_voucher_warehouse_accounts(voucher_no, company): + """Return (warehouse, account, account_type) for each distinct warehouse the voucher posted stock + into, so the caller can verify the account is a 'Stock' asset account.""" + warehouses = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": voucher_no, "is_cancelled": 0}, + pluck="warehouse", + distinct=True, + ) + + rows = [] + for warehouse in {w for w in warehouses if w}: + account = frappe.get_cached_value("Warehouse", warehouse, "account") or frappe.get_cached_value( + "Company", company, "default_inventory_account" + ) + account_type = frappe.get_cached_value("Account", account, "account_type") if account else None + rows.append((warehouse, account, account_type)) + + return rows + + +def notify_incorrect_stock_account(company, misconfigured): + """Email System Managers about warehouse accounts that are not of type 'Stock', which keep stock + and accounting values from reconciling even after reposting.""" + recipients = get_users_with_role("System Manager") + if not recipients: + return + + items = "".join( + "
  • {} {} → {}: {}
  • ".format( + voucher_type, + get_link_to_form(voucher_type, voucher_no), + warehouse, + account or _("No account set"), + ) + for voucher_type, voucher_no, warehouse, account in misconfigured + ) + + subject = _("Incorrect Stock Asset Account in {0}").format(company) + message = ( + _("Stock and accounting values could not be reconciled by reposting for {0}.").format( + frappe.bold(company) + ) + + "

    " + + _( + "The warehouse account(s) below are not of type 'Stock'. Please set a correct Stock asset account on the warehouse (Account Type must be 'Stock'):" + ) + + f"
      {items}
    " + ) + + frappe.sendmail(recipients=recipients, subject=subject, message=message) + + +def has_pending_valuation_repost(company, item_code, warehouse): + """True if an Item & Warehouse repost for this item-warehouse is already queued or running, so the + weekly job does not stack duplicate reposts.""" + return bool( + frappe.db.exists( + "Repost Item Valuation", + { + "company": company, + "item_code": item_code, + "warehouse": warehouse, + "based_on": "Item and Warehouse", + "status": ("in", ["Queued", "In Progress"]), + "docstatus": 1, + }, + ) + ) diff --git a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py index ff89ad34ab6..00f0b67984e 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py @@ -1,14 +1,137 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import unittest +from unittest.mock import patch import frappe +from frappe.utils import add_days, getdate, today +from erpnext.accounts.utils import get_fiscal_year +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import get_recipients +from erpnext.stock.doctype.stock_reposting_settings import stock_reposting_settings as srs from erpnext.tests.utils import ERPNextTestSuite +TEST_COMPANY = "_Test Company" +TEST_WAREHOUSE = "_Test Warehouse - _TC" + class TestStockRepostingSettings(ERPNextTestSuite): + def tearDown(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + super().tearDown() + + def test_auto_repost_disabled_does_nothing(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + enqueue.assert_not_called() + + def test_auto_repost_enabled_enqueues_per_company(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 1) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + self.assertTrue(enqueue.called) + # one job per company + self.assertEqual(enqueue.call_count, frappe.db.count("Company")) + + def test_reposts_only_current_financial_year_entries(self): + item = make_item().name + fy_start_date = get_fiscal_year(today(), company=TEST_COMPANY)[1] + + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + prior_fy_row = { + "item_code": item, + "warehouse": TEST_WAREHOUSE, + "posting_date": add_days(fy_start_date, -1), + } + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row, prior_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + # Only the current-FY entry is reposted; the prior-FY one is ignored. + self.assertEqual(calls, [(item, TEST_WAREHOUSE, str(today()))]) + + def test_skips_when_repost_already_pending(self): + item = make_item().name + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object(srs, "has_pending_valuation_repost", return_value=True), + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + self.assertEqual(calls, []) + + def test_value_comparison_excludes_je_and_flags_wrong_account(self): + fy_start = getdate(today()) + + rows = [ + # correct stock account, current FY -> reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-OK", "posting_date": today()}, + # Journal Entry -> never reposted + {"voucher_type": "Journal Entry", "voucher_no": "JE-1", "posting_date": today()}, + # warehouse account not of type "Stock" -> notify, not reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-BADACC", "posting_date": today()}, + # warehouse account with an unset account_type -> unknown, repost (not a false alarm) + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-NOTYPE", "posting_date": today()}, + # prior financial year -> ignored + { + "voucher_type": "Purchase Receipt", + "voucher_no": "PR-OLD", + "posting_date": add_days(fy_start, -1), + }, + ] + accounts = { + "PR-OK": [("WH-A", "Stock A - _TC", "Stock")], + "PR-BADACC": [("WH-B", "Debtors - _TC", "Receivable")], + "PR-NOTYPE": [("WH-C", "Unclassified - _TC", None)], + "PR-OLD": [("WH-A", "Stock A - _TC", "Stock")], + } + + reposted = {} + sent = [] + comparison = ( + "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison" + ) + with ( + patch("erpnext.is_perpetual_inventory_enabled", return_value=True), + patch(f"{comparison}.get_data", return_value=rows), + patch( + f"{comparison}.create_reposting_entries", + side_effect=lambda r, c: reposted.update(rows=r, company=c), + ), + patch.object( + srs, "get_voucher_warehouse_accounts", side_effect=lambda vno, c: accounts.get(vno, []) + ), + patch.object(srs, "get_users_with_role", return_value=["sysmgr@test.com"]), + patch("frappe.sendmail", side_effect=lambda **kw: sent.append(kw)), + ): + srs._repost_stock_account_value_comparison(TEST_COMPANY, fy_start) + + # Current-FY, non-Journal-Entry vouchers are reposted: the correct-account one and the one whose + # account_type is unset (unknown is treated as "proceed", not "wrong account"). + self.assertEqual([r["voucher_no"] for r in reposted["rows"]], ["PR-OK", "PR-NOTYPE"]) + # Only the concrete wrong-account voucher triggers a System Manager notification. + self.assertEqual(len(sent), 1) + self.assertIn("PR-BADACC", sent[0]["message"]) + self.assertNotIn("PR-NOTYPE", sent[0]["message"]) + + +class TestStockRepostingSettingsNotification(ERPNextTestSuite): def test_notify_reposting_error_to_role(self): role = "Notify Reposting Role" diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 0e23561fca1..011d117e2b2 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -33,6 +33,11 @@ def get_data(report_filters): "posting_date": ("<=", report_filters.as_on_date), } + # Optional lower bound: lets callers (e.g. the weekly auto-repost job) scope the scan to the current + # fiscal year in the query itself instead of loading every voucher ever posted and filtering later. + if report_filters.get("from_date"): + filters["posting_date"] = ("between", [report_filters.from_date, report_filters.as_on_date]) + get_currency_precision() or 2 stock_ledger_entries = get_stock_ledger_data(report_filters, filters) voucher_wise_gl_data = get_gl_data(report_filters, filters) @@ -240,19 +245,26 @@ def repost_based_on_transaction(rows, company=None, entries=None): continue duplicate_vouchers.add(voucher_key) - doc = frappe.get_doc( - { - "doctype": "Repost Item Valuation", - "based_on": "Transaction", - "status": "Queued", - "voucher_type": row.get("voucher_type"), - "voucher_no": row.get("voucher_no"), - "posting_date": row.get("posting_date"), - "posting_time": row.get("posting_time"), - "company": company, - "allow_nagative_stock": 1, - "recalculate_valuation_rate": 1, - } - ).submit() + # Isolate each submit in a savepoint: an already-queued repost raises DuplicateEntryError, and on + # PostgreSQL a failed insert aborts the whole transaction, killing the rest of the loop (and the + # silent weekly job). Rolling back to the savepoint keeps prior/next reposts intact. + frappe.db.savepoint("repost_based_on_transaction") + try: + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Transaction", + "status": "Queued", + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + "posting_date": row.get("posting_date"), + "posting_time": row.get("posting_time"), + "company": company, + "allow_nagative_stock": 1, + "recalculate_valuation_rate": 1, + } + ).submit() - entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + except frappe.DuplicateEntryError: + frappe.db.rollback(save_point="repost_based_on_transaction") From 08e267271e2c9cd91e58a8bec62413315eaadb71 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 11 Jun 2026 19:40:24 +0530 Subject: [PATCH 10/33] refactor: reports on duckdb (cherry picked from commit adb768505a5d621064edf21a1645f8c0ecd1183a) --- .../report/general_ledger/general_ledger.py | 9 +++++++++ .../report/trial_balance/trial_balance.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 8670a4fd175..c9ee9784a8d 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -818,3 +818,12 @@ def get_columns(filters): columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}]) return columns + + +def execute_duckdb(filters, duckdb_conn): + print(filters) + conn = duckdb_conn + columns = get_columns(filters) + res = [] + + return columns, res diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 186a9eb71f0..2b74bce1706 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -571,3 +571,23 @@ def hide_group_accounts(data): d.update(indent=0) non_group_accounts_data.append(d) return non_group_accounts_data + + +def execute_duckdb(filters, duckdb_conn): + validate_filters(filters) + conn = duckdb_conn + data = [] + res = conn.sql( + f"select account, sum(debit), sum(credit), account_currency from \"tabGL Entry\" where company = '{filters.company}' and posting_date between '{filters.from_date}' and '{filters.to_date}' and is_opening = 'No' group by account, account_currency;" + ).fetchall() + for x in res: + data.append( + { + "account": x[0], + "debit": x[1], + "credit": x[2], + } + ) + + columns = get_columns() + return columns, data From 228418b05fda887fb4cea86dbb6a81222a0c9550 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 15 Jun 2026 12:51:44 +0530 Subject: [PATCH 11/33] feat(trial-balance): implement execute_duckdb with full parity to normal report Replaces the placeholder stub with 8 focused functions that mirror the normal execute() flow using parameterized DuckDB SQL queries: account fetch, period GL entries, opening balances (with Period Closing Voucher path), and all filters (cost center, project, finance book, accounting dimensions). Reuses existing pure-Python processing functions unchanged. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit b1c8e2cb5cf75dc4f3cf6a7e6a71534cb805069e) --- .../report/trial_balance/trial_balance.py | 284 +++++++++++++++++- 1 file changed, 270 insertions(+), 14 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 2b74bce1706..74d1700f623 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -575,19 +575,275 @@ def hide_group_accounts(data): def execute_duckdb(filters, duckdb_conn): validate_filters(filters) - conn = duckdb_conn - data = [] - res = conn.sql( - f"select account, sum(debit), sum(credit), account_currency from \"tabGL Entry\" where company = '{filters.company}' and posting_date between '{filters.from_date}' and '{filters.to_date}' and is_opening = 'No' group by account, account_currency;" - ).fetchall() - for x in res: - data.append( - { - "account": x[0], - "debit": x[1], - "credit": x[2], - } - ) - columns = get_columns() + data = get_data_duckdb(filters, duckdb_conn) return columns, data + + +def get_data_duckdb(filters, conn): + accounts = get_accounts_duckdb(conn, filters.company) + if not accounts: + return None + + company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + + gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) + opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening) + + calculate_values( + accounts, + gl_entries_by_account, + opening_balances, + filters.get("show_net_values"), + ignore_is_opening=ignore_is_opening, + ) + accumulate_values_into_parents(accounts, accounts_by_name) + + data = prepare_data(accounts, filters, parent_children_map, company_currency) + data = filter_out_zero_value_rows( + data, parent_children_map, show_zero_values=filters.get("show_zero_values") + ) + + return data + + +def get_accounts_duckdb(conn, company): + rows = conn.execute( + """SELECT name, account_number, parent_account, account_name, root_type, + report_type, is_group, lft, rgt + FROM "tabAccount" WHERE company = ? ORDER BY lft""", + [company], + ).fetchall() + cols = [ + "name", + "account_number", + "parent_account", + "account_name", + "root_type", + "report_type", + "is_group", + "lft", + "rgt", + ] + return [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + +def _build_common_gl_filters(filters): + """Returns (sql_fragments, params) for filters shared across all GL/ACB queries.""" + sql = [] + params = [] + + if filters.get("cost_center"): + cost_centers = get_cost_centers_with_children(filters.get("cost_center")) + placeholders = ", ".join(["?" for _ in cost_centers]) + sql.append(f"AND cost_center IN ({placeholders})") + params.extend(cost_centers) + + if filters.get("project"): + proj_list = filters.project if isinstance(filters.project, list) else [filters.project] + placeholders = ", ".join(["?" for _ in proj_list]) + sql.append(f"AND project IN ({placeholders})") + params.extend(proj_list) + + if frappe.db.count("Finance Book"): + company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") + if filters.get("include_default_book_entries"): + if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] + else: + fb_list = [cstr(filters.get("finance_book")), ""] + placeholders = ", ".join(["?" for _ in fb_list]) + sql.append(f"AND (finance_book IN ({placeholders}) OR finance_book IS NULL)") + params.extend(fb_list) + + accounting_dimensions = get_accounting_dimensions(as_list=False) + for dimension in accounting_dimensions: + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + dim_vals = filters[dimension.fieldname] + if not isinstance(dim_vals, list): + dim_vals = [dim_vals] + placeholders = ", ".join(["?" for _ in dim_vals]) + sql.append(f"AND {dimension.fieldname} IN ({placeholders})") + params.extend(dim_vals) + + return sql, params + + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + ignore_closing_entries = not flt(filters.get("with_period_closing_entry_for_current_period")) + common_sql, common_params = _build_common_gl_filters(filters) + + sql_parts = [ + "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", + " SUM(debit_in_account_currency) AS debit_in_account_currency,", + " SUM(credit_in_account_currency) AS credit_in_account_currency,", + " account_currency", + 'FROM "tabGL Entry"', + "WHERE company = ?", + " AND is_cancelled = 0", + " AND posting_date >= ?", + " AND posting_date <= ?", + ] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + sql_parts.append(" AND is_opening = 'No'") + + if ignore_closing_entries: + sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + + sql_parts.extend(common_sql) + params.extend(common_params) + sql_parts.append("GROUP BY account, account_currency") + + rows = conn.execute("\n".join(sql_parts), params).fetchall() + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + entries = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + if filters.get("presentation_currency"): + convert_to_presentation_currency(entries, get_currency(filters)) + + gl_entries_by_account = {} + for entry in entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + return gl_entries_by_account + + +def get_opening_balances_duckdb(conn, filters, ignore_is_opening): + bs = _get_rootwise_opening_balances_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_balances_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs.update(pl) + return bs + + +def _get_rootwise_opening_balances_duckdb(conn, filters, report_type, ignore_is_opening): + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + last_period_closing_voucher = None + + if not ignore_closing_balances: + pcv = frappe.db.get_all( + "Period Closing Voucher", + filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if pcv: + last_period_closing_voucher = pcv[0] + + gle = [] + if last_period_closing_voucher: + gle = _query_opening_balance_duckdb( + conn, + "Account Closing Balance", + filters, + report_type, + ignore_is_opening, + period_closing_voucher=last_period_closing_voucher.name, + ) + if getdate(last_period_closing_voucher.period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_period_closing_voucher.period_end_date, 1) + gle += _query_opening_balance_duckdb( + conn, + "GL Entry", + filters, + report_type, + ignore_is_opening, + start_date=start_date, + ) + else: + gle = _query_opening_balance_duckdb(conn, "GL Entry", filters, report_type, ignore_is_opening) + + opening = frappe._dict() + for d in gle: + opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) + opening[d.account]["opening_debit"] += flt(d.debit) + opening[d.account]["opening_credit"] += flt(d.credit) + + return opening + + +def _query_opening_balance_duckdb( + conn, doctype, filters, report_type, ignore_is_opening, period_closing_voucher=None, start_date=None +): + table = f'"tab{doctype}"' + common_sql, common_params = _build_common_gl_filters(filters) + + sql_parts = [ + "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", + " SUM(debit_in_account_currency) AS debit_in_account_currency,", + " SUM(credit_in_account_currency) AS credit_in_account_currency,", + " account_currency", + f"FROM {table}", + "WHERE company = ?", + ' AND account IN (SELECT name FROM "tabAccount" WHERE report_type = ?)', + ] + params = [filters.company, report_type] + + if doctype == "GL Entry": + sql_parts.append(" AND is_cancelled = 0") + + if start_date: + sql_parts.append(" AND posting_date >= ?") + sql_parts.append(" AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + sql_parts.append(" AND is_opening = 'No'") + else: + if not ignore_is_opening: + sql_parts.append(" AND (posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) + else: + sql_parts.append(" AND posting_date < ?") + params.append(filters.from_date) + + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + sql_parts.append(" AND posting_date >= ?") + params.append(filters.year_start_date) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + else: + sql_parts.append(" AND period_closing_voucher = ?") + params.append(period_closing_voucher) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + sql_parts.append(" AND is_period_closing_voucher_entry = 0") + + sql_parts.extend(common_sql) + params.extend(common_params) + sql_parts.append("GROUP BY account, account_currency") + + rows = conn.execute("\n".join(sql_parts), params).fetchall() + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + gle = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + if filters.get("presentation_currency"): + convert_to_presentation_currency(gle, get_currency(filters)) + + return gle From 98a65f752958b076db1ed028fa017f2eb6961243 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 15 Jun 2026 13:47:46 +0530 Subject: [PATCH 12/33] refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb Replaces the previous over-engineered stub with 7 short functions. Account data, Account Closing Balance, and all metadata come from frappe.db as normal; only tabGL Entry is read from the duckdb_conn. Reuses get_opening_balance() for Account Closing Balance unchanged, reuses all downstream compute helpers (calculate_values, prepare_data, etc.) unchanged. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 55862f98f4327a0d7994981beccae17170c6acd8) --- .../report/trial_balance/trial_balance.py | 257 +++++++----------- 1 file changed, 94 insertions(+), 163 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 74d1700f623..11aa3966a06 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -581,13 +581,18 @@ def execute_duckdb(filters, duckdb_conn): def get_data_duckdb(filters, conn): - accounts = get_accounts_duckdb(conn, filters.company) + # accounts and all metadata via frappe.db — only GL Entry comes from DuckDB + accounts = frappe.db.sql( + """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt + from `tabAccount` where company=%s order by lft""", + filters.company, + as_dict=True, + ) if not accounts: return None company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") - accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) @@ -603,50 +608,24 @@ def get_data_duckdb(filters, conn): accumulate_values_into_parents(accounts, accounts_by_name) data = prepare_data(accounts, filters, parent_children_map, company_currency) - data = filter_out_zero_value_rows( + return filter_out_zero_value_rows( data, parent_children_map, show_zero_values=filters.get("show_zero_values") ) - return data - -def get_accounts_duckdb(conn, company): - rows = conn.execute( - """SELECT name, account_number, parent_account, account_name, root_type, - report_type, is_group, lft, rgt - FROM "tabAccount" WHERE company = ? ORDER BY lft""", - [company], - ).fetchall() - cols = [ - "name", - "account_number", - "parent_account", - "account_name", - "root_type", - "report_type", - "is_group", - "lft", - "rgt", - ] - return [frappe._dict(zip(cols, row, strict=False)) for row in rows] - - -def _build_common_gl_filters(filters): - """Returns (sql_fragments, params) for filters shared across all GL/ACB queries.""" - sql = [] - params = [] +def _extra_gl_conditions(filters): + """Returns (conditions, params) for optional shared GL Entry filters.""" + conditions, params = [], [] if filters.get("cost_center"): - cost_centers = get_cost_centers_with_children(filters.get("cost_center")) - placeholders = ", ".join(["?" for _ in cost_centers]) - sql.append(f"AND cost_center IN ({placeholders})") - params.extend(cost_centers) + cc = get_cost_centers_with_children(filters.get("cost_center")) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})") + params.extend(cc) if filters.get("project"): - proj_list = filters.project if isinstance(filters.project, list) else [filters.project] - placeholders = ", ".join(["?" for _ in proj_list]) - sql.append(f"AND project IN ({placeholders})") - params.extend(proj_list) + proj = filters.project if isinstance(filters.project, list) else [filters.project] + conditions.append(f"project IN ({', '.join(['?'] * len(proj))})") + params.extend(proj) if frappe.db.count("Finance Book"): company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") @@ -658,55 +637,27 @@ def _build_common_gl_filters(filters): fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] else: fb_list = [cstr(filters.get("finance_book")), ""] - placeholders = ", ".join(["?" for _ in fb_list]) - sql.append(f"AND (finance_book IN ({placeholders}) OR finance_book IS NULL)") + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)") params.extend(fb_list) - accounting_dimensions = get_accounting_dimensions(as_list=False) - for dimension in accounting_dimensions: - if filters.get(dimension.fieldname): - if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): - filters[dimension.fieldname] = get_dimension_with_children( - dimension.document_type, filters.get(dimension.fieldname) + for dim in get_accounting_dimensions(as_list=False): + if filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, filters.get(dim.fieldname) ) - dim_vals = filters[dimension.fieldname] - if not isinstance(dim_vals, list): - dim_vals = [dim_vals] - placeholders = ", ".join(["?" for _ in dim_vals]) - sql.append(f"AND {dimension.fieldname} IN ({placeholders})") - params.extend(dim_vals) + vals = ( + filters[dim.fieldname] + if isinstance(filters[dim.fieldname], list) + else [filters[dim.fieldname]] + ) + conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) - return sql, params + return conditions, params -def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): - ignore_closing_entries = not flt(filters.get("with_period_closing_entry_for_current_period")) - common_sql, common_params = _build_common_gl_filters(filters) - - sql_parts = [ - "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", - " SUM(debit_in_account_currency) AS debit_in_account_currency,", - " SUM(credit_in_account_currency) AS credit_in_account_currency,", - " account_currency", - 'FROM "tabGL Entry"', - "WHERE company = ?", - " AND is_cancelled = 0", - " AND posting_date >= ?", - " AND posting_date <= ?", - ] - params = [filters.company, filters.from_date, filters.to_date] - - if not ignore_is_opening: - sql_parts.append(" AND is_opening = 'No'") - - if ignore_closing_entries: - sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") - - sql_parts.extend(common_sql) - params.extend(common_params) - sql_parts.append("GROUP BY account, account_currency") - - rows = conn.execute("\n".join(sql_parts), params).fetchall() +def _fetch_gl_rows_duckdb(conn, conditions, params): cols = [ "account", "debit", @@ -715,135 +666,115 @@ def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): "credit_in_account_currency", "account_currency", ] - entries = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + sql = f"""SELECT account, SUM(debit), SUM(credit), + SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency + FROM "tabGL Entry" WHERE {" AND ".join(conditions)} + GROUP BY account, account_currency""" + return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()] + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + if not flt(filters.get("with_period_closing_entry_for_current_period")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + entries = _fetch_gl_rows_duckdb(conn, conditions, params) if filters.get("presentation_currency"): convert_to_presentation_currency(entries, get_currency(filters)) gl_entries_by_account = {} for entry in entries: gl_entries_by_account.setdefault(entry.account, []).append(entry) - return gl_entries_by_account def get_opening_balances_duckdb(conn, filters, ignore_is_opening): - bs = _get_rootwise_opening_balances_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) - pl = _get_rootwise_opening_balances_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) bs.update(pl) return bs -def _get_rootwise_opening_balances_duckdb(conn, filters, report_type, ignore_is_opening): +def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening): + accounting_dimensions = get_accounting_dimensions(as_list=False) ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") - last_period_closing_voucher = None + last_pcv = "" if not ignore_closing_balances: - pcv = frappe.db.get_all( + last_pcv = frappe.db.get_all( "Period Closing Voucher", filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, fields=["period_end_date", "name"], order_by="period_end_date desc", limit=1, ) - if pcv: - last_period_closing_voucher = pcv[0] - gle = [] - if last_period_closing_voucher: - gle = _query_opening_balance_duckdb( - conn, + if last_pcv: + # Account Closing Balance fetched via frappe (not GL Entry) + gle = get_opening_balance( "Account Closing Balance", filters, report_type, - ignore_is_opening, - period_closing_voucher=last_period_closing_voucher.name, + accounting_dimensions, + period_closing_voucher=last_pcv[0].name, + ignore_is_opening=ignore_is_opening, ) - if getdate(last_period_closing_voucher.period_end_date) < getdate(add_days(filters.from_date, -1)): - start_date = add_days(last_period_closing_voucher.period_end_date, 1) - gle += _query_opening_balance_duckdb( - conn, - "GL Entry", - filters, - report_type, - ignore_is_opening, - start_date=start_date, + if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_pcv[0].period_end_date, 1) + gle += _get_gl_entry_opening_duckdb( + conn, filters, report_type, ignore_is_opening, start_date=start_date ) else: - gle = _query_opening_balance_duckdb(conn, "GL Entry", filters, report_type, ignore_is_opening) + gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening) opening = frappe._dict() for d in gle: opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) opening[d.account]["opening_debit"] += flt(d.debit) opening[d.account]["opening_credit"] += flt(d.credit) - return opening -def _query_opening_balance_duckdb( - conn, doctype, filters, report_type, ignore_is_opening, period_closing_voucher=None, start_date=None -): - table = f'"tab{doctype}"' - common_sql, common_params = _build_common_gl_filters(filters) +def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None): + accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name") + if not accounts: + return [] - sql_parts = [ - "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", - " SUM(debit_in_account_currency) AS debit_in_account_currency,", - " SUM(credit_in_account_currency) AS credit_in_account_currency,", - " account_currency", - f"FROM {table}", - "WHERE company = ?", - ' AND account IN (SELECT name FROM "tabAccount" WHERE report_type = ?)', - ] - params = [filters.company, report_type] + conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"] + params = [filters.company, *accounts] - if doctype == "GL Entry": - sql_parts.append(" AND is_cancelled = 0") - - if start_date: - sql_parts.append(" AND posting_date >= ?") - sql_parts.append(" AND posting_date < ?") - params.extend([start_date, filters.from_date]) - if not ignore_is_opening: - sql_parts.append(" AND is_opening = 'No'") - else: - if not ignore_is_opening: - sql_parts.append(" AND (posting_date < ? OR is_opening = 'Yes')") - params.append(filters.from_date) - else: - sql_parts.append(" AND posting_date < ?") - params.append(filters.from_date) - - if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": - sql_parts.append(" AND posting_date >= ?") - params.append(filters.year_start_date) - - if not flt(filters.get("with_period_closing_entry_for_opening")): - sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + if start_date: + conditions.append("posting_date >= ? AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + elif not ignore_is_opening: + conditions.append("(posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) else: - sql_parts.append(" AND period_closing_voucher = ?") - params.append(period_closing_voucher) + conditions.append("posting_date < ?") + params.append(filters.from_date) - if not flt(filters.get("with_period_closing_entry_for_opening")): - sql_parts.append(" AND is_period_closing_voucher_entry = 0") + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + conditions.append("posting_date >= ?") + params.append(filters.year_start_date) - sql_parts.extend(common_sql) - params.extend(common_params) - sql_parts.append("GROUP BY account, account_currency") + if not flt(filters.get("with_period_closing_entry_for_opening")): + conditions.append("voucher_type != 'Period Closing Voucher'") - rows = conn.execute("\n".join(sql_parts), params).fetchall() - cols = [ - "account", - "debit", - "credit", - "debit_in_account_currency", - "credit_in_account_currency", - "account_currency", - ] - gle = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + gle = _fetch_gl_rows_duckdb(conn, conditions, params) if filters.get("presentation_currency"): convert_to_presentation_currency(gle, get_currency(filters)) - return gle From d41b9f11ff23f482cd1b0e03c91cc96a34e7a007 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 18 Jun 2026 12:39:25 +0530 Subject: [PATCH 13/33] refactor: maintain sync dependency in report master (cherry picked from commit 5c536b8ad1e7a7c7274cd3f82ac9e9ab2f34891f) --- .../report/accounts_payable/accounts_payable.json | 10 +++++++++- .../accounts_receivable/accounts_receivable.json | 10 +++++++++- .../report/general_ledger/general_ledger.json | 10 +++++++++- .../accounts/report/trial_balance/trial_balance.json | 12 ++++++++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 40aa222cbb0..48380605ccf 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-22 16:16:03", "default_print_format": "Accounts Payable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:14.716933", + "modified": "2026-06-18 11:54:12.154865", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -33,5 +40,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index b6e7820f91c..3b4d6594bf1 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-16 11:31:13", "default_print_format": "Accounts Receivable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-05-22 14:34:57.666402", + "modified": "2026-06-18 11:53:59.190645", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -27,5 +34,6 @@ "role": "Accounts User" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 8dac581eae3..7f5d59a9f98 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-12-06 13:22:23", "default_print_format": "General Ledger Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:34:35.246000", + "modified": "2026-06-18 11:53:29.057634", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index b6c121bd5fd..321bf46d05b 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-22 11:41:23.743564", "default_print_format": "Trial Balance Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], - "idx": 2, + "generate_csv": 0, + "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:35:44.889062", + "modified": "2026-06-18 11:41:42.774023", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } From 2f6ef7b2ec23079e64081dbb7727b6829c48624d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 18 Jun 2026 16:39:55 +0530 Subject: [PATCH 14/33] refactor: DB agnostic method names (cherry picked from commit f40cd4180146b76e9b62854dc015b8c0ecfb96f4) --- .../report/trial_balance/trial_balance.json | 2 +- .../report/trial_balance/trial_balance.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 321bf46d05b..7aca6d62acc 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 11:41:42.774023", + "modified": "2026-06-18 16:37:42.112788", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 11aa3966a06..b02651b6f77 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -573,11 +573,16 @@ def hide_group_accounts(data): return non_group_accounts_data -def execute_duckdb(filters, duckdb_conn): - validate_filters(filters) - columns = get_columns() - data = get_data_duckdb(filters, duckdb_conn) - return columns, data +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + validate_filters(filters) + columns = get_columns() + data = get_data_duckdb(filters, conn) + return columns, data + else: + frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) def get_data_duckdb(filters, conn): From 19ec095ff8c198ee92e1afabe9158852a67dfb39 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 19 Jun 2026 15:47:57 +0530 Subject: [PATCH 15/33] feat(general-ledger): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 6b4895bcc92be13d45d82bd31c3229c1914434c1) --- .../report/general_ledger/general_ledger.json | 2 +- .../report/general_ledger/general_ledger.py | 286 +++++++++++++++++- 2 files changed, 282 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 7f5d59a9f98..914fa496c07 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 11:53:29.057634", + "modified": "2026-06-22 11:50:08.020553", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index c9ee9784a8d..08c467d5c1c 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -820,10 +820,286 @@ def get_columns(filters): return columns -def execute_duckdb(filters, duckdb_conn): - print(filters) - conn = duckdb_conn - columns = get_columns(filters) - res = [] +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + if conn := get_latest_sync("GL Entry"): + return _execute_with_duckdb_conn(filters, conn) + + frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def _execute_with_duckdb_conn(filters, conn): + if not filters: + return [], [] + + account_details = {} + + if filters.get("print_in_account_currency") and not filters.get("account"): + frappe.throw(_("Select an account to print in account currency")) + + for acc in frappe.get_all("Account", fields=["name", "is_group"]): + account_details.setdefault(acc.name, acc) + + if filters.get("party"): + filters.party = frappe.parse_json(filters.get("party")) + + validate_filters(filters, account_details) + validate_party(filters) + filters = set_account_currency(filters) + columns = get_columns(filters) + res = get_result_duckdb(filters, account_details, conn) return columns, res + + +def get_result_duckdb(filters, account_details, conn): + accounting_dimensions = [] + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions() + + gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn) + data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries) + return get_result_as_list(data, filters) + + +def get_gl_entries_duckdb(filters, accounting_dimensions, conn): + currency_map = get_currency(filters) + + col_names = [ + "gl_entry", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + select_exprs = [ + "name", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + + if filters.get("show_remarks"): + remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length") + if remarks_length: + select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})") + else: + select_exprs.append("remarks") + col_names.append("remarks") + + if filters.get("add_values_in_transaction_currency"): + select_exprs += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + col_names += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + + if accounting_dimensions: + select_exprs += accounting_dimensions + col_names += accounting_dimensions + + order_by = "posting_date, account, creation" + if filters.get("include_dimensions"): + order_by = "posting_date, creation" + if filters.get("categorize_by") == "Categorize by Voucher": + order_by = "posting_date, voucher_type, voucher_no" + if filters.get("categorize_by") == "Categorize by Account": + order_by = "account, posting_date, creation" + + if filters.get("include_default_book_entries"): + filters["company_fb"] = frappe.get_cached_value( + "Company", filters.get("company"), "default_finance_book" + ) + + conditions, params = _build_gl_conditions_duckdb(filters) + select_clause = ", ".join(select_exprs) + sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}' + + rows = conn.execute(sql, params).fetchall() + gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows] + + party_name_map = get_party_name_map() + for gl_entry in gl_entries: + if gl_entry.party_type and gl_entry.party: + gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party) + + if filters.get("presentation_currency"): + return convert_to_presentation_currency(gl_entries, currency_map, filters) + return gl_entries + + +def _build_gl_conditions_duckdb(filters): + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + conditions = ["company = ?"] + params = [filters.company] + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + if filters.account: + conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})") + params.extend(filters.account) + + if filters.get("cost_center"): + filters.cost_center = get_cost_centers_with_children(filters.cost_center) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})") + params.extend(filters.cost_center) + + if filters.get("voucher_no"): + conditions.append("voucher_no = ?") + params.append(filters.voucher_no) + + if filters.get("against_voucher_no"): + conditions.append("against_voucher = ?") + params.append(filters.against_voucher_no) + + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + pluck="name", + ) + if err_journals: + filters.update({"voucher_no_not_in": err_journals}) + + if filters.get("ignore_cr_dr_notes"): + system_generated = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Credit Note", "Debit Note"]), + "is_system_generated": 1, + }, + pluck="name", + ) + if system_generated: + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated + filters.update({"voucher_no_not_in": vouchers_to_ignore}) + + if filters.get("voucher_no_not_in"): + vouchers = filters.voucher_no_not_in + conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})") + params.extend(vouchers) + + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): + conditions.append("party_type IN ('Customer', 'Supplier')") + + if filters.get("party_type"): + conditions.append("party_type = ?") + params.append(filters.party_type) + + if filters.get("party"): + conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})") + params.extend(filters.party) + + # from_date: skip when filtering by account/party to allow opening balance calc in Python + if filters.get("disable_opening_balance_calculation"): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + elif not ( + filters.get("account") + or filters.get("party") + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] + ): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + + if not ignore_is_opening: + conditions.append("(posting_date <= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date <= ?") + params.append(filters.to_date) + + if filters.get("project"): + conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})") + params.extend(filters.project) + + company_fb = filters.get("company_fb") or frappe.get_cached_value( + "Company", filters.company, "default_finance_book" + ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_vals = [cstr(filters.finance_book), ""] + else: + fb_vals = [cstr(company_fb), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)") + params.extend(fb_vals) + else: + if filters.get("finance_book"): + conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)") + params.append(cstr(filters.finance_book)) + else: + conditions.append("(finance_book IN ('') OR finance_book IS NULL)") + + if not filters.get("show_cancelled_entries"): + conditions.append("is_cancelled = 0") + + accounting_dimensions_list = get_accounting_dimensions(as_list=False) + if accounting_dimensions_list: + for dimension in accounting_dimensions_list: + if not dimension.disabled and dimension.document_type != "Finance Book": + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + vals = ( + filters[dimension.fieldname] + if isinstance(filters[dimension.fieldname], list) + else [filters[dimension.fieldname]] + ) + conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params From 636bcbedc0c782678a2fefbee5a457ecf3bf73b2 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:29:02 +0530 Subject: [PATCH 16/33] feat(balance-sheet): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit bb195408165aa6c69771e75dd36e3b80ca1f2f3a) --- .../report/balance_sheet/balance_sheet.json | 10 +- .../report/balance_sheet/balance_sheet.py | 204 +++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index 4c1d4b64030..a992e189d61 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-14 05:24:20.385279", "default_print_format": "Balance Sheet Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:28.187799", + "modified": "2026-06-22 13:06:12.602924", "modified_by": "Administrator", "module": "Accounts", "name": "Balance Sheet", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index a8531e58acb..756d0c2ebbb 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -4,18 +4,27 @@ import frappe from frappe import _ -from frappe.utils import cint, flt +from frappe.utils import add_days, cint, flt from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( FinancialReportEngine, get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounting_entries, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency): chart["currency"] = currency return chart + + +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + filters.period_start_date = period_list[0]["year_start_date"] + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + + asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list) + liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list) + equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list) + + provisional_profit_loss, total_credit = get_provisional_profit_loss( + asset, liability, equity, period_list, filters.company, currency + ) + message, opening_balance = check_opening_balance(asset, liability, equity) + + data = [] + data.extend(asset or []) + data.extend(liability or []) + data.extend(equity or []) + if opening_balance and round(opening_balance, 2) != 0: + unclosed = { + "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "warn_if_negative": True, + "currency": currency, + } + for period in period_list: + unclosed[period.key] = opening_balance + if provisional_profit_loss: + provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance + unclosed["total"] = opening_balance + data.append(unclosed) + + if provisional_profit_loss: + data.append(provisional_profit_loss) + if total_credit: + data.append(total_credit) + + columns = get_columns( + filters.periodicity, period_list, filters.accumulated_values, company=filters.company + ) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) + report_summary, primitive_summary = get_report_summary( + period_list, asset, liability, equity, provisional_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + return columns, data, message, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + year_start_date = period_list[0]["year_start_date"] + last_to_date = period_list[-1]["to_date"] + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + opening_from_date = None + ignore_opening_entries = False + + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + if not ignore_closing_balances: + last_pcv_list = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": company, + "period_end_date": ("<", filters.get("period_start_date") or year_start_date), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if last_pcv_list: + last_pcv = last_pcv_list[0] + pcv_entries = get_accounting_entries( + "Account Closing Balance", + None, + last_to_date, + filters, + root_type=root_type, + ignore_closing_entries=False, + period_closing_voucher=last_pcv.name, + ) + if filters.get("presentation_currency"): + convert_to_presentation_currency(pcv_entries, get_currency(filters)) + for entry in pcv_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + opening_from_date = add_days(last_pcv.period_end_date, 1) + ignore_opening_entries = True + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + ] + base_params = [company, *leaf_accounts] + if ignore_opening_entries and not ignore_is_opening: + base_conds.append("is_opening = 'No'") + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + # Opening GL entries from DuckDB (entries before year_start_date) + open_conds = [*base_conds, "posting_date < ?"] + open_params = [*base_params, year_start_date] + if opening_from_date: + open_conds = [*open_conds, "posting_date >= ?"] + open_params = [*open_params, opening_from_date] + + opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(opening_entries, get_currency(filters)) + synthetic_open_date = add_days(year_start_date, -1) + for entry in opening_entries: + entry.posting_date = synthetic_open_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + # Period GL entries from DuckDB (one aggregated query per period) + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) From 21ddb00e2003e2f862f999a0d078f1463930519e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:36:02 +0530 Subject: [PATCH 17/33] feat(profit-and-loss): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 6a93baacf05a82a0f643633b38178e13878d2283) --- .../profit_and_loss_statement.json | 10 +- .../profit_and_loss_statement.py | 130 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 5abd51e2a30..7565c197119 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-18 11:43:33.173207", "default_print_format": "P&L Statement Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-05-22 14:36:04.544347", + "modified": "2026-06-22 13:06:12.602924", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 9ce6cd77e5b..297aa961058 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, compute_margin_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur chart["currency"] = currency return chart + + +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw( + _("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")) + ) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list) + expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list) + + net_profit_loss = get_net_profit_loss( + income, expense, period_list, filters.company, filters.presentation_currency + ) + + data = [] + data.extend(income or []) + data.extend(expense or []) + if net_profit_loss: + data.append(net_profit_loss) + + columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company) + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency) + + report_summary, primitive_summary = get_report_summary( + period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + if filters.get("selected_view") == "Margin": + compute_margin_view_data(data, period_list, filters.accumulated_values) + + return columns, data, None, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + "voucher_type != 'Period Closing Voucher'", + ] + base_params = [company, *leaf_accounts] + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) From 88b7a38be49af2a7527785709b302e501ec3efda Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:39:01 +0530 Subject: [PATCH 18/33] refactor: synced reports should be enabled on sites based on requirements (cherry picked from commit 963bbc8729e279c91582b942f43aaad645c872b1) --- .../accounts/report/accounts_payable/accounts_payable.json | 4 ++-- .../report/accounts_receivable/accounts_receivable.json | 4 ++-- erpnext/accounts/report/balance_sheet/balance_sheet.json | 4 ++-- erpnext/accounts/report/general_ledger/general_ledger.json | 4 ++-- .../profit_and_loss_statement/profit_and_loss_statement.json | 4 ++-- erpnext/accounts/report/trial_balance/trial_balance.json | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 48380605ccf..9c713fccf64 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-06-18 11:54:12.154865", + "modified": "2026-06-25 12:03:36.559152", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -40,6 +40,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index 3b4d6594bf1..dcc3c2c6a49 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-06-18 11:53:59.190645", + "modified": "2026-06-25 12:03:28.812092", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -34,6 +34,6 @@ "role": "Accounts User" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index a992e189d61..75277f72ac7 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-06-22 13:06:12.602924", + "modified": "2026-06-22 13:38:25.236839", "modified_by": "Administrator", "module": "Accounts", "name": "Balance Sheet", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 914fa496c07..083f7b62ae8 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-22 11:50:08.020553", + "modified": "2026-06-22 13:38:35.057216", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 7565c197119..9aa088aefe0 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-06-22 13:06:12.602924", + "modified": "2026-06-22 13:38:15.898375", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 7aca6d62acc..6793268a1e6 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 16:37:42.112788", + "modified": "2026-06-22 13:38:42.740436", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } From 97a7a2d6bc4a0994789bd3f0e2d0fae59dec2371 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 1 Jul 2026 13:27:21 +0530 Subject: [PATCH 19/33] refactor: rename execute_synced_report to execute_snapshot_report Match the framework rename of the standard report entry point in the trial balance, P&L, balance sheet, and general ledger reports. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit ba7b6a47c56f62c885191bf1a12faeace2967cb7) --- erpnext/accounts/report/balance_sheet/balance_sheet.py | 2 +- erpnext/accounts/report/general_ledger/general_ledger.py | 2 +- .../profit_and_loss_statement/profit_and_loss_statement.py | 2 +- erpnext/accounts/report/trial_balance/trial_balance.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index 756d0c2ebbb..1090b7f4b9c 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency): return chart -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if not (conn := get_latest_sync("GL Entry")): diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 08c467d5c1c..76d4029fae1 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -820,7 +820,7 @@ def get_columns(filters): return columns -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if conn := get_latest_sync("GL Entry"): diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 297aa961058..25eca6f4c79 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur return chart -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if not (conn := get_latest_sync("GL Entry")): diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index b02651b6f77..8fb564ac47f 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -573,7 +573,7 @@ def hide_group_accounts(data): return non_group_accounts_data -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if conn := get_latest_sync("GL Entry"): From c03d115999d566ba002003b45f317d93c55f04df Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 1 Jul 2026 13:43:18 +0530 Subject: [PATCH 20/33] refactor: rename feature toggle in report master (cherry picked from commit 981e90e4da111fffeb954d138eddbf122ea67f59) --- .../accounts/report/accounts_payable/accounts_payable.json | 4 ++-- .../report/accounts_receivable/accounts_receivable.json | 4 ++-- erpnext/accounts/report/general_ledger/general_ledger.json | 4 ++-- .../profit_and_loss_statement/profit_and_loss_statement.json | 4 ++-- erpnext/accounts/report/trial_balance/trial_balance.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 9c713fccf64..5caee4f5c1f 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-06-25 12:03:36.559152", + "modified": "2026-07-01 13:37:41.185347", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -40,6 +40,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index dcc3c2c6a49..ef9b6df88d4 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-06-25 12:03:28.812092", + "modified": "2026-07-01 13:37:44.167999", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -34,6 +34,6 @@ "role": "Accounts User" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 083f7b62ae8..a702e606edd 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-22 13:38:35.057216", + "modified": "2026-07-01 13:36:06.682661", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 9aa088aefe0..5ddd3af7aa4 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-06-22 13:38:15.898375", + "modified": "2026-07-01 13:36:14.934965", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 6793268a1e6..5a8bd5c006e 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-22 13:38:42.740436", + "modified": "2026-07-01 17:32:21.801141", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } From 00a646ac25a65c1d0ce8ed077ad90ca8b3cac8bc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 13 Jul 2026 13:17:40 +0530 Subject: [PATCH 21/33] fix: allow barcode scan to add and increment items in pick list - allow new rows on scan when pick manually is enabled, since only then are scanned rows not subject to being overridden by set_item_locations on save - stop capping picked qty at the default demand qty (1) for rows added by the scanner itself, so repeat scans of the same barcode keep incrementing the row instead of failing with "maximum quantity scanned" - ignore barcode uom when matching an existing row if new rows aren't allowed, since there's no alternate-uom row to fall back to (cherry picked from commit 3ece4a615d0ce7a206a144acc78221c03df7135b) --- erpnext/public/js/utils/barcode_scanner.js | 13 +++++++++++-- erpnext/stock/doctype/pick_list/pick_list.js | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index dd585041d71..9f344a6c576 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -15,6 +15,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.warehouse_field = opts.warehouse_field || "warehouse"; // field name on row which defines max quantity to be scanned e.g. picklist this.max_qty_field = opts.max_qty_field; + // row fields that, if set, mean max_qty_field is a real demand qty (e.g. from a + // linked Sales Order) that scanning must not exceed. Rows with none of these set + // have no real demand qty, so max_qty_field is just an arbitrary default and + // shouldn't cap further scans. + this.demand_ref_fields = opts.demand_ref_fields || []; // scanner won't add a new row if this flag is set. this.dont_allow_new_row = opts.dont_allow_new_row; // scanner will ask user to type the quantity instead of incrementing by 1 @@ -390,6 +395,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } async set_barcode_uom(row, uom) { + // e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental + // barcode uom must not overwrite the row's own uom. + if (this.max_qty_field) return; if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) { await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom); } @@ -454,8 +462,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { const matching_row = (row) => { const item_match = row.item_code == item_code; const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no; - const uom_match = !uom || row[this.uom_field] == uom; - const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]); + const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom; + const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]); + const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]); const item_scanned = row.has_item_scanned; let warehouse_match = true; diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index bcb194bd23c..e50f8bc4390 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -288,7 +288,8 @@ frappe.ui.form.on("Pick List", { items_table_name: "locations", qty_field: "picked_qty", max_qty_field: "qty", - dont_allow_new_row: true, + demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"], + dont_allow_new_row: !frm.doc.pick_manually, prompt_qty: frm.doc.prompt_qty, serial_no_field: "not_supported", // doesn't make sense for picklist without a separate field. }; From 0d9ace7ab87d5198aa8dfc3874b6f82920dc88ae Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 13:33:37 +0530 Subject: [PATCH 22/33] fix(stock): show qty (company) and qty (warehouse) in sales transactions company was passed to get_bin_details only for purchase order, so company_total_stock was never returned for sales order, quotation, sales invoice and delivery note and the qty (company) column always read zero. pass ctx.company for every doctype, which also drops the dependency on doc being supplied. on the client, set_actual_qty copied only actual_qty out of the response, so qty (company) never refreshed on a warehouse change. use frm.call with child so every bin field is applied, pass include_child_warehouses to match the server, and include quotation. (cherry picked from commit ab30bab6cbdc0981ece3f0ecbc7cf87329b9a416) --- erpnext/public/js/utils/sales_common.js | 12 +++++------- erpnext/stock/get_item_details.py | 7 +++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index d1d5d77f285..72a8d25af3b 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -284,19 +284,17 @@ erpnext.sales_common = { set_actual_qty(doc, cdt, cdn) { let row = locals[cdt][cdn]; - let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"]; + let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"]; if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) { - frappe.call({ + return this.frm.call({ method: "erpnext.stock.get_item_details.get_bin_details", + child: row, args: { item_code: row.item_code, warehouse: row.warehouse, - }, - callback(r) { - if (r.message) { - frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty); - } + company: doc.company, + include_child_warehouses: true, }, }); } diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 05c61bbb4d5..7cbe369c720 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -307,10 +307,9 @@ def update_bin_details(ctx: ItemDetailsCtx, out: ItemDetails, doc): out.update(get_bin_details(ctx.item_code, ctx.from_warehouse)) elif out.get("warehouse"): - company = ctx.company if (doc and doc.get("doctype") == "Purchase Order") else None - - # calculate company_total_stock only for po - bin_details = get_bin_details(ctx.item_code, out.warehouse, company, include_child_warehouses=True) + bin_details = get_bin_details( + ctx.item_code, out.warehouse, ctx.company, include_child_warehouses=True + ) out.update(bin_details) From 77cca4464d6baf61b0549ea49df6ec62d770df8b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 13:34:13 +0530 Subject: [PATCH 23/33] test(stock): assert qty (company) and qty (warehouse) on item details covers sales order, quotation, sales invoice, delivery note and purchase order, asserting actual_qty from the row warehouse and company_total_stock across all warehouses of the company. (cherry picked from commit 4e5e1f659648005e1f9e7c1ab8767a4b48ccd595) --- erpnext/stock/tests/test_get_item_details.py | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index fdc563064ec..99d94008221 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -27,6 +27,40 @@ class TestGetItemDetail(ERPNextTestSuite): details = get_item_details(args) self.assertEqual(details.get("price_list_rate"), 100) + def test_bin_details_for_selling_doctypes(self): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + item_code = make_item(properties={"is_stock_item": 1}).name + + make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=100, rate=100) + make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse 1 - _TC", qty=50, rate=100) + + args = frappe._dict( + { + "item_code": item_code, + "warehouse": "_Test Warehouse - _TC", + "company": "_Test Company", + "customer": "_Test Customer", + "currency": "INR", + "conversion_rate": 1.0, + "price_list": "_Test Price List", + "price_list_currency": "INR", + "plc_conversion_rate": 1.0, + "transaction_date": None, + "name": None, + "ignore_pricing_rule": 1, + "qty": 1, + } + ) + + for doctype in ("Sales Order", "Quotation", "Sales Invoice", "Delivery Note", "Purchase Order"): + with self.subTest(doctype=doctype): + details = get_item_details(args.copy().update({"doctype": doctype})) + + self.assertEqual(details.get("actual_qty"), 100) + self.assertEqual(details.get("company_total_stock"), 150) + # making this test in get_item_details test file as feat/fix is present in that method def test_fetch_price_from_list_rate_on_doc_save(self): # create item From 5991ecfa3d0addddb9dc66fff454405016f91e0a Mon Sep 17 00:00:00 2001 From: PranavDarade Date: Sun, 5 Jul 2026 20:03:56 +0530 Subject: [PATCH 24/33] fix(stock): set stock_uom on transferred Stock Reservation Entries StockReservation.transfer_reservation_entries_to() created the transferred SREs without copying stock_uom, in both the entries_to_reserve dict and the extra-items fallback. get_items_to_reserve() already selects the item's stock_uom, so entry.stock_uom is used. On sites with a global default stock_uom (e.g. "Nos"), frappe's _set_defaults() backfilled the blank field, so the transfer silently stored the wrong UOM for any item whose stock UOM is not the default. On sites without that default the SRE's validate_mandatory() raised "Stock UOM is required", aborting Work Order submission for the Subcontracting Inward Order / Production Plan flows. --- .../doctype/stock_reservation_entry/stock_reservation_entry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index ca8d49fe8ef..6fa410e50e3 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1316,6 +1316,7 @@ class StockReservation: "voucher_type": entry.voucher_type or to_doctype, "voucher_no": entry.voucher_no, "voucher_detail_no": entry.voucher_detail_no, + "stock_uom": entry.stock_uom, "serial_nos": [], "sre_names": defaultdict(float), "batches": defaultdict(float), @@ -1373,6 +1374,7 @@ class StockReservation: sre.voucher_qty = entry.required_qty sre.item_code = entry.item_code sre.warehouse = entry.warehouse + sre.stock_uom = entry.stock_uom sre.reserved_qty = min(sre.available_qty, entry.qty) sre.has_serial_no = frappe.get_value("Item", sre.item_code, "has_serial_no") sre.has_batch_no = frappe.get_value("Item", sre.item_code, "has_batch_no") From 40c85a0087f7974813c962d9754fc782e70cd0ab Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Mon, 13 Jul 2026 23:22:55 +0530 Subject: [PATCH 25/33] fix(tnc): `get_terms_and_conditions` render_template with `safe_exec` (backport #56944) (#56977) --- .../terms_and_conditions/terms_and_conditions.py | 11 +++++++---- pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 127517a1e3f..d3d056f3862 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -30,7 +30,7 @@ class TermsandConditions(Document): def validate(self): if self.terms: - validate_template(self.terms) + validate_template(self.terms, restrict_globals=True) if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled): throw(_("At least one of the Applicable Modules should be selected")) @@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc): if isinstance(doc, str): doc = json.loads(doc) - terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name) + tnc = frappe.get_cached_doc("Terms and Conditions", template_name) + tnc.check_permission() - if terms_and_conditions.terms: - return frappe.render_template(terms_and_conditions.terms, doc) + if not tnc.terms: + return + + return frappe.render_template(tnc.terms, doc, restrict_globals=1) diff --git a/pyproject.toml b/pyproject.toml index 094dcf03ccb..c8a39902883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ skip_namespaces = [ ] [tool.bench.frappe-dependencies] -frappe = ">=16.0.0,<17.0.0" +frappe = ">=16.21.0,<17.0.0" [tool.bench.assets] build_dir = "./banking" From 5a99dd6016eab69f5946458db5641fa2e2a988b1 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Mon, 13 Jul 2026 19:01:00 +0530 Subject: [PATCH 26/33] fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos (cherry picked from commit 4544a6c935818cb1f6f25ad52bef0654ebfb1736) --- .../serial_and_batch_bundle.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 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 db902d1b3f0..c33c2cfd36e 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 @@ -2584,22 +2584,24 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): def get_bundle_wise_serial_nos(data, kwargs): bundle_wise_serial_nos = defaultdict(list) - bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle] + bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle}) if not bundles: return bundle_wise_serial_nos - filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")} - - if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): - filters["serial_no"] = ("in", kwargs.get("serial_nos")) - - bundle_data = frappe.get_all( - "Serial and Batch Entry", - fields=["serial_no", "parent"], - filters=filters, + sabe = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sabe) + .select(sabe.serial_no, sabe.parent) + .where(sabe.parent.isin(bundles)) + .where(sabe.docstatus == 1) + .where(sabe.serial_no.isnotnull()) + .where(sabe.serial_no != "") ) - for d in bundle_data: + if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): + query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos"))) + + for d in query.run(as_dict=True): if d.parent: bundle_wise_serial_nos[d.parent].append(d.serial_no) From 345c508be77d68cb1fa78e10ac0eb9e8c84ecb4e Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 14 Jul 2026 00:46:26 +0530 Subject: [PATCH 27/33] chore: remove dead assets dashboard_fixtures with broken imports (#57079) erpnext.accounts.dashboard_fixtures and erpnext.buying.dashboard_fixtures were removed in 2020 when dashboards were exported to JSON fixtures. The assets module's dashboard_fixtures.py was left behind unreferenced; its dashboard, charts and number cards already exist as exported JSON. (cherry picked from commit 14a15cc6f99e4986a6f1f18b9efa78da0a135f36) --- erpnext/assets/dashboard_fixtures.py | 190 --------------------------- pyproject.toml | 1 - 2 files changed, 191 deletions(-) delete mode 100644 erpnext/assets/dashboard_fixtures.py diff --git a/erpnext/assets/dashboard_fixtures.py b/erpnext/assets/dashboard_fixtures.py deleted file mode 100644 index 0fd6c019f36..00000000000 --- a/erpnext/assets/dashboard_fixtures.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import json - -import frappe -from frappe import _ -from frappe.utils import get_date_str, nowdate - -from erpnext.accounts.dashboard_fixtures import _get_fiscal_year -from erpnext.buying.dashboard_fixtures import get_company_for_dashboards - - -def get_data(): - fiscal_year = _get_fiscal_year(nowdate()) - - if not fiscal_year: - return frappe._dict() - - year_start_date = get_date_str(fiscal_year.get("year_start_date")) - year_end_date = get_date_str(fiscal_year.get("year_end_date")) - - return frappe._dict( - { - "dashboards": get_dashboards(), - "charts": get_charts(fiscal_year, year_start_date, year_end_date), - "number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date), - } - ) - - -def get_dashboards(): - return [ - { - "name": "Asset", - "dashboard_name": "Asset", - "charts": [ - {"chart": "Asset Value Analytics", "width": "Full"}, - {"chart": "Category-wise Asset Value", "width": "Half"}, - {"chart": "Location-wise Asset Value", "width": "Half"}, - ], - "cards": [ - {"card": "Total Assets"}, - {"card": "New Assets (This Year)"}, - {"card": "Asset Value"}, - ], - } - ] - - -def get_charts(fiscal_year, year_start_date, year_end_date): - company = get_company_for_dashboards() - return [ - { - "name": "Asset Value Analytics", - "chart_name": _("Asset Value Analytics"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "is_custom": 1, - "group_by_type": "Count", - "number_of_groups": 0, - "is_public": 0, - "timespan": "Last Year", - "time_interval": "Yearly", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "filter_based_on": "Fiscal Year", - "from_fiscal_year": fiscal_year.get("name"), - "to_fiscal_year": fiscal_year.get("name"), - "period_start_date": year_start_date, - "period_end_date": year_end_date, - "date_based_on": "Purchase Date", - "group_by": "--Select a group--", - } - ), - "type": "Bar", - "custom_options": json.dumps( - { - "type": "bar", - "barOptions": {"stacked": 1}, - "axisOptions": {"shortenYAxisNumbers": 1}, - "tooltipOptions": {}, - } - ), - "doctype": "Dashboard Chart", - "y_axis": [], - }, - { - "name": "Category-wise Asset Value", - "chart_name": _("Category-wise Asset Value"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "asset_category", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Asset Category", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Category-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - { - "name": "Location-wise Asset Value", - "chart_name": "Location-wise Asset Value", - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "location", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Location", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Location-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - ] - - -def get_number_cards(fiscal_year, year_start_date, year_end_date): - return [ - { - "name": "Total Assets", - "label": _("Total Assets"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - { - "name": "New Assets (This Year)", - "label": _("New Assets (This Year)"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]), - "doctype": "Number Card", - }, - { - "name": "Asset Value", - "label": _("Asset Value"), - "function": "Sum", - "aggregate_function_based_on": "value_after_depreciation", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - ] diff --git a/pyproject.toml b/pyproject.toml index c8a39902883..afd54dac13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ build-backend = "flit_core.buildapi" max_module_depth = 1 skip_namespaces = [ "erpnext.deprecation_dumpster", - "erpnext.assets.dashboard_fixtures", # https://github.com/frappe/erpnext/issues/44418 ] [tool.bench.frappe-dependencies] From cec1e87c9038ec9e1bfd108952c239f1b8ee363d Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Mon, 13 Jul 2026 19:01:50 +0530 Subject: [PATCH 28/33] test(stock): add unit test for get_bundle_wise_serial_nos query (cherry picked from commit e748bf512b3d3f430d19c9c9ed3c4fef93df6f76) --- .../test_serial_and_batch_bundle.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) 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 e0b8cb750ac..a566ab8216e 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 @@ -10,8 +10,12 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, combine_datetime, + get_available_batches_qty, + get_qty_based_available_batches, + get_type_of_transaction, make_batch_nos, make_serial_nos, + parse_serial_nos, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite @@ -1467,3 +1471,133 @@ def make_serial_batch_bundle(kwargs): return sb.make_serial_and_batch_bundle() return sb + + +class TestSerialandBatchBundleLogic(ERPNextTestSuite): + """Pure helpers and in-memory document validations, covering branches the + integration suite doesn't reach (no stock-ledger / serial / batch fixtures).""" + + def test_parse_serial_nos_splits_and_trims(self): + self.assertEqual(parse_serial_nos("SN1\nSN2"), ["SN1", "SN2"]) + self.assertEqual(parse_serial_nos("SN1, SN2 , SN3"), ["SN1", "SN2", "SN3"]) + # blanks are dropped and an existing list is returned unchanged + self.assertEqual(parse_serial_nos("SN1,,\n , SN2"), ["SN1", "SN2"]) + self.assertEqual(parse_serial_nos(["SN1", "SN2"]), ["SN1", "SN2"]) + + def test_get_qty_based_available_batches_allocates_across_batches(self): + batches = [ + frappe._dict(batch_no="B1", qty=10, warehouse="W"), + frappe._dict(batch_no="B2", qty=5, warehouse="W"), + ] + # 12 consumes B1 fully then 2 from B2 + result = get_qty_based_available_batches(batches, 12) + self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 10), ("B2", 2)]) + # 8 is satisfied by B1 alone; B2 is not touched + result = get_qty_based_available_batches(batches, 8) + self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 8)]) + + def test_get_available_batches_qty_aggregates_by_batch(self): + batches = [ + frappe._dict(batch_no="B1", qty=10), + frappe._dict(batch_no="B2", qty=5), + frappe._dict(batch_no="B1", qty=3), + ] + agg = get_available_batches_qty(batches) + self.assertEqual(agg["B1"], 13) + self.assertEqual(agg["B2"], 5) + + def test_get_type_of_transaction_derives_direction(self): + def se(**kw): + return get_type_of_transaction(frappe._dict(doctype="Stock Entry"), frappe._dict(**kw)) + + self.assertEqual(se(s_warehouse="W"), "Outward") # issuing from a source warehouse + self.assertEqual(se(), "Inward") # only a target warehouse + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Purchase Receipt"), frappe._dict()), "Inward" + ) + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Stock Reconciliation"), frappe._dict()), "Inward" + ) + # a purchase return reverses the direction to Outward + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Purchase Receipt", is_return=1), frappe._dict()), + "Outward", + ) + + def test_duplicate_serial_no_in_entries_is_rejected(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"serial_no": "SN1"}) + doc.append("entries", {"serial_no": "SN1"}) + self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no) + + def test_duplicate_batch_no_in_entries_is_rejected(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"batch_no": "B1"}) + doc.append("entries", {"batch_no": "B1"}) + self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no) + + def test_voucher_no_is_mandatory(self): + doc = frappe.new_doc("Serial and Batch Bundle") + self.assertRaises(frappe.ValidationError, doc.validate_serial_and_batch_data) + + def test_validate_docstatus_rejects_unsubmitted_entries(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"qty": 1}) # a fresh row has docstatus 0 + self.assertRaises(frappe.ValidationError, doc.validate_docstatus) + + def test_calculate_total_qty_normalizes_and_signs(self): + inward = frappe.new_doc("Serial and Batch Bundle") + inward.type_of_transaction = "Inward" + inward.append("entries", {"qty": 5}) + inward.append("entries", {"qty": 3}) + inward.calculate_total_qty(save=False) + self.assertEqual(inward.total_qty, 8) + + # Outward flips the sign + outward = frappe.new_doc("Serial and Batch Bundle") + outward.type_of_transaction = "Outward" + outward.append("entries", {"qty": 5}) + outward.calculate_total_qty(save=False) + self.assertEqual(outward.total_qty, -5) + + # a serialized bundle normalizes each row qty to 1 + serialized = frappe.new_doc("Serial and Batch Bundle") + serialized.has_serial_no = 1 + serialized.type_of_transaction = "Inward" + serialized.append("entries", {"qty": 5}) + serialized.calculate_total_qty(save=False) + self.assertEqual(serialized.total_qty, 1) + + def test_get_bundle_wise_serial_nos(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_bundle_wise_serial_nos, + ) + + item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name + + bundles = [] + for _ in range(2): + se = make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=3, + rate=100, + ) + bundles.append(se.items[0].serial_and_batch_bundle) + + data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles] + + self.assertEqual(get_bundle_wise_serial_nos([], {}), {}) + + bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {}) + for bundle in bundles: + self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle)) + + # check_serial_nos must restrict the result to the requested serial nos + serial_no = get_serial_nos_from_bundle(bundles[0])[0] + bundle_wise_serial_nos = get_bundle_wise_serial_nos( + data, {"check_serial_nos": True, "serial_nos": [serial_no]} + ) + + self.assertNotIn(bundles[1], bundle_wise_serial_nos) + self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no]) From e8a532587d0af6bb710c0ea6fe7ca8fea4e140b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:07:12 +0530 Subject: [PATCH 29/33] fix(accounts): added permission checks on `get_account_balances_coa` (backport #57107) (#57122) Co-authored-by: Diptanil Saha --- erpnext/accounts/utils.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index c5ac2d1cba2..37aa943f447 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1408,13 +1408,11 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f def get_account_balances_coa(company: str, include_default_fb_balances: bool = False): company_currency = frappe.get_cached_value("Company", company, "default_currency") - Account = DocType("Account") - account_list = ( - frappe.qb.from_(Account) - .select(Account.name, Account.parent_account, Account.account_currency) - .where(Account.company == company) - .orderby(Account.lft) - .run(as_dict=True) + account_list = frappe.get_list( + "Account", + fields=["name", "parent_account", "account_currency"], + filters={"company": company}, + order_by="lft", ) account_balances_cc = {account.get("name"): 0 for account in account_list} @@ -1424,9 +1422,8 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F GLEntry = DocType("GL Entry") precision = get_currency_precision() get_ledger_balances_query = ( - frappe.qb.from_(GLEntry) + frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False) .select( - GLEntry.account, (Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"), ( Sum(Round(GLEntry.debit_in_account_currency, precision)) @@ -1436,7 +1433,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F .groupby(GLEntry.account) ) - condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0] + conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0] default_finance_book = None @@ -1444,12 +1441,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book") if default_finance_book: - condition_list.append( - (GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()) - ) + conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())) - for condition in condition_list: - get_ledger_balances_query = get_ledger_balances_query.where(condition) + get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions)) ledger_balances = get_ledger_balances_query.run(as_dict=True) From d7e9321746c04f405d8766e542f3fdbe57acb473 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 13:23:28 +0530 Subject: [PATCH 30/33] fix(manufacturing): preserve job card transferred quantity --- erpnext/manufacturing/doctype/work_order/work_order.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 2454341dc39..488700b4208 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1806,6 +1806,10 @@ class WorkOrder(Document): def recompute_material_transferred_for_manufacturing(self, transferred_items): """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty.""" + # Job Card transfers use the minimum completed quantity across operations. + if self.operations and self.transfer_material_against == "Job Card": + return + # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly. sum_fg_completed_qty = self.get_transferred_or_manufactured_qty( From fd6c9a71cdd2761a4907ad99aca552c975c479db Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 13:23:49 +0530 Subject: [PATCH 31/33] test(manufacturing): cover transferred quantity across job cards --- .../doctype/job_card/test_job_card.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 8551d5e04ff..f4b3622c9ca 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -457,6 +457,49 @@ class TestJobCard(ERPNextTestSuite): job_card.reload() self.assertEqual(job_card.transferred_qty, 0.0) + def test_work_order_transferred_qty_with_multiple_job_cards(self): + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + self.generate_required_stock(work_order) + + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": work_order.name}, + pluck="name", + order_by="sequence_id", + ) + completed_qty = (4, 3) + + for job_card_name, qty in zip(job_cards, completed_qty, strict=True): + job_card = frappe.get_doc("Job Card", job_card_name) + job_card.for_quantity = qty + job_card.save() + + transfer_entry = make_stock_entry_from_jc(job_card.name) + transfer_entry.fg_completed_qty = qty + transfer_entry.get_items() + transfer_entry.submit() + + job_card.reload() + job_card.append( + "time_logs", + { + "from_time": now(), + "to_time": add_to_date(now(), hours=1), + "completed_qty": qty, + }, + ) + job_card.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty)) + + # Refreshing required items must not replace the Job Card roll-up with the sum + # of FG quantities from Material Transfer Stock Entries (4 + 3). + work_order.update_required_items() + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty)) + def test_job_card_material_transfer_correctness(self): """ 1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card From 4d951c1cf8d7811c5d54c329e0f77daf2cfc5d91 Mon Sep 17 00:00:00 2001 From: SandraFrappe Date: Tue, 14 Jul 2026 14:32:06 +0530 Subject: [PATCH 32/33] fix: prevent duplicate material request items in purchase order (cherry picked from commit 2d6f89a7f58856b7ee52b646f696adac2483331c) --- .../doctype/purchase_order/purchase_order.py | 1 + .../purchase_order/test_purchase_order.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c5975b1a35e..72ae897b745 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -270,6 +270,7 @@ class PurchaseOrder(BuyingController): "ref_dn_field": "material_request_item", "compare_fields": mri_compare_fields, "is_child_table": True, + "allow_duplicate_prev_row_id": True, }, } ) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 7f934489b13..6fcb93fbb96 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -166,6 +166,23 @@ class TestPurchaseOrder(ERPNextTestSuite): frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + def test_duplicate_material_request_item_row_allowed(self): + """Splitting a Material Request Item's qty across multiple PO rows must be + allowed, mirroring how Sales Order allows duplicate Quotation Item rows.""" + mr = make_material_request(qty=10) + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + + duplicate_row = po.items[0].as_dict() + duplicate_row.qty = 4 + po.items[0].qty = 6 + + po.append("items", duplicate_row) + po.save() + + self.assertEqual(len(po.items), 2) + self.assertEqual(po.items[0].material_request_item, po.items[1].material_request_item) + def test_update_remove_child_linked_to_mr(self): """Test impact on linked PO and MR on deleting/updating row.""" mr = make_material_request(qty=10) From 4d931a71086fe2f6eed69b5af5b3fbd4890a6f97 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 14 Jul 2026 16:18:53 +0530 Subject: [PATCH 33/33] test: remove test (cherry picked from commit b2ec906ff3663fec4710890b874d33755f002273) --- .../purchase_order/test_purchase_order.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 6fcb93fbb96..7f934489b13 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -166,23 +166,6 @@ class TestPurchaseOrder(ERPNextTestSuite): frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) - def test_duplicate_material_request_item_row_allowed(self): - """Splitting a Material Request Item's qty across multiple PO rows must be - allowed, mirroring how Sales Order allows duplicate Quotation Item rows.""" - mr = make_material_request(qty=10) - po = make_purchase_order(mr.name) - po.supplier = "_Test Supplier" - - duplicate_row = po.items[0].as_dict() - duplicate_row.qty = 4 - po.items[0].qty = 6 - - po.append("items", duplicate_row) - po.save() - - self.assertEqual(len(po.items), 2) - self.assertEqual(po.items[0].material_request_item, po.items[1].material_request_item) - def test_update_remove_child_linked_to_mr(self): """Test impact on linked PO and MR on deleting/updating row.""" mr = make_material_request(qty=10)