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"
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/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(
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": "
",
+ "content": "",
"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..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\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\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\t\t
\n\t\n
\n",
+ "content": "\n\t\n\t\t\n\t\t\t\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\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\t\t
\n\t\n
\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 22:03:24.525672",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead - Grey",
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json
index 40aa222cbb0..5caee4f5c1f 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-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -33,5 +40,6 @@
"role": "Auditor"
}
],
+ "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 b6e7820f91c..ef9b6df88d4 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-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -27,5 +34,6 @@
"role": "Accounts User"
}
],
+ "snapshot_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 4c1d4b64030..75277f72ac7 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:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py
index a8531e58acb..1090b7f4b9c 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_snapshot_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)
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)
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json
index 8dac581eae3..a702e606edd 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-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "snapshot_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index 8670a4fd175..76d4029fae1 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -818,3 +818,288 @@ def get_columns(filters):
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
return columns
+
+
+def execute_snapshot_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
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..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
@@ -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-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "snapshot_report": 0,
"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..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
@@ -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_snapshot_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)
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json
index b6c121bd5fd..5a8bd5c006e 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-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "snapshot_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py
index 186a9eb71f0..8fb564ac47f 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.py
+++ b/erpnext/accounts/report/trial_balance/trial_balance.py
@@ -571,3 +571,215 @@ def hide_group_accounts(data):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data
+
+
+def execute_snapshot_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):
+ # 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)
+ 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)
+ return filter_out_zero_value_rows(
+ data, parent_children_map, show_zero_values=filters.get("show_zero_values")
+ )
+
+
+def _extra_gl_conditions(filters):
+ """Returns (conditions, params) for optional shared GL Entry filters."""
+ conditions, params = [], []
+
+ if filters.get("cost_center"):
+ 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 = 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")
+ 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")), ""]
+ conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
+ params.extend(fb_list)
+
+ 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)
+ )
+ 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 conditions, params
+
+
+def _fetch_gl_rows_duckdb(conn, conditions, params):
+ cols = [
+ "account",
+ "debit",
+ "credit",
+ "debit_in_account_currency",
+ "credit_in_account_currency",
+ "account_currency",
+ ]
+ 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_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_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_pcv = ""
+
+ if not ignore_closing_balances:
+ 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 last_pcv:
+ # Account Closing Balance fetched via frappe (not GL Entry)
+ gle = get_opening_balance(
+ "Account Closing Balance",
+ filters,
+ report_type,
+ accounting_dimensions,
+ period_closing_voucher=last_pcv[0].name,
+ ignore_is_opening=ignore_is_opening,
+ )
+ 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 = _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 _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 []
+
+ conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
+ params = [filters.company, *accounts]
+
+ 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:
+ conditions.append("posting_date < ?")
+ params.append(filters.from_date)
+
+ 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)
+
+ if not flt(filters.get("with_period_closing_entry_for_opening")):
+ conditions.append("voucher_type != 'Period Closing Voucher'")
+
+ 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
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)
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/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/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 424bf9bac87..1b808cedcc1 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -1391,6 +1391,47 @@ 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
+ JournalEntry.update_journal_entry_link_on_depr_schedule()."""
+ from unittest.mock import MagicMock, patch
+
+ from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module
+
+ posting_date = getdate("2021-06-01")
+ 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()
+ 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(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]),
+ patch.object(frappe.db, "set_value") as mock_set_value,
+ ):
+ 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"
+ )
+
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."""
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)):
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/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/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
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(
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/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/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/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.
};
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)
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])
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""
+ )
+
+ 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/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")
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)
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")
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
diff --git a/pyproject.toml b/pyproject.toml
index 094dcf03ccb..afd54dac13f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,11 +39,10 @@ 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]
-frappe = ">=16.0.0,<17.0.0"
+frappe = ">=16.21.0,<17.0.0"
[tool.bench.assets]
build_dir = "./banking"