Compare commits

..

8 Commits

Author SHA1 Message Date
Mihir Kandoi
ce62ef3e2e Merge pull request #57102 from frappe/mergify/bp/version-15-hotfix/pr-57089
fix: allow barcode scan to add and increment items in pick list (backport #57089)
2026-07-13 21:28:03 +05:30
pandiyan
bb5be10372 fix: allow barcode scan to add and increment items in pick list
- allow new rows on scan when pick manually is enabled, since only
  then are scanned rows not subject to being overridden by
  set_item_locations on save
- stop capping picked qty at the default demand qty (1) for rows
  added by the scanner itself, so repeat scans of the same barcode
  keep incrementing the row instead of failing with "maximum
  quantity scanned"
- ignore barcode uom when matching an existing row if new rows
  aren't allowed, since there's no alternate-uom row to fall back to

(cherry picked from commit 3ece4a615d)
2026-07-13 15:32:46 +00:00
ruthra kumar
fcd0350652 refactor: rename synced to snapshot report (backport #56706) (#57094)
* refactor: rename execute_synced_report to execute_snapshot_report

Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba7b6a47c5)

* refactor: rename feature toggle in report master

(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:36:35 +05:30
ruthra kumar
1489258ea0 refactor: rename feature toggle in report master
(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json
2026-07-13 16:08:50 +05:30
ruthra kumar
674704cbfd refactor: rename execute_synced_report to execute_snapshot_report
Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba7b6a47c5)
2026-07-13 10:35:28 +00:00
mergify[bot]
a6a887f833 feat: faster (synced) financial statements using duckdb (backport #56304) (#57093)
* refactor: reports on duckdb

(cherry picked from commit adb768505a)

* feat(trial-balance): implement execute_duckdb with full parity to normal report

Replaces the placeholder stub with 8 focused functions that mirror the
normal execute() flow using parameterized DuckDB SQL queries: account
fetch, period GL entries, opening balances (with Period Closing Voucher
path), and all filters (cost center, project, finance book, accounting
dimensions). Reuses existing pure-Python processing functions unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit b1c8e2cb5c)

* refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb

Replaces the previous over-engineered stub with 7 short functions.
Account data, Account Closing Balance, and all metadata come from
frappe.db as normal; only tabGL Entry is read from the duckdb_conn.

Reuses get_opening_balance() for Account Closing Balance unchanged,
reuses all downstream compute helpers (calculate_values, prepare_data,
etc.) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 55862f98f4)

* refactor: maintain sync dependency in report master

(cherry picked from commit 5c536b8ad1)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/general_ledger/general_ledger.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

* refactor: DB agnostic method names

(cherry picked from commit f40cd41801)

* feat(general-ledger): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 6b4895bcc9)

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* feat(balance-sheet): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit bb19540816)

# Conflicts:
#	erpnext/accounts/report/balance_sheet/balance_sheet.json

* feat(profit-and-loss): implement execute_synced_report with full parity to normal report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 6a93baacf0)

# Conflicts:
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json

* refactor: synced reports should be enabled on sites based on requirements

(cherry picked from commit 963bbc8729)

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* chore: resolve conflicts

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 16:03:26 +05:30
ruthra kumar
e8cdad1c6b Merge pull request #57082 from frappe/mergify/bp/version-15-hotfix/pr-56628
fix(journal-entry): fetch outstanding on foreign currency (backport #56628)
2026-07-13 13:05:57 +05:30
SowmyaArunachalam
ace51fccfa fix(journal-entry): fetch outstanding on foreign currency
(cherry picked from commit 07f641c48c)
2026-07-13 07:07:36 +00:00
14 changed files with 903 additions and 25 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.116.0"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -2811,9 +2811,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(

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-02-24 20:09:46.150861",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -15,6 +15,11 @@
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -28,5 +33,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-03-06 05:52:06.235584",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -15,6 +15,11 @@
"ref_doctype": "Sales Invoice",
"report_name": "Accounts Receivable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts Manager"
@@ -22,5 +27,7 @@
{
"role": "Accounts User"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -6,7 +6,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2018-09-07 12:18:21.850851",
"modified": "2026-06-22 13:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -15,6 +15,11 @@
"ref_doctype": "GL Entry",
"report_name": "Balance Sheet",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"synced_report": 0,
"timeout": 0
}

View File

@@ -4,14 +4,23 @@
import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.utils import add_days, cint, flt
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,
)
@@ -259,3 +268,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)

View File

@@ -6,11 +6,15 @@
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"idx": 3,
"is_standard": "Yes",
"letterhead": null,
"modified": "2025-11-05 15:47:59.597853",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +34,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -792,3 +792,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

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:40.282376",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -15,6 +15,12 @@
"ref_doctype": "GL Entry",
"report_name": "Profit and Loss Statement",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +31,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,12 +7,20 @@ from frappe import _
from frappe.utils import flt
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,
)
@@ -193,3 +201,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)

View File

@@ -7,13 +7,18 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:33.520866",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
"owner": "Administrator",
"ref_doctype": "GL Entry",
"report_name": "Trial Balance",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"report_type": "Script Report",
"roles": [
{
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -510,3 +510,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

View File

@@ -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);
}
@@ -456,8 +464,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;

View File

@@ -297,7 +297,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: "serial_no",
};