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( diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 321722a29da..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": "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" } - ] -} \ No newline at end of file + ], + "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 1c99ac5f00b..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": "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" } - ] -} \ No newline at end of file + ], + "snapshot_report": 0, + "timeout": 0 +} diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index 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..13c2ba29a37 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_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + filters.period_start_date = period_list[0]["year_start_date"] + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + + asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list) + liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list) + equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list) + + provisional_profit_loss, total_credit = get_provisional_profit_loss( + asset, liability, equity, period_list, filters.company, currency + ) + message, opening_balance = check_opening_balance(asset, liability, equity) + + data = [] + data.extend(asset or []) + data.extend(liability or []) + data.extend(equity or []) + if opening_balance and round(opening_balance, 2) != 0: + unclosed = { + "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "warn_if_negative": True, + "currency": currency, + } + for period in period_list: + unclosed[period.key] = opening_balance + if provisional_profit_loss: + provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance + unclosed["total"] = opening_balance + data.append(unclosed) + + if provisional_profit_loss: + data.append(provisional_profit_loss) + if total_credit: + data.append(total_credit) + + columns = get_columns( + filters.periodicity, period_list, filters.accumulated_values, company=filters.company + ) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) + report_summary, primitive_summary = get_report_summary( + period_list, asset, liability, equity, provisional_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + return columns, data, message, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + year_start_date = period_list[0]["year_start_date"] + last_to_date = period_list[-1]["to_date"] + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + opening_from_date = None + ignore_opening_entries = False + + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + if not ignore_closing_balances: + last_pcv_list = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": company, + "period_end_date": ("<", filters.get("period_start_date") or year_start_date), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if last_pcv_list: + last_pcv = last_pcv_list[0] + pcv_entries = get_accounting_entries( + "Account Closing Balance", + None, + last_to_date, + filters, + root_type=root_type, + ignore_closing_entries=False, + period_closing_voucher=last_pcv.name, + ) + if filters.get("presentation_currency"): + convert_to_presentation_currency(pcv_entries, get_currency(filters)) + for entry in pcv_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + opening_from_date = add_days(last_pcv.period_end_date, 1) + ignore_opening_entries = True + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + ] + base_params = [company, *leaf_accounts] + if ignore_opening_entries and not ignore_is_opening: + base_conds.append("is_opening = 'No'") + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + # Opening GL entries from DuckDB (entries before year_start_date) + open_conds = [*base_conds, "posting_date < ?"] + open_params = [*base_params, year_start_date] + if opening_from_date: + open_conds = [*open_conds, "posting_date >= ?"] + open_params = [*open_params, opening_from_date] + + opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(opening_entries, get_currency(filters)) + synthetic_open_date = add_days(year_start_date, -1) + for entry in opening_entries: + entry.posting_date = synthetic_open_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + # Period GL entries from DuckDB (one aggregated query per period) + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index a49f6356122..5af6ec01a7e 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-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 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 597ab75385a..daee573615b 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_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + return _execute_with_duckdb_conn(filters, conn) + + frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def _execute_with_duckdb_conn(filters, conn): + if not filters: + return [], [] + + account_details = {} + + if filters.get("print_in_account_currency") and not filters.get("account"): + frappe.throw(_("Select an account to print in account currency")) + + for acc in frappe.get_all("Account", fields=["name", "is_group"]): + account_details.setdefault(acc.name, acc) + + if filters.get("party"): + filters.party = frappe.parse_json(filters.get("party")) + + validate_filters(filters, account_details) + validate_party(filters) + filters = set_account_currency(filters) + columns = get_columns(filters) + res = get_result_duckdb(filters, account_details, conn) + return columns, res + + +def get_result_duckdb(filters, account_details, conn): + accounting_dimensions = [] + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions() + + gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn) + data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries) + return get_result_as_list(data, filters) + + +def get_gl_entries_duckdb(filters, accounting_dimensions, conn): + currency_map = get_currency(filters) + + col_names = [ + "gl_entry", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + select_exprs = [ + "name", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + + if filters.get("show_remarks"): + remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length") + if remarks_length: + select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})") + else: + select_exprs.append("remarks") + col_names.append("remarks") + + if filters.get("add_values_in_transaction_currency"): + select_exprs += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + col_names += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + + if accounting_dimensions: + select_exprs += accounting_dimensions + col_names += accounting_dimensions + + order_by = "posting_date, account, creation" + if filters.get("include_dimensions"): + order_by = "posting_date, creation" + if filters.get("categorize_by") == "Categorize by Voucher": + order_by = "posting_date, voucher_type, voucher_no" + if filters.get("categorize_by") == "Categorize by Account": + order_by = "account, posting_date, creation" + + if filters.get("include_default_book_entries"): + filters["company_fb"] = frappe.get_cached_value( + "Company", filters.get("company"), "default_finance_book" + ) + + conditions, params = _build_gl_conditions_duckdb(filters) + select_clause = ", ".join(select_exprs) + sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}' + + rows = conn.execute(sql, params).fetchall() + gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows] + + party_name_map = get_party_name_map() + for gl_entry in gl_entries: + if gl_entry.party_type and gl_entry.party: + gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party) + + if filters.get("presentation_currency"): + return convert_to_presentation_currency(gl_entries, currency_map, filters) + return gl_entries + + +def _build_gl_conditions_duckdb(filters): + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + conditions = ["company = ?"] + params = [filters.company] + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + if filters.account: + conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})") + params.extend(filters.account) + + if filters.get("cost_center"): + filters.cost_center = get_cost_centers_with_children(filters.cost_center) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})") + params.extend(filters.cost_center) + + if filters.get("voucher_no"): + conditions.append("voucher_no = ?") + params.append(filters.voucher_no) + + if filters.get("against_voucher_no"): + conditions.append("against_voucher = ?") + params.append(filters.against_voucher_no) + + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + pluck="name", + ) + if err_journals: + filters.update({"voucher_no_not_in": err_journals}) + + if filters.get("ignore_cr_dr_notes"): + system_generated = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Credit Note", "Debit Note"]), + "is_system_generated": 1, + }, + pluck="name", + ) + if system_generated: + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated + filters.update({"voucher_no_not_in": vouchers_to_ignore}) + + if filters.get("voucher_no_not_in"): + vouchers = filters.voucher_no_not_in + conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})") + params.extend(vouchers) + + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): + conditions.append("party_type IN ('Customer', 'Supplier')") + + if filters.get("party_type"): + conditions.append("party_type = ?") + params.append(filters.party_type) + + if filters.get("party"): + conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})") + params.extend(filters.party) + + # from_date: skip when filtering by account/party to allow opening balance calc in Python + if filters.get("disable_opening_balance_calculation"): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + elif not ( + filters.get("account") + or filters.get("party") + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] + ): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + + if not ignore_is_opening: + conditions.append("(posting_date <= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date <= ?") + params.append(filters.to_date) + + if filters.get("project"): + conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})") + params.extend(filters.project) + + company_fb = filters.get("company_fb") or frappe.get_cached_value( + "Company", filters.company, "default_finance_book" + ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_vals = [cstr(filters.finance_book), ""] + else: + fb_vals = [cstr(company_fb), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)") + params.extend(fb_vals) + else: + if filters.get("finance_book"): + conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)") + params.append(cstr(filters.finance_book)) + else: + conditions.append("(finance_book IN ('') OR finance_book IS NULL)") + + if not filters.get("show_cancelled_entries"): + conditions.append("is_cancelled = 0") + + accounting_dimensions_list = get_accounting_dimensions(as_list=False) + if accounting_dimensions_list: + for dimension in accounting_dimensions_list: + if not dimension.disabled and dimension.document_type != "Finance Book": + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + vals = ( + filters[dimension.fieldname] + if isinstance(filters[dimension.fieldname], list) + else [filters[dimension.fieldname]] + ) + conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index d92d6e8d241..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": "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" } - ] -} \ No newline at end of file + ], + "snapshot_report": 0, + "timeout": 0 +} diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 3e2cc34fc68..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 @@ -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) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index af586f7f17d..49131ff004c 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-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" } - ] -} \ No newline at end of file + ], + "snapshot_report": 0, + "timeout": 0 +} diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 536cbf610c6..48e2ff039a5 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_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + validate_filters(filters) + columns = get_columns() + data = get_data_duckdb(filters, conn) + return columns, data + else: + frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def get_data_duckdb(filters, conn): + # accounts and all metadata via frappe.db — only GL Entry comes from DuckDB + accounts = frappe.db.sql( + """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt + from `tabAccount` where company=%s order by lft""", + filters.company, + as_dict=True, + ) + if not accounts: + return None + + company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + + gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) + opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening) + + calculate_values( + accounts, + gl_entries_by_account, + opening_balances, + filters.get("show_net_values"), + ignore_is_opening=ignore_is_opening, + ) + accumulate_values_into_parents(accounts, accounts_by_name) + + data = prepare_data(accounts, filters, parent_children_map, company_currency) + return filter_out_zero_value_rows( + data, parent_children_map, show_zero_values=filters.get("show_zero_values") + ) + + +def _extra_gl_conditions(filters): + """Returns (conditions, params) for optional shared GL Entry filters.""" + conditions, params = [], [] + + if filters.get("cost_center"): + cc = get_cost_centers_with_children(filters.get("cost_center")) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})") + params.extend(cc) + + if filters.get("project"): + proj = filters.project if isinstance(filters.project, list) else [filters.project] + conditions.append(f"project IN ({', '.join(['?'] * len(proj))})") + params.extend(proj) + + if frappe.db.count("Finance Book"): + company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") + if filters.get("include_default_book_entries"): + if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] + else: + fb_list = [cstr(filters.get("finance_book")), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)") + params.extend(fb_list) + + for dim in get_accounting_dimensions(as_list=False): + if filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, filters.get(dim.fieldname) + ) + vals = ( + filters[dim.fieldname] + if isinstance(filters[dim.fieldname], list) + else [filters[dim.fieldname]] + ) + conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params + + +def _fetch_gl_rows_duckdb(conn, conditions, params): + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + sql = f"""SELECT account, SUM(debit), SUM(credit), + SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency + FROM "tabGL Entry" WHERE {" AND ".join(conditions)} + GROUP BY account, account_currency""" + return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()] + + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + if not flt(filters.get("with_period_closing_entry_for_current_period")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + entries = _fetch_gl_rows_duckdb(conn, conditions, params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(entries, get_currency(filters)) + + gl_entries_by_account = {} + for entry in entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + return gl_entries_by_account + + +def get_opening_balances_duckdb(conn, filters, ignore_is_opening): + bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs.update(pl) + return bs + + +def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening): + accounting_dimensions = get_accounting_dimensions(as_list=False) + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + last_pcv = "" + + if not ignore_closing_balances: + last_pcv = frappe.db.get_all( + "Period Closing Voucher", + filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + + if last_pcv: + # Account Closing Balance fetched via frappe (not GL Entry) + gle = get_opening_balance( + "Account Closing Balance", + filters, + report_type, + accounting_dimensions, + period_closing_voucher=last_pcv[0].name, + ignore_is_opening=ignore_is_opening, + ) + if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_pcv[0].period_end_date, 1) + gle += _get_gl_entry_opening_duckdb( + conn, filters, report_type, ignore_is_opening, start_date=start_date + ) + else: + gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening) + + opening = frappe._dict() + for d in gle: + opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) + opening[d.account]["opening_debit"] += flt(d.debit) + opening[d.account]["opening_credit"] += flt(d.credit) + return opening + + +def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None): + accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name") + if not accounts: + return [] + + conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"] + params = [filters.company, *accounts] + + if start_date: + conditions.append("posting_date >= ? AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + elif not ignore_is_opening: + conditions.append("(posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) + else: + conditions.append("posting_date < ?") + params.append(filters.from_date) + + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + conditions.append("posting_date >= ?") + params.append(filters.year_start_date) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + gle = _fetch_gl_rows_duckdb(conn, conditions, params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(gle, get_currency(filters)) + return gle diff --git a/erpnext/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)): 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 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") 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/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/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", }; 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) 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]) 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) 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