From ace51fccfa9c893863480c715e17935b49c8a2f9 Mon Sep 17 00:00:00 2001 From: SowmyaArunachalam Date: Mon, 29 Jun 2026 21:48:38 +0530 Subject: [PATCH 01/12] fix(journal-entry): fetch outstanding on foreign currency (cherry picked from commit 07f641c48cd2c4d61c5106081b3769b8c82687cc) --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 815bd0fd6f2..09f7d5c7d51 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -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( From a6a887f8337adf817c94abf0f7e4174c56f0110d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:03:26 +0530 Subject: [PATCH 02/12] feat: faster (synced) financial statements using duckdb (backport #56304) (#57093) * refactor: reports on duckdb (cherry picked from commit adb768505a5d621064edf21a1645f8c0ecd1183a) * feat(trial-balance): implement execute_duckdb with full parity to normal report Replaces the placeholder stub with 8 focused functions that mirror the normal execute() flow using parameterized DuckDB SQL queries: account fetch, period GL entries, opening balances (with Period Closing Voucher path), and all filters (cost center, project, finance book, accounting dimensions). Reuses existing pure-Python processing functions unchanged. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit b1c8e2cb5cf75dc4f3cf6a7e6a71534cb805069e) * refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb Replaces the previous over-engineered stub with 7 short functions. Account data, Account Closing Balance, and all metadata come from frappe.db as normal; only tabGL Entry is read from the duckdb_conn. Reuses get_opening_balance() for Account Closing Balance unchanged, reuses all downstream compute helpers (calculate_values, prepare_data, etc.) unchanged. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 55862f98f4327a0d7994981beccae17170c6acd8) * refactor: maintain sync dependency in report master (cherry picked from commit 5c536b8ad1e7a7c7274cd3f82ac9e9ab2f34891f) # 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 f40cd4180146b76e9b62854dc015b8c0ecfb96f4) * feat(general-ledger): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 6b4895bcc92be13d45d82bd31c3229c1914434c1) # 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 (cherry picked from commit bb195408165aa6c69771e75dd36e3b80ca1f2f3a) # 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 (cherry picked from commit 6a93baacf05a82a0f643633b38178e13878d2283) # 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 963bbc8729e279c91582b942f43aaad645c872b1) # Conflicts: # erpnext/accounts/report/general_ledger/general_ledger.json * chore: resolve conflicts --------- Co-authored-by: ruthra kumar Co-authored-by: Claude Sonnet 4.6 --- .../accounts_payable/accounts_payable.json | 13 +- .../accounts_receivable.json | 13 +- .../report/balance_sheet/balance_sheet.json | 13 +- .../report/balance_sheet/balance_sheet.py | 204 ++++++++++++- .../report/general_ledger/general_ledger.json | 9 +- .../report/general_ledger/general_ledger.py | 285 ++++++++++++++++++ .../profit_and_loss_statement.json | 14 +- .../profit_and_loss_statement.py | 130 ++++++++ .../report/trial_balance/trial_balance.json | 13 +- .../report/trial_balance/trial_balance.py | 212 +++++++++++++ 10 files changed, 888 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 321722a29da..6695d8a5799 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 3, "is_standard": "Yes", - "modified": "2017-02-24 20:09:46.150861", + "modified": "2026-06-25 12:03:36.559152", "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" } - ] -} \ No newline at end of file + ], + "synced_report": 0, + "timeout": 0 +} diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index 1c99ac5f00b..91f4c1167cc 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 3, "is_standard": "Yes", - "modified": "2017-03-06 05:52:06.235584", + "modified": "2026-06-25 12:03:28.812092", "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" } - ] -} \ No newline at end of file + ], + "synced_report": 0, + "timeout": 0 +} diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index f67a34b25e9..09fa23d4aaf 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -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" } - ] -} \ No newline at end of file + ], + "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 e2dc2f01943..3f1bedb63e8 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -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_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + filters.period_start_date = period_list[0]["year_start_date"] + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + + asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list) + liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list) + equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list) + + provisional_profit_loss, total_credit = get_provisional_profit_loss( + asset, liability, equity, period_list, filters.company, currency + ) + message, opening_balance = check_opening_balance(asset, liability, equity) + + data = [] + data.extend(asset or []) + data.extend(liability or []) + data.extend(equity or []) + if opening_balance and round(opening_balance, 2) != 0: + unclosed = { + "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "warn_if_negative": True, + "currency": currency, + } + for period in period_list: + unclosed[period.key] = opening_balance + if provisional_profit_loss: + provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance + unclosed["total"] = opening_balance + data.append(unclosed) + + if provisional_profit_loss: + data.append(provisional_profit_loss) + if total_credit: + data.append(total_credit) + + columns = get_columns( + filters.periodicity, period_list, filters.accumulated_values, company=filters.company + ) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) + report_summary, primitive_summary = get_report_summary( + period_list, asset, liability, equity, provisional_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + return columns, data, message, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + year_start_date = period_list[0]["year_start_date"] + last_to_date = period_list[-1]["to_date"] + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + opening_from_date = None + ignore_opening_entries = False + + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + if not ignore_closing_balances: + last_pcv_list = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": company, + "period_end_date": ("<", filters.get("period_start_date") or year_start_date), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if last_pcv_list: + last_pcv = last_pcv_list[0] + pcv_entries = get_accounting_entries( + "Account Closing Balance", + None, + last_to_date, + filters, + root_type=root_type, + ignore_closing_entries=False, + period_closing_voucher=last_pcv.name, + ) + if filters.get("presentation_currency"): + convert_to_presentation_currency(pcv_entries, get_currency(filters)) + for entry in pcv_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + opening_from_date = add_days(last_pcv.period_end_date, 1) + ignore_opening_entries = True + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + ] + base_params = [company, *leaf_accounts] + if ignore_opening_entries and not ignore_is_opening: + base_conds.append("is_opening = 'No'") + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + # Opening GL entries from DuckDB (entries before year_start_date) + open_conds = [*base_conds, "posting_date < ?"] + open_params = [*base_params, year_start_date] + if opening_from_date: + open_conds = [*open_conds, "posting_date >= ?"] + open_params = [*open_params, opening_from_date] + + opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(opening_entries, get_currency(filters)) + synthetic_open_date = add_days(year_start_date, -1) + for entry in opening_entries: + entry.posting_date = synthetic_open_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + # Period GL entries from DuckDB (one aggregated query per period) + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index a49f6356122..a42a5f6195e 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -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-06-22 13:38:35.057216", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -30,5 +34,6 @@ "role": "Auditor" } ], + "synced_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 597ab75385a..6884877dae7 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -792,3 +792,288 @@ def get_columns(filters): columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}]) return columns + + +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + return _execute_with_duckdb_conn(filters, conn) + + frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def _execute_with_duckdb_conn(filters, conn): + if not filters: + return [], [] + + account_details = {} + + if filters.get("print_in_account_currency") and not filters.get("account"): + frappe.throw(_("Select an account to print in account currency")) + + for acc in frappe.get_all("Account", fields=["name", "is_group"]): + account_details.setdefault(acc.name, acc) + + if filters.get("party"): + filters.party = frappe.parse_json(filters.get("party")) + + validate_filters(filters, account_details) + validate_party(filters) + filters = set_account_currency(filters) + columns = get_columns(filters) + res = get_result_duckdb(filters, account_details, conn) + return columns, res + + +def get_result_duckdb(filters, account_details, conn): + accounting_dimensions = [] + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions() + + gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn) + data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries) + return get_result_as_list(data, filters) + + +def get_gl_entries_duckdb(filters, accounting_dimensions, conn): + currency_map = get_currency(filters) + + col_names = [ + "gl_entry", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + select_exprs = [ + "name", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + + if filters.get("show_remarks"): + remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length") + if remarks_length: + select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})") + else: + select_exprs.append("remarks") + col_names.append("remarks") + + if filters.get("add_values_in_transaction_currency"): + select_exprs += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + col_names += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + + if accounting_dimensions: + select_exprs += accounting_dimensions + col_names += accounting_dimensions + + order_by = "posting_date, account, creation" + if filters.get("include_dimensions"): + order_by = "posting_date, creation" + if filters.get("categorize_by") == "Categorize by Voucher": + order_by = "posting_date, voucher_type, voucher_no" + if filters.get("categorize_by") == "Categorize by Account": + order_by = "account, posting_date, creation" + + if filters.get("include_default_book_entries"): + filters["company_fb"] = frappe.get_cached_value( + "Company", filters.get("company"), "default_finance_book" + ) + + conditions, params = _build_gl_conditions_duckdb(filters) + select_clause = ", ".join(select_exprs) + sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}' + + rows = conn.execute(sql, params).fetchall() + gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows] + + party_name_map = get_party_name_map() + for gl_entry in gl_entries: + if gl_entry.party_type and gl_entry.party: + gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party) + + if filters.get("presentation_currency"): + return convert_to_presentation_currency(gl_entries, currency_map, filters) + return gl_entries + + +def _build_gl_conditions_duckdb(filters): + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + conditions = ["company = ?"] + params = [filters.company] + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + if filters.account: + conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})") + params.extend(filters.account) + + if filters.get("cost_center"): + filters.cost_center = get_cost_centers_with_children(filters.cost_center) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})") + params.extend(filters.cost_center) + + if filters.get("voucher_no"): + conditions.append("voucher_no = ?") + params.append(filters.voucher_no) + + if filters.get("against_voucher_no"): + conditions.append("against_voucher = ?") + params.append(filters.against_voucher_no) + + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + pluck="name", + ) + if err_journals: + filters.update({"voucher_no_not_in": err_journals}) + + if filters.get("ignore_cr_dr_notes"): + system_generated = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Credit Note", "Debit Note"]), + "is_system_generated": 1, + }, + pluck="name", + ) + if system_generated: + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated + filters.update({"voucher_no_not_in": vouchers_to_ignore}) + + if filters.get("voucher_no_not_in"): + vouchers = filters.voucher_no_not_in + conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})") + params.extend(vouchers) + + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): + conditions.append("party_type IN ('Customer', 'Supplier')") + + if filters.get("party_type"): + conditions.append("party_type = ?") + params.append(filters.party_type) + + if filters.get("party"): + conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})") + params.extend(filters.party) + + # from_date: skip when filtering by account/party to allow opening balance calc in Python + if filters.get("disable_opening_balance_calculation"): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + elif not ( + filters.get("account") + or filters.get("party") + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] + ): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + + if not ignore_is_opening: + conditions.append("(posting_date <= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date <= ?") + params.append(filters.to_date) + + if filters.get("project"): + conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})") + params.extend(filters.project) + + company_fb = filters.get("company_fb") or frappe.get_cached_value( + "Company", filters.company, "default_finance_book" + ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_vals = [cstr(filters.finance_book), ""] + else: + fb_vals = [cstr(company_fb), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)") + params.extend(fb_vals) + else: + if filters.get("finance_book"): + conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)") + params.append(cstr(filters.finance_book)) + else: + conditions.append("(finance_book IN ('') OR finance_book IS NULL)") + + if not filters.get("show_cancelled_entries"): + conditions.append("is_cancelled = 0") + + accounting_dimensions_list = get_accounting_dimensions(as_list=False) + if accounting_dimensions_list: + for dimension in accounting_dimensions_list: + if not dimension.disabled and dimension.document_type != "Finance Book": + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + vals = ( + filters[dimension.fieldname] + if isinstance(filters[dimension.fieldname], list) + else [filters[dimension.fieldname]] + ) + conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params 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 d92d6e8d241..67640b52c1b 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 @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 2, "is_standard": "Yes", - "modified": "2017-02-24 20:12:40.282376", + "modified": "2026-06-22 13:38:15.898375", "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" } - ] -} \ No newline at end of file + ], + "synced_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 3e2cc34fc68..3545842acf9 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 @@ -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_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw( + _("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")) + ) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list) + expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list) + + net_profit_loss = get_net_profit_loss( + income, expense, period_list, filters.company, filters.presentation_currency + ) + + data = [] + data.extend(income or []) + data.extend(expense or []) + if net_profit_loss: + data.append(net_profit_loss) + + columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company) + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency) + + report_summary, primitive_summary = get_report_summary( + period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + if filters.get("selected_view") == "Margin": + compute_margin_view_data(data, period_list, filters.accumulated_values) + + return columns, data, None, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + "voucher_type != 'Period Closing Voucher'", + ] + base_params = [company, *leaf_accounts] + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index af586f7f17d..25b565a124d 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -7,13 +7,18 @@ "doctype": "Report", "idx": 2, "is_standard": "Yes", - "modified": "2017-02-24 20:12:33.520866", + "modified": "2026-06-22 13:38:42.740436", "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" } - ] -} \ No newline at end of file + ], + "synced_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 536cbf610c6..50e0ec7b97d 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -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_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + validate_filters(filters) + columns = get_columns() + data = get_data_duckdb(filters, conn) + return columns, data + else: + frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def get_data_duckdb(filters, conn): + # 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 From 674704cbfd8189e008b085857a86b5d742d991ba Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 1 Jul 2026 13:27:21 +0530 Subject: [PATCH 03/12] refactor: rename execute_synced_report to execute_snapshot_report Match the framework rename of the standard report entry point in the trial balance, P&L, balance sheet, and general ledger reports. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit ba7b6a47c56f62c885191bf1a12faeace2967cb7) --- erpnext/accounts/report/balance_sheet/balance_sheet.py | 2 +- erpnext/accounts/report/general_ledger/general_ledger.py | 2 +- .../profit_and_loss_statement/profit_and_loss_statement.py | 2 +- erpnext/accounts/report/trial_balance/trial_balance.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index 3f1bedb63e8..13c2ba29a37 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -270,7 +270,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency): return chart -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if not (conn := get_latest_sync("GL Entry")): diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 6884877dae7..daee573615b 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -794,7 +794,7 @@ def get_columns(filters): return columns -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if conn := get_latest_sync("GL Entry"): diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 3545842acf9..f0f41d063cc 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 @@ -203,7 +203,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur return chart -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if not (conn := get_latest_sync("GL Entry")): diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 50e0ec7b97d..48e2ff039a5 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -512,7 +512,7 @@ def hide_group_accounts(data): return non_group_accounts_data -def execute_synced_report(filters): +def execute_snapshot_report(filters): from frappe.database.duckdb.database import get_latest_sync if conn := get_latest_sync("GL Entry"): From 1489258ea0d1020c9e986608c035207d7bae037f Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 1 Jul 2026 13:43:18 +0530 Subject: [PATCH 04/12] refactor: rename feature toggle in report master (cherry picked from commit 981e90e4da111fffeb954d138eddbf122ea67f59) # 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 --- .../accounts/report/accounts_payable/accounts_payable.json | 4 ++-- .../report/accounts_receivable/accounts_receivable.json | 4 ++-- erpnext/accounts/report/general_ledger/general_ledger.json | 4 ++-- .../profit_and_loss_statement/profit_and_loss_statement.json | 4 ++-- erpnext/accounts/report/trial_balance/trial_balance.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 6695d8a5799..f05944b8e46 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 3, "is_standard": "Yes", - "modified": "2026-06-25 12:03:36.559152", + "modified": "2026-07-01 13:37:41.185347", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -34,6 +34,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index 91f4c1167cc..cb4aa71f0e0 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 3, "is_standard": "Yes", - "modified": "2026-06-25 12:03:28.812092", + "modified": "2026-07-01 13:37:44.167999", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -28,6 +28,6 @@ "role": "Accounts User" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index a42a5f6195e..5af6ec01a7e 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -14,7 +14,7 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "modified": "2026-06-22 13:38:35.057216", + "modified": "2026-07-01 13:36:06.682661", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -34,6 +34,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 67640b52c1b..890d1ba46a4 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 @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 2, "is_standard": "Yes", - "modified": "2026-06-22 13:38:15.898375", + "modified": "2026-07-01 13:36:14.934965", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -32,6 +32,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 25b565a124d..49131ff004c 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -7,7 +7,7 @@ "doctype": "Report", "idx": 2, "is_standard": "Yes", - "modified": "2026-06-22 13:38:42.740436", + "modified": "2026-07-01 17:32:21.801141", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -31,6 +31,6 @@ "role": "Auditor" } ], - "synced_report": 0, + "snapshot_report": 0, "timeout": 0 } From bb5be1037273c1ad656a0674f46d709d23895d3f Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 13 Jul 2026 13:17:40 +0530 Subject: [PATCH 05/12] fix: allow barcode scan to add and increment items in pick list - allow new rows on scan when pick manually is enabled, since only then are scanned rows not subject to being overridden by set_item_locations on save - stop capping picked qty at the default demand qty (1) for rows added by the scanner itself, so repeat scans of the same barcode keep incrementing the row instead of failing with "maximum quantity scanned" - ignore barcode uom when matching an existing row if new rows aren't allowed, since there's no alternate-uom row to fall back to (cherry picked from commit 3ece4a615d0ce7a206a144acc78221c03df7135b) --- erpnext/public/js/utils/barcode_scanner.js | 13 +++++++++++-- erpnext/stock/doctype/pick_list/pick_list.js | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index 77246d82bc6..c1ce996792a 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); } @@ -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; diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index b5367cd846e..f89355439b3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -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", }; From e4d2a960d2f77a14ed82408726563ce3a9ef3b55 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:09:13 +0530 Subject: [PATCH 06/12] fix(asset): allow asset repair creation for fully depreciated assets (backport #55276) --- erpnext/assets/doctype/asset_repair/asset_repair.js | 9 +++++++++ erpnext/assets/doctype/asset_repair/asset_repair.json | 4 ++-- erpnext/assets/doctype/asset_repair/asset_repair.py | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 7f7d045ac92..a477c0bfcbc 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -74,6 +74,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" + ); + }); + } }, repair_status: (frm) => { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 45e531d6976..bfe74707517 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -140,7 +140,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", - "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]", "options": "Asset", "reqd": 1 }, @@ -251,7 +251,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-01-06 15:48:13.862505", + "modified": "2026-07-14 10:00:00.000000", "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 7e17dd70e46..793101f2072 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -77,12 +77,15 @@ class AssetRepair(AccountsController): ) def validate_asset(self): - if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + if self.asset_doc.status in ("Sold", "Scrapped"): frappe.throw( _("Asset {0} is in {1} status and cannot be repaired.").format( get_link_to_form("Asset", self.asset), self.asset_doc.status ) ) + if self.asset_doc.get_status() == "Fully Depreciated": + self.capitalize_repair_cost = 0 + self.increase_in_asset_life = 0 def validate_dates(self): if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)): From cc457433fe733baeee0d9dc9b0d3c0934484d788 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Mon, 13 Jul 2026 19:01:00 +0530 Subject: [PATCH 07/12] fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos (cherry picked from commit 4544a6c935818cb1f6f25ad52bef0654ebfb1736) --- .../serial_and_batch_bundle.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index e3428c98add..fd958d55c61 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 @@ -2477,22 +2477,24 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): def get_bundle_wise_serial_nos(data, kwargs): bundle_wise_serial_nos = defaultdict(list) - bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle] + bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle}) if not bundles: return bundle_wise_serial_nos - filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")} - - if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): - filters["serial_no"] = ("in", kwargs.get("serial_nos")) - - bundle_data = frappe.get_all( - "Serial and Batch Entry", - fields=["serial_no", "parent"], - filters=filters, + sabe = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sabe) + .select(sabe.serial_no, sabe.parent) + .where(sabe.parent.isin(bundles)) + .where(sabe.docstatus == 1) + .where(sabe.serial_no.isnotnull()) + .where(sabe.serial_no != "") ) - for d in bundle_data: + if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): + query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos"))) + + for d in query.run(as_dict=True): if d.parent: bundle_wise_serial_nos[d.parent].append(d.serial_no) From 80865428852eb88c108e9f4611b4ad2f0720656b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 11:51:40 +0530 Subject: [PATCH 08/12] fix(stock): show qty (company) and qty (warehouse) in sales transactions --- erpnext/public/js/utils/sales_common.js | 12 +++++------- erpnext/stock/get_item_details.py | 7 +++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index c9b5ed7e6f2..cc40727f6ec 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -280,19 +280,17 @@ erpnext.sales_common = { set_actual_qty(doc, cdt, cdn) { let row = locals[cdt][cdn]; - let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"]; + let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"]; if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) { - frappe.call({ + return this.frm.call({ method: "erpnext.stock.get_item_details.get_bin_details", + child: row, args: { item_code: row.item_code, warehouse: row.warehouse, - }, - callback(r) { - if (r.message) { - frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty); - } + company: doc.company, + include_child_warehouses: true, }, }); } diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index fe41802fdf0..5b2854b7b8d 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -177,10 +177,9 @@ def update_bin_details(args, out, doc): out.update(get_bin_details(args.item_code, args.get("from_warehouse"))) elif out.get("warehouse"): - company = args.company if (doc and doc.get("doctype") == "Purchase Order") else None - - # calculate company_total_stock only for po - bin_details = get_bin_details(args.item_code, out.warehouse, company, include_child_warehouses=True) + bin_details = get_bin_details( + args.item_code, out.warehouse, args.company, include_child_warehouses=True + ) out.update(bin_details) From aa4cc4c97d62cfb007aacfd8f9da633322511e15 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 11:51:48 +0530 Subject: [PATCH 09/12] test(stock): assert qty (company) and qty (warehouse) on item details --- erpnext/stock/tests/test_get_item_details.py | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index af98aa43980..d0b725b9179 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -35,6 +35,40 @@ class TestGetItemDetail(FrappeTestCase): 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) + def test_fetch_asset_category_expense_account_on_purchase_receipt(self): from erpnext.stock.doctype.item.test_item import make_item From dcff9164c42e0176451c84d9e0df9c29fd91ef3c Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Mon, 13 Jul 2026 19:01:50 +0530 Subject: [PATCH 10/12] test(stock): add unit test for get_bundle_wise_serial_nos query (cherry picked from commit e748bf512b3d3f430d19c9c9ed3c4fef93df6f76) --- .../test_serial_and_batch_bundle.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 100e62deafb..a301c1f3017 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 @@ -11,8 +11,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 @@ -1383,3 +1387,128 @@ def make_serial_batch_bundle(kwargs): return sb.make_serial_and_batch_bundle() return sb + + +class TestSerialandBatchBundleLogic(FrappeTestCase): + """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_calculate_total_qty_normalizes_and_signs(self): + inward = frappe.new_doc("Serial and Batch Bundle") + inward.type_of_transaction = "Inward" + inward.append("entries", {"qty": 5}) + inward.append("entries", {"qty": 3}) + inward.calculate_total_qty(save=False) + self.assertEqual(inward.total_qty, 8) + + # Outward flips the sign + outward = frappe.new_doc("Serial and Batch Bundle") + outward.type_of_transaction = "Outward" + outward.append("entries", {"qty": 5}) + outward.calculate_total_qty(save=False) + self.assertEqual(outward.total_qty, -5) + + # a serialized bundle normalizes each row qty to 1 + serialized = frappe.new_doc("Serial and Batch Bundle") + serialized.has_serial_no = 1 + serialized.type_of_transaction = "Inward" + serialized.append("entries", {"qty": 5}) + serialized.calculate_total_qty(save=False) + self.assertEqual(serialized.total_qty, 1) + + def test_get_bundle_wise_serial_nos(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_bundle_wise_serial_nos, + ) + + item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name + + bundles = [] + for _ in range(2): + se = make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=3, + rate=100, + ) + bundles.append(se.items[0].serial_and_batch_bundle) + + data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles] + + self.assertEqual(get_bundle_wise_serial_nos([], {}), {}) + + bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {}) + for bundle in bundles: + self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle)) + + # check_serial_nos must restrict the result to the requested serial nos + serial_no = get_serial_nos_from_bundle(bundles[0])[0] + bundle_wise_serial_nos = get_bundle_wise_serial_nos( + data, {"check_serial_nos": True, "serial_nos": [serial_no]} + ) + + self.assertNotIn(bundles[1], bundle_wise_serial_nos) + self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no]) From fc4ac7a4418a4692b9929f4a596817c817b7050c Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 13:23:28 +0530 Subject: [PATCH 11/12] fix(manufacturing): preserve job card transferred quantity (cherry picked from commit d7e9321746c04f405d8766e542f3fdbe57acb473) --- erpnext/manufacturing/doctype/work_order/work_order.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index dd5c07c2945..c28e0054c99 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1275,6 +1275,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("Material Transfer for Manufacture") From 1bbeec6bfa4200ea4fc98811ef96f04fda4820a2 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 14 Jul 2026 13:23:49 +0530 Subject: [PATCH 12/12] test(manufacturing): cover transferred quantity across job cards (cherry picked from commit fd6c9a71cdd2761a4907ad99aca552c975c479db) --- .../doctype/job_card/test_job_card.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index f59eb057de3..a50d0cba62b 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -347,6 +347,49 @@ class TestJobCard(FrappeTestCase): 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