From 6c1620ab8cb816733de4e613af67ff4374ed0f84 Mon Sep 17 00:00:00 2001 From: Karuppasamy B Date: Thu, 13 Nov 2025 01:16:48 +0530 Subject: [PATCH 01/51] fix(purchase_receipt): add internal_and_external_links field to show purchase invoice connection count --- .../doctype/purchase_receipt/purchase_receipt_dashboard.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py index b1b0a962246..628b4628f79 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py @@ -18,6 +18,9 @@ def get_data(): "Purchase Order": ["items", "purchase_order"], "Project": ["items", "project"], }, + "internal_and_external_links": { + "Purchase Invoice": ["items", "purchase_invoice"], + }, "transactions": [ { "label": _("Related"), From 59c3eef7dbf6c93f5b037d7e317e283f28e98a39 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 13:00:47 +0530 Subject: [PATCH 02/51] fix: stock entry manufacture - fix operating cost calculation --- erpnext/manufacturing/doctype/bom/bom.py | 1 + .../landed_cost_taxes_and_charges.json | 12 ++++++++-- .../landed_cost_taxes_and_charges.py | 1 + .../stock/doctype/stock_entry/stock_entry.py | 23 ++++++++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 754a64e11bb..753d5a6ec4d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1574,6 +1574,7 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_ "expense_account": expense_account, "description": _("Operating Cost as per Work Order / BOM"), "amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty), + "has_operating_cost": 1, }, ) diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index dac161a46ff..1bbafc08446 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -12,7 +12,8 @@ "col_break3", "amount", "base_amount", - "has_corrective_cost" + "has_corrective_cost", + "has_operating_cost" ], "fields": [ { @@ -70,13 +71,20 @@ "fieldtype": "Check", "label": "Has Corrective Cost", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_operating_cost", + "fieldtype": "Check", + "label": "Has Operating Cost", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-09 10:22:20.286641", + "modified": "2025-07-16 15:27:59.175530", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py index a3f7f037d60..a4fb129a7ae 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py @@ -21,6 +21,7 @@ class LandedCostTaxesandCharges(Document): exchange_rate: DF.Float expense_account: DF.Link | None has_corrective_cost: DF.Check + has_operating_cost: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e67192a4299..540de088c42 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3418,6 +3418,25 @@ def get_work_order_details(work_order, company): def get_operating_cost_per_unit(work_order=None, bom_no=None): + def get_consumed_operating_cost(wo_name, bom_no): + table = frappe.qb.DocType("Stock Entry") + child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(child_table) + .join(table) + .on(child_table.parent == table.name) + .select(Sum(child_table.amount).as_("consumed_cost")) + .where( + (table.docstatus == 1) + & (table.work_order == wo_name) + & (table.purpose == "Manufacture") + & (table.bom_no == bom_no) + & (child_table.has_operating_cost == 1) + ) + ) + cost = query.run(pluck="consumed_cost") + return cost[0] if cost and cost[0] else 0 + operating_cost_per_unit = 0 if work_order: if ( @@ -3434,7 +3453,9 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): for d in work_order.get("operations"): if flt(d.completed_qty): - operating_cost_per_unit += flt(d.actual_operating_cost) / flt(d.completed_qty) + operating_cost_per_unit += flt( + d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no) + ) / flt(d.completed_qty - work_order.produced_qty) elif work_order.qty: operating_cost_per_unit += flt(d.planned_operating_cost) / flt(work_order.qty) From ae0d9d1134c8c7c353af5c2ea606fa7c8af1bdb8 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 19 Nov 2025 14:14:46 +0530 Subject: [PATCH 03/51] fix: validate reserved batches --- erpnext/controllers/stock_controller.py | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 12b24c420d4..5847d9d3adf 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1183,6 +1183,91 @@ class StockController(AccountsController): self.doctype, self.name, self.docstatus, via_landed_cost_voucher=via_landed_cost_voucher ) + self.validate_reserved_batches() + + def validate_reserved_batches(self): + if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + if self.doctype not in ["Delivery Note", "Sales Invoice", "Stock Entry"]: + return + + batches = frappe.get_all( + "Serial and Batch Entry", + filters={ + "voucher_type": self.doctype, + "voucher_no": self.name, + "docstatus": 1, + "batch_no": ("is", "set"), + "qty": ("<", 0), + }, + pluck="batch_no", + ) + + if not batches: + return + + field_mapper = { + "Sales Invoice": [["Sales Order", "sales_order"]], + "Delivery Note": [["Sales Order", "against_sales_order"]], + "Stock Entry": [ + ["Work Order", "work_order"], + ["Subcontracting Inward Order", "subcontracting_inward_order"], + ], + }.get(self.doctype) + + reserved_batches_data = self.get_reserved_batches(batches) + items = self.items + if self.doctype == "Stock Entry": + items = [self] + + for item in items: + for field in field_mapper: + if not item.get(field[1]): + continue + + value = item.get(field[1]) + for row in reserved_batches_data: + if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get( + "item_code" + ): + continue + + if row.voucher_no == value: + continue + + frappe.throw( + _( + "The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}." + ).format( + frappe.bold(row.batch_no), + frappe.bold(row.voucher_type), + frappe.bold(row.voucher_no), + frappe.bold(self.doctype), + frappe.bold(self.name), + frappe.bold(field[0]), + frappe.bold(value), + ), + title=_("Reserved Batch Conflict"), + ) + + def get_reserved_batches(self, batches): + doctype = frappe.qb.DocType("Stock Reservation Entry") + child_doc = frappe.qb.DocType("Serial and Batch Entry") + + return ( + frappe.qb.from_(doctype) + .join(child_doc) + .on(doctype.name == child_doc.parent) + .select( + child_doc.batch_no, + doctype.voucher_type, + doctype.voucher_no, + doctype.item_code, + ) + .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches))) + ).run(as_dict=True) + def make_gl_entries_on_cancel(self, from_repost=False): if not from_repost: cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) From 2a1eb08b0810393558fbe6fc014c3adcff843170 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 19 Nov 2025 15:50:42 +0530 Subject: [PATCH 04/51] fix: replace `this` with function path --- erpnext/public/js/financial_statements.js | 33 +++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 8a3ac40d212..9ffe867aa17 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -4,13 +4,17 @@ erpnext.financial_statements = { filters: get_filters(), baseData: null, formatter: function (value, row, column, data, default_formatter, filter) { + const report_params = [value, row, column, data, default_formatter, filter]; // Growth/Margin - if (this._is_special_view(column, data)) - return this._format_special_view(value, row, column, data, default_formatter); + if (erpnext.financial_statements._is_special_view(column, data)) + return erpnext.financial_statements._format_special_view(...report_params); if (frappe.query_report.get_filter_value("report_template")) - return this._format_custom_report(value, row, column, data, default_formatter, filter); - else return this._format_standard_report(value, row, column, data, default_formatter, filter); + return erpnext.financial_statements._format_custom_report(...report_params); + + if (frappe.query_report.get_filter_value("report_template")) + return erpnext.financial_statements._format_custom_report(...report_params); + else return erpnext.financial_statements._format_standard_report(...report_params); }, _is_special_view: function (column, data) { @@ -20,11 +24,11 @@ erpnext.financial_statements = { }, _format_custom_report: function (value, row, column, data, default_formatter, filter) { - const columnInfo = this._parse_column_info(column.fieldname, data); - const formatting = this._get_formatting_for_column(data, columnInfo); + const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data); + const formatting = erpnext.financial_statements._get_formatting_for_column(data, columnInfo); if (columnInfo.isAccount) { - return this._format_custom_account_column( + return erpnext.financial_statements._format_custom_account_column( value, data, formatting, @@ -33,7 +37,14 @@ erpnext.financial_statements = { row ); } else { - return this._format_custom_value_column(value, data, formatting, column, default_formatter, row); + return erpnext.financial_statements._format_custom_value_column( + value, + data, + formatting, + column, + default_formatter, + row + ); } }, @@ -99,7 +110,7 @@ erpnext.financial_statements = { } // Style - return this._style_custom_value(formattedValue, formatting, null); + return erpnext.financial_statements._style_custom_value(formattedValue, formatting, null); }, _format_custom_value_column: function (value, data, formatting, column, default_formatter, row) { @@ -111,7 +122,7 @@ erpnext.financial_statements = { if (col.fieldtype === "Float") col.options = null; let formattedValue = default_formatter(value, row, col, data); - return this._style_custom_value(formattedValue, formatting, value); + return erpnext.financial_statements._style_custom_value(formattedValue, formatting, value); }, _style_custom_value(formattedValue, formatting, value) { @@ -157,7 +168,7 @@ erpnext.financial_statements = { }, _format_standard_report: function (value, row, column, data, default_formatter, filter) { - if (data && column.fieldname == this.name_field) { + if (data && column.fieldname == erpnext.financial_statements.name_field) { value = data.section_name || data.account_name || value; if (filter && filter?.text && filter?.type == "contains") { From 37b120bf69adab1d1f266678f8ae50302f6b7128 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:56:13 +0530 Subject: [PATCH 05/51] fix: modify for new changes --- erpnext/manufacturing/doctype/bom/bom.py | 18 ++++++--- .../stock/doctype/stock_entry/stock_entry.py | 37 ++++++++++--------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 753d5a6ec4d..2c3d8b1279b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1503,7 +1503,7 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card= def add_operating_cost_component_wise( - stock_entry, work_order=None, operating_cost_per_unit=None, op_expense_account=None, job_card=None + stock_entry, work_order=None, consumed_operating_cost=None, op_expense_account=None, job_card=None ): if not work_order: return False @@ -1527,11 +1527,11 @@ def add_operating_cost_component_wise( get_component_account(wc.operating_component, stock_entry.company) or op_expense_account ) actual_cp_operating_cost = flt( - flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0), + flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0) - consumed_operating_cost, row.precision("actual_operating_cost"), ) - per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty) + per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty - work_order.produced_qty) if per_unit_cost and expense_account: stock_entry.append( @@ -1542,6 +1542,7 @@ def add_operating_cost_component_wise( wc.operating_component, row.operation ), "amount": per_unit_cost * flt(stock_entry.fg_completed_qty), + "has_operating_cost": 1, }, ) @@ -1558,13 +1559,20 @@ def get_component_account(parent, company): def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None): - from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit + from erpnext.stock.doctype.stock_entry.stock_entry import ( + get_consumed_operating_cost, + get_operating_cost_per_unit, + ) operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no) if operating_cost_per_unit: cost_added = add_operating_cost_component_wise( - stock_entry, work_order, operating_cost_per_unit, expense_account, job_card=job_card + stock_entry, + work_order, + get_consumed_operating_cost(work_order.name, stock_entry.bom_no), + expense_account, + job_card=job_card, ) if not cost_added: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 540de088c42..b386955f9a5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3417,26 +3417,27 @@ def get_work_order_details(work_order, company): } -def get_operating_cost_per_unit(work_order=None, bom_no=None): - def get_consumed_operating_cost(wo_name, bom_no): - table = frappe.qb.DocType("Stock Entry") - child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") - query = ( - frappe.qb.from_(child_table) - .join(table) - .on(child_table.parent == table.name) - .select(Sum(child_table.amount).as_("consumed_cost")) - .where( - (table.docstatus == 1) - & (table.work_order == wo_name) - & (table.purpose == "Manufacture") - & (table.bom_no == bom_no) - & (child_table.has_operating_cost == 1) - ) +def get_consumed_operating_cost(wo_name, bom_no): + table = frappe.qb.DocType("Stock Entry") + child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(child_table) + .join(table) + .on(child_table.parent == table.name) + .select(Sum(child_table.amount).as_("consumed_cost")) + .where( + (table.docstatus == 1) + & (table.work_order == wo_name) + & (table.purpose == "Manufacture") + & (table.bom_no == bom_no) + & (child_table.has_operating_cost == 1) ) - cost = query.run(pluck="consumed_cost") - return cost[0] if cost and cost[0] else 0 + ) + cost = query.run(pluck="consumed_cost") + return cost[0] if cost and cost[0] else 0 + +def get_operating_cost_per_unit(work_order=None, bom_no=None): operating_cost_per_unit = 0 if work_order: if ( From b6e452a695ed3a5734cfe0d5bfe23d9634206acb Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 6 Oct 2025 15:15:13 +0530 Subject: [PATCH 06/51] feat: show budget total --- erpnext/accounts/doctype/budget/budget.json | 29 ++++++++++++++++++--- erpnext/accounts/doctype/budget/budget.py | 5 ++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index fcd78691a03..5b62b7eec92 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -33,7 +33,11 @@ "action_if_annual_exceeded_on_cumulative_expense", "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "section_break_21", - "accounts" + "accounts", + "section_break_hqka", + "column_break_gnot", + "column_break_ybiq", + "total_budget_amount" ], "fields": [ { @@ -188,7 +192,8 @@ }, { "fieldname": "section_break_21", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "accounts", @@ -232,13 +237,31 @@ "fieldtype": "Select", "label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense", "options": "\nStop\nWarn\nIgnore" + }, + { + "fieldname": "section_break_hqka", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_gnot", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_ybiq", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_budget_amount", + "fieldtype": "Currency", + "label": "Total Budget Amount", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-06-16 15:57:13.114981", + "modified": "2025-10-06 14:55:07.247313", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index a55c189f783..2472e813cd1 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -53,6 +53,7 @@ class Budget(Document): monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None + total_budget_amount: DF.Currency # end: auto-generated types def validate(self): @@ -62,6 +63,7 @@ class Budget(Document): self.validate_accounts() self.set_null_value() self.validate_applicable_for() + self.set_total_budget_amount() def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) @@ -139,6 +141,9 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 + def set_total_budget_amount(self): + self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 906a4bd398f229d677f23674ac5c70998dc91a84 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 6 Oct 2025 17:24:52 +0530 Subject: [PATCH 07/51] feat(patch): set total budget amount on budget doctype --- erpnext/patches.txt | 2 +- erpnext/patches/v16_0/set_total_budget_amount.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v16_0/set_total_budget_amount.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 51d4f66b9bb..a894f8cb44a 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -448,4 +448,4 @@ erpnext.patches.v16_0.update_serial_batch_entries erpnext.patches.v16_0.set_company_wise_warehouses erpnext.patches.v16_0.set_valuation_method_on_companies erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table - +erpnext.patches.v16_0.set_total_budget_amount diff --git a/erpnext/patches/v16_0/set_total_budget_amount.py b/erpnext/patches/v16_0/set_total_budget_amount.py new file mode 100644 index 00000000000..163f0f17274 --- /dev/null +++ b/erpnext/patches/v16_0/set_total_budget_amount.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + if frappe.db.has_column("Budget", "total_budget_amount"): + frappe.db.sql( + """ + UPDATE `tabBudget` b + SET b.total_budget_amount = ( + SELECT SUM(ba.budget_amount) + FROM `tabBudget Account` ba + WHERE ba.parent = b.name + ) + WHERE IFNULL(b.total_budget_amount, 0) = 0 + """ + ) From e23d229e7bd3db25bc79a4167d2daba4337434c4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 13 Oct 2025 12:20:41 +0530 Subject: [PATCH 08/51] feat: introduce budget distribution child table --- erpnext/accounts/doctype/budget/budget.json | 16 +++++- erpnext/accounts/doctype/budget/budget.py | 2 + .../doctype/budget_distribution/__init__.py | 0 .../budget_distribution.json | 56 +++++++++++++++++++ .../budget_distribution.py | 26 +++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 erpnext/accounts/doctype/budget_distribution/__init__.py create mode 100644 erpnext/accounts/doctype/budget_distribution/budget_distribution.json create mode 100644 erpnext/accounts/doctype/budget_distribution/budget_distribution.py diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 5b62b7eec92..ef347a37082 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -37,7 +37,9 @@ "section_break_hqka", "column_break_gnot", "column_break_ybiq", - "total_budget_amount" + "total_budget_amount", + "section_break_fpdt", + "budget_distribution" ], "fields": [ { @@ -255,13 +257,23 @@ "fieldtype": "Currency", "label": "Total Budget Amount", "read_only": 1 + }, + { + "fieldname": "section_break_fpdt", + "fieldtype": "Section Break" + }, + { + "fieldname": "budget_distribution", + "fieldtype": "Table", + "label": "Budget Distribution", + "options": "Budget Distribution" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-06 14:55:07.247313", + "modified": "2025-10-12 23:44:49.632709", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 2472e813cd1..3c7e2b04b8d 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -31,6 +31,7 @@ class Budget(Document): from frappe.types import DF from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount + from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution accounts: DF.Table[BudgetAccount] action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] @@ -47,6 +48,7 @@ class Budget(Document): applicable_on_material_request: DF.Check applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] + budget_distribution: DF.Table[BudgetDistribution] company: DF.Link cost_center: DF.Link | None fiscal_year: DF.Link diff --git a/erpnext/accounts/doctype/budget_distribution/__init__.py b/erpnext/accounts/doctype/budget_distribution/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json new file mode 100644 index 00000000000..5c633fa392b --- /dev/null +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-10-12 23:31:03.841996", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "start_date", + "end_date", + "amount", + "percent" + ], + "fields": [ + { + "fieldname": "start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date", + "search_index": 1 + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "End Date" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount" + }, + { + "fieldname": "percent", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Percent" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-10-12 23:47:30.393908", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Budget Distribution", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.py b/erpnext/accounts/doctype/budget_distribution/budget_distribution.py new file mode 100644 index 00000000000..4c2cb3bb1bf --- /dev/null +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BudgetDistribution(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amount: DF.Currency + end_date: DF.Date | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + percent: DF.Percent + start_date: DF.Date | None + # end: auto-generated types + + pass From ccb89fee755b7b56f96b1077c27ee4dd594db432 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 13 Oct 2025 16:27:09 +0530 Subject: [PATCH 09/51] feat: add fields for new budget flow --- erpnext/accounts/doctype/budget/budget.json | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index ef347a37082..bae88b9ffc5 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -16,6 +16,9 @@ "column_break_3", "monthly_distribution", "amended_from", + "account", + "distribution_type", + "allocation_frequency", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -267,13 +270,36 @@ "fieldtype": "Table", "label": "Budget Distribution", "options": "Budget Distribution" + }, + { + "fieldname": "account", + "fieldtype": "Link", + "label": "Account", + "options": "Account", + "reqd": 1 + }, + { + "default": "Percent", + "fieldname": "distribution_type", + "fieldtype": "Select", + "label": "Distribution Type", + "options": "Amount\nPercent", + "reqd": 1 + }, + { + "default": "Monthly", + "fieldname": "allocation_frequency", + "fieldtype": "Select", + "label": "Allocation Frequency", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-12 23:44:49.632709", + "modified": "2025-10-13 16:15:53.046278", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From 88570379717e16a2a6f7b6aac6687f78c05e245e Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 01:38:46 +0530 Subject: [PATCH 10/51] feat: flexible budget allocation frequency --- erpnext/accounts/doctype/budget/budget.json | 16 +++++++++++++++- erpnext/accounts/doctype/budget/budget.py | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index bae88b9ffc5..129ecd31be8 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -19,6 +19,8 @@ "account", "distribution_type", "allocation_frequency", + "budget_start_date", + "budget_end_date", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -286,6 +288,18 @@ "options": "Amount\nPercent", "reqd": 1 }, + { + "fieldname": "budget_start_date", + "fieldtype": "Date", + "label": "Budget Start Date", + "reqd": 1 + }, + { + "fieldname": "budget_end_date", + "fieldtype": "Date", + "label": "Budget End Date", + "reqd": 1 + }, { "default": "Monthly", "fieldname": "allocation_frequency", @@ -299,7 +313,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-13 16:15:53.046278", + "modified": "2025-10-15 01:20:30.551362", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 3c7e2b04b8d..0c4cb18087d 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -2,10 +2,12 @@ # For license information, please see license.txt +from datetime import date + import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate +from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -33,6 +35,7 @@ class Budget(Document): from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution + account: DF.Link accounts: DF.Table[BudgetAccount] action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] @@ -42,6 +45,7 @@ class Budget(Document): action_if_annual_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"] + allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly", "Date Range"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -49,8 +53,11 @@ class Budget(Document): applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] budget_distribution: DF.Table[BudgetDistribution] + budget_end_date: DF.Date + budget_start_date: DF.Date company: DF.Link cost_center: DF.Link | None + distribution_type: DF.Literal["Amount", "Percent"] fiscal_year: DF.Link monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] From d8deb33c8c3224b04134825794411c928f93e94b Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 02:13:41 +0530 Subject: [PATCH 11/51] feat: auto-generate budget distribution rows based on start and end date --- erpnext/accounts/doctype/budget/budget.py | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 0c4cb18087d..7df2187e8ef 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -8,6 +8,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff +from frappe.utils.data import get_first_day from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -153,6 +154,63 @@ class Budget(Document): def set_total_budget_amount(self): self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) + def before_save(self): + self.allocate_budget() + + def allocate_budget(self): + self.set("budget_distribution", []) + if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): + return + + start = getdate(self.budget_start_date) + end = getdate(self.budget_end_date) + freq = self.allocation_frequency + + current = start + + if freq == "Monthly": + while current <= end: + row = self.append("budget_distribution", {}) + row.start_date = get_first_day(current) + row.end_date = get_last_day(current) + current = add_months(current, 1) + + elif freq == "Quarterly": + while current <= end: + row = self.append("budget_distribution", {}) + + month = ((current.month - 1) // 3) * 3 + 1 + quarter_start = date(current.year, month, 1) + quarter_end = get_last_day(add_months(quarter_start, 2)) + if quarter_end > end: + quarter_end = end + row.start_date = quarter_start + row.end_date = quarter_end + current = add_months(quarter_start, 3) + + elif freq == "Half-Yearly": + while current <= end: + row = self.append("budget_distribution", {}) + half = 1 if current.month <= 6 else 2 + half_start = date(current.year, 1, 1) if half == 1 else date(current.year, 7, 1) + half_end = date(current.year, 6, 30) if half == 1 else date(current.year, 12, 31) + if half_end > end: + half_end = end + row.start_date = half_start + row.end_date = half_end + current = add_months(half_start, 6) + + elif freq == "Yearly": + while current <= end: + row = self.append("budget_distribution", {}) + year_start = date(current.year, 1, 1) + year_end = date(current.year, 12, 31) + if year_end > end: + year_end = end + row.start_date = year_start + row.end_date = year_end + current = date(current.year + 1, 1, 1) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 882b6c29500422fb1fd54747dc86bd5c6f106f1a Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 14:43:48 +0530 Subject: [PATCH 12/51] feat: add budget amount field on parent --- erpnext/accounts/doctype/budget/budget.json | 10 ++- erpnext/accounts/doctype/budget/budget.py | 68 +++++++++++++-------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 129ecd31be8..01953359c37 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -12,15 +12,16 @@ "company", "cost_center", "project", + "account", "fiscal_year", "column_break_3", "monthly_distribution", "amended_from", - "account", "distribution_type", "allocation_frequency", "budget_start_date", "budget_end_date", + "budget_amount", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -307,13 +308,18 @@ "label": "Allocation Frequency", "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", "reqd": 1 + }, + { + "fieldname": "budget_amount", + "fieldtype": "Currency", + "label": "Budget Amount" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 01:20:30.551362", + "modified": "2025-10-15 02:29:23.201493", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 7df2187e8ef..df9ec661423 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -53,6 +53,7 @@ class Budget(Document): applicable_on_material_request: DF.Check applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] + budget_amount: DF.Currency budget_distribution: DF.Table[BudgetDistribution] budget_end_date: DF.Date budget_start_date: DF.Date @@ -166,51 +167,68 @@ class Budget(Document): end = getdate(self.budget_end_date) freq = self.allocation_frequency + months = month_diff(end, start) + 1 + if freq == "Monthly": + total_periods = months + elif freq == "Quarterly": + total_periods = months // 3 + (1 if months % 3 else 0) + elif freq == "Half-Yearly": + total_periods = months // 6 + (1 if months % 6 else 0) + else: + total_periods = end.year - start.year + 1 + + if self.distribution_type == "Amount": + per_row = flt(self.budget_amount / total_periods, 2) + else: + per_row = flt(100 / total_periods, 2) + + assigned = 0 current = start - if freq == "Monthly": - while current <= end: - row = self.append("budget_distribution", {}) + while current <= end: + row = self.append("budget_distribution", {}) + + if freq == "Monthly": row.start_date = get_first_day(current) row.end_date = get_last_day(current) current = add_months(current, 1) - - elif freq == "Quarterly": - while current <= end: - row = self.append("budget_distribution", {}) - + elif freq == "Quarterly": month = ((current.month - 1) // 3) * 3 + 1 quarter_start = date(current.year, month, 1) quarter_end = get_last_day(add_months(quarter_start, 2)) - if quarter_end > end: - quarter_end = end row.start_date = quarter_start - row.end_date = quarter_end + row.end_date = min(quarter_end, end) current = add_months(quarter_start, 3) - - elif freq == "Half-Yearly": - while current <= end: - row = self.append("budget_distribution", {}) + elif freq == "Half-Yearly": half = 1 if current.month <= 6 else 2 half_start = date(current.year, 1, 1) if half == 1 else date(current.year, 7, 1) half_end = date(current.year, 6, 30) if half == 1 else date(current.year, 12, 31) - if half_end > end: - half_end = end row.start_date = half_start - row.end_date = half_end + row.end_date = min(half_end, end) current = add_months(half_start, 6) - - elif freq == "Yearly": - while current <= end: - row = self.append("budget_distribution", {}) + else: # Yearly year_start = date(current.year, 1, 1) year_end = date(current.year, 12, 31) - if year_end > end: - year_end = end row.start_date = year_start - row.end_date = year_end + row.end_date = min(year_end, end) current = date(current.year + 1, 1, 1) + if self.distribution_type == "Amount": + if len(self.budget_distribution) == total_periods: + row.amount = flt(self.budget_amount - assigned) + + else: + row.amount = per_row + assigned += per_row + row.percent = flt(row.amount * 100 / self.budget_amount) + else: + if len(self.budget_distribution) == total_periods: + row.percent = flt(100 - assigned) + else: + row.percent = per_row + assigned += per_row + row.amount = flt(row.percent * self.budget_amount / 100) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 077692b57be26d73fb5e1e53896e69f1c4165f34 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 24 Oct 2025 23:35:11 +0530 Subject: [PATCH 13/51] feat: Budget Revision --- erpnext/accounts/doctype/budget/budget.js | 33 +++++++++++++++++++ erpnext/accounts/doctype/budget/budget.json | 16 +++++++-- erpnext/accounts/doctype/budget/budget.py | 23 ++++++++++++- .../budget_distribution.json | 8 +++-- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index d3931dec3db..1186b60ab11 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -32,6 +32,16 @@ frappe.ui.form.on("Budget", { refresh: function (frm) { frm.trigger("toggle_reqd_fields"); + + if (!frm.doc.__islocal && frm.doc.docstatus == 1) { + frm.add_custom_button( + __("Revise Budget"), + function () { + frm.events.revise_budget_action(frm); + }, + __("Actions") + ); + } }, budget_against: function (frm) { @@ -51,4 +61,27 @@ frappe.ui.form.on("Budget", { frm.toggle_reqd("cost_center", frm.doc.budget_against == "Cost Center"); frm.toggle_reqd("project", frm.doc.budget_against == "Project"); }, + + revise_budget_action: function (frm) { + frappe.confirm( + __( + "Are you sure you want to revise this budget? The current budget will be cancelled and a new draft will be created." + ), + function () { + frappe.call({ + method: "erpnext.accounts.doctype.budget.budget.revise_budget", + args: { budget_name: frm.doc.name }, + callback: function (r) { + if (r.message) { + frappe.msgprint(__("New revised budget created successfully")); + frappe.set_route("Form", "Budget", r.message); + } + }, + }); + }, + function () { + frappe.msgprint(__("Revision cancelled")); + } + ); + }, }); diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 01953359c37..fdf5b74c477 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -45,7 +45,9 @@ "column_break_ybiq", "total_budget_amount", "section_break_fpdt", - "budget_distribution" + "budget_distribution", + "section_break_kkan", + "revision_of" ], "fields": [ { @@ -313,13 +315,23 @@ "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount" + }, + { + "fieldname": "section_break_kkan", + "fieldtype": "Section Break" + }, + { + "fieldname": "revision_of", + "fieldtype": "Data", + "label": "Revision Of", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 02:29:23.201493", + "modified": "2025-10-15 16:55:25.157976", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index df9ec661423..ed0348c7480 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -64,6 +64,7 @@ class Budget(Document): monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None + revision_of: DF.Data | None total_budget_amount: DF.Currency # end: auto-generated types @@ -159,6 +160,9 @@ class Budget(Document): self.allocate_budget() def allocate_budget(self): + if self.revision_of: + return + self.set("budget_distribution", []) if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): return @@ -167,7 +171,7 @@ class Budget(Document): end = getdate(self.budget_end_date) freq = self.allocation_frequency - months = month_diff(end, start) + 1 + months = month_diff(end, start) if freq == "Monthly": total_periods = months elif freq == "Quarterly": @@ -652,3 +656,20 @@ def get_expense_cost_center(doctype, args): return frappe.db.get_value( doctype, args.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"] ) + + +@frappe.whitelist() +def revise_budget(budget_name): + old_budget = frappe.get_doc("Budget", budget_name) + + if old_budget.docstatus == 1: + old_budget.cancel() + frappe.db.commit() + + new_budget = frappe.copy_doc(old_budget) + new_budget.docstatus = 0 + new_budget.revision_of = old_budget.name + new_budget.posting_date = frappe.utils.nowdate() + new_budget.insert() + + return new_budget.name diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json index 5c633fa392b..7602956e0c6 100644 --- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -29,20 +29,22 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount" + "label": "Amount", + "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" }, { "fieldname": "percent", "fieldtype": "Percent", "in_list_view": 1, - "label": "Percent" + "label": "Percent", + "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-12 23:47:30.393908", + "modified": "2025-10-15 16:53:23.462653", "modified_by": "Administrator", "module": "Accounts", "name": "Budget Distribution", From af9dc8e4063ac59437aea17082e17091b2436524 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Sat, 25 Oct 2025 01:01:05 +0530 Subject: [PATCH 14/51] test: budget revision test cases --- .../accounts/doctype/budget/test_budget.py | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index ccc92fb518b..1afde6ed98e 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -3,7 +3,7 @@ import unittest import frappe -from frappe.utils import now_datetime, nowdate +from frappe.utils import add_days, getdate, now_datetime, nowdate from erpnext.accounts.doctype.budget.budget import ( BudgetError, @@ -25,6 +25,10 @@ class TestBudget(ERPNextTestSuite): def setUp(self): frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False) + self.company = "_Test Company" + self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") + self.account = "_Test Account Cost for Goods Sold - _TC" + self.cost_center = "_Test Cost Center - _TC" def test_monthly_budget_crossed_ignore(self): set_total_expense_zero(nowdate(), "cost_center") @@ -422,6 +426,117 @@ class TestBudget(ERPNextTestSuite): po.cancel() jv.cancel() + def test_distribution_date_validation(self): + budget = frappe.new_doc("Budget") + budget.company = self.company + budget.fiscal_year = self.fiscal_year + budget.budget_against = "Cost Center" + budget.cost_center = self.cost_center + budget.append("accounts", {"account": self.account, "budget_amount": 100000}) + + start = getdate("2025-04-10") + end = getdate("2025-04-05") + + budget.append( + "budget_distribution", + { + "start_date": start, + "end_date": end, + "amount": 50000, + }, + ) + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_total_distribution_equals_budget(self): + budget = frappe.new_doc("Budget") + budget.company = self.company + budget.fiscal_year = self.fiscal_year + budget.budget_against = "Cost Center" + budget.cost_center = self.cost_center + budget.account = ("_Test Account Cost for Goods Sold - _TC",) + budget.budget_amount = 12000 + + budget.start_date = getdate("2025-04-01") + budget.end_date = getdate("2025-06-30") + + budget.append( + "budget_distribution", + { + "start_date": getdate("2025-04-01"), + "end_date": getdate("2025-04-30"), + "amount": 6000, + }, + ) + budget.append( + "budget_distribution", + { + "start_date": getdate("2025-05-01"), + "end_date": getdate("2025-06-30"), + "amount": 5000, + }, + ) + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_evenly_distribute_budget(self): + budget = frappe.new_doc("Budget") + budget.company = self.company + budget.fiscal_year = self.fiscal_year + budget.budget_against = "Cost Center" + budget.cost_center = self.cost_center + budget.distribute_evenly = 1 + budget.account = ("_Test Account Cost for Goods Sold - _TC",) + budget.budget_amount = 12000 + + budget.budget_start_date = getdate("2025-04-01") + budget.budget_end_date = getdate("2026-03-31") + + for i in range(12): + budget.append( + "budget_distribution", + { + "start_date": add_days(getdate("2025-04-01"), 30 * i), + "end_date": add_days(getdate("2025-04-30"), 30 * i), + }, + ) + + budget.save() + budget.reload() + + total = sum([d.amount for d in budget.budget_distribution]) + self.assertEqual(total, 120000) + self.assertTrue(all(d.amount == 10000 for d in budget.budget_distribution)) + + def test_create_revised_budget(self): + budget = make_budget(budget_against="Cost Center", budget_amount=120000) + + revised_name = frappe.get_doc("Budget", budget.name).revise_budget() + + revised_budget = frappe.get_doc("Budget", revised_name) + self.assertNotEqual(budget.name, revised_budget.name) + self.assertEqual(revised_budget.budget_against, budget.budget_against) + self.assertEqual(revised_budget.accounts[0].budget_amount, budget.accounts[0].budget_amount) + + old_budget = frappe.get_doc("Budget", budget.name) + self.assertEqual(old_budget.docstatus, 2) + + def test_revision_preserves_distribution(self): + budget = make_budget(budget_against="Cost Center", budget_amount=120000) + budget.distribute_evenly = 1 + budget.allocate_budget() + budget.save() + + revised_name = budget.revise_budget() + revised_budget = frappe.get_doc("Budget", revised_name) + + self.assertGreater(len(revised_budget.budget_distribution), 0) + + total = sum(row.amount for row in revised_budget.budget_distribution) + self.assertEqual(total, revised_budget.accounts[0].budget_amount) + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project": From 64456af654e57494646c707116536ccdac4d25e0 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 28 Oct 2025 12:41:05 +0530 Subject: [PATCH 15/51] refactor: update budget expense validation to align with new structure --- erpnext/accounts/doctype/budget/budget.json | 32 +---- erpnext/accounts/doctype/budget/budget.py | 142 ++++++++------------ erpnext/controllers/budget_controller.py | 4 +- 3 files changed, 60 insertions(+), 118 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index fdf5b74c477..8f03782f0c6 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -15,7 +15,6 @@ "account", "fiscal_year", "column_break_3", - "monthly_distribution", "amended_from", "distribution_type", "allocation_frequency", @@ -40,10 +39,6 @@ "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "section_break_21", "accounts", - "section_break_hqka", - "column_break_gnot", - "column_break_ybiq", - "total_budget_amount", "section_break_fpdt", "budget_distribution", "section_break_kkan", @@ -99,13 +94,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "depends_on": "eval:in_list([\"Stop\", \"Warn\"], doc.action_if_accumulated_monthly_budget_exceeded_on_po || doc.action_if_accumulated_monthly_budget_exceeded_on_mr || doc.action_if_accumulated_monthly_budget_exceeded_on_actual)", - "fieldname": "monthly_distribution", - "fieldtype": "Link", - "label": "Monthly Distribution", - "options": "Monthly Distribution" - }, { "fieldname": "amended_from", "fieldtype": "Link", @@ -248,24 +236,6 @@ "label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense", "options": "\nStop\nWarn\nIgnore" }, - { - "fieldname": "section_break_hqka", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_gnot", - "fieldtype": "Column Break" - }, - { - "fieldname": "column_break_ybiq", - "fieldtype": "Column Break" - }, - { - "fieldname": "total_budget_amount", - "fieldtype": "Currency", - "label": "Total Budget Amount", - "read_only": 1 - }, { "fieldname": "section_break_fpdt", "fieldtype": "Section Break" @@ -331,7 +301,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 16:55:25.157976", + "modified": "2025-10-26 01:09:56.367821", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ed0348c7480..d2053d46d0b 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -7,6 +7,7 @@ from datetime import date import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.functions import Sum from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff from frappe.utils.data import get_first_day @@ -33,11 +34,9 @@ class Budget(Document): if TYPE_CHECKING: from frappe.types import DF - from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution account: DF.Link - accounts: DF.Table[BudgetAccount] action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] @@ -61,73 +60,67 @@ class Budget(Document): cost_center: DF.Link | None distribution_type: DF.Literal["Amount", "Percent"] fiscal_year: DF.Link - monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None revision_of: DF.Data | None - total_budget_amount: DF.Currency # end: auto-generated types def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) self.validate_duplicate() - self.validate_accounts() + self.validate_account() self.set_null_value() self.validate_applicable_for() - self.set_total_budget_amount() def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) + account = self.account - accounts = [d.account for d in self.accounts] or [] - existing_budget = frappe.db.sql( - """ - select - b.name, ba.account from `tabBudget` b, `tabBudget Account` ba - where - ba.parent = b.name and b.docstatus < 2 and b.company = {} and {}={} and - b.fiscal_year={} and b.name != {} and ba.account in ({}) """.format( - "%s", budget_against_field, "%s", "%s", "%s", ",".join(["%s"] * len(accounts)) - ), - (self.company, budget_against, self.fiscal_year, self.name, *tuple(accounts)), - as_dict=1, + if not account: + return + + existing_budget = frappe.db.get_all( + "Budget", + filters={ + "docstatus": ("<", 2), + "company": self.company, + budget_against_field: budget_against, + "fiscal_year": self.fiscal_year, + "account": account, + "name": ("!=", self.name), + }, + fields=["name", "account"], ) - for d in existing_budget: + if existing_budget: + d = existing_budget[0] frappe.throw( - _( - "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' for fiscal year {4}" - ).format(d.name, self.budget_against, budget_against, d.account, self.fiscal_year), + _("Another Budget record '{0}' already exists against {1} '{2}' and account '{3}'").format( + d.name, self.budget_against, budget_against, d.account + ), DuplicateBudgetError, ) - def validate_accounts(self): - account_list = [] - for d in self.get("accounts"): - if d.account: - account_details = frappe.get_cached_value( - "Account", d.account, ["is_group", "company", "report_type"], as_dict=1 + def validate_account(self): + if not self.account: + frappe.throw(_("Account is mandatory")) + + account_details = frappe.get_cached_value( + "Account", self.account, ["is_group", "company", "report_type"], as_dict=1 + ) + + if account_details.is_group: + frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(self.account)) + elif account_details.company != self.company: + frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company)) + elif account_details.report_type != "Profit and Loss": + frappe.throw( + _("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format( + self.account ) - - if account_details.is_group: - frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account)) - elif account_details.company != self.company: - frappe.throw( - _("Account {0} does not belongs to company {1}").format(d.account, self.company) - ) - elif account_details.report_type != "Profit and Loss": - frappe.throw( - _( - "Budget cannot be assigned against {0}, as it's not an Income or Expense account" - ).format(d.account) - ) - - if d.account in account_list: - frappe.throw(_("Account {0} has been entered multiple times").format(d.account)) - else: - account_list.append(d.account) + ) def set_null_value(self): if self.budget_against == "Cost Center": @@ -153,9 +146,6 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 - def set_total_budget_amount(self): - self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) - def before_save(self): self.allocate_budget() @@ -295,7 +285,7 @@ def validate_expense_against_budget(args, expense_amount=0): budget_records = frappe.db.sql( f""" select - b.{budget_against} as budget_against, ba.budget_amount, b.monthly_distribution, + b.name, b.{budget_against} as budget_against, b.budget_amount, b.monthly_distribution, ifnull(b.applicable_on_material_request, 0) as for_material_request, ifnull(applicable_on_purchase_order, 0) as for_purchase_order, ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses, @@ -303,10 +293,10 @@ def validate_expense_against_budget(args, expense_amount=0): b.action_if_annual_budget_exceeded_on_mr, b.action_if_accumulated_monthly_budget_exceeded_on_mr, b.action_if_annual_budget_exceeded_on_po, b.action_if_accumulated_monthly_budget_exceeded_on_po from - `tabBudget` b, `tabBudget Account` ba + `tabBudget` b where - b.name=ba.parent and b.fiscal_year=%s - and ba.account=%s and b.docstatus=1 + b.fiscal_year=%s + and b.account=%s and b.docstatus=1 {condition} """, (args.fiscal_year, args.account), @@ -335,9 +325,7 @@ def validate_budget_records(args, budget_records, expense_amount): ) if monthly_action in ["Stop", "Warn"]: - budget_amount = get_accumulated_monthly_budget( - budget.monthly_distribution, args.posting_date, args.fiscal_year, budget.budget_amount - ) + budget_amount = get_accumulated_monthly_budget(budget.name, args.posting_date) args["month_end_date"] = get_last_day(args.posting_date) @@ -581,37 +569,23 @@ def get_actual_expense(args): return amount -def get_accumulated_monthly_budget(monthly_distribution, posting_date, fiscal_year, annual_budget): - distribution = {} - if monthly_distribution: - mdp = frappe.qb.DocType("Monthly Distribution Percentage") - md = frappe.qb.DocType("Monthly Distribution") +def get_accumulated_monthly_budget(budget_name, posting_date): + posting_date = getdate(posting_date) - res = ( - frappe.qb.from_(mdp) - .join(md) - .on(mdp.parent == md.name) - .select(mdp.month, mdp.percentage_allocation) - .where(md.fiscal_year == fiscal_year) - .where(md.name == monthly_distribution) - .run(as_dict=True) - ) + bd = frappe.qb.DocType("Budget Distribution") + b = frappe.qb.DocType("Budget") - for d in res: - distribution.setdefault(d.month, d.percentage_allocation) + result = ( + frappe.qb.from_(bd) + .join(b) + .on(bd.parent == b.name) + .select(Sum(bd.amount).as_("accumulated_amount")) + .where(b.name == budget_name) + .where(bd.end_date >= posting_date) + .run(as_dict=True) + ) - dt = frappe.get_cached_value("Fiscal Year", fiscal_year, "year_start_date") - accumulated_percentage = 0.0 - - while dt <= getdate(posting_date): - if monthly_distribution and distribution: - accumulated_percentage += distribution.get(getdate(dt).strftime("%B"), 0) - else: - accumulated_percentage += 100.0 / 12 - - dt = add_months(dt, 1) - - return annual_budget * accumulated_percentage / 100 + return flt(result[0]["accumulated_amount"]) if result else 0.0 def get_item_details(args): diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 6a9e6ae316d..0325f18b972 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -59,10 +59,8 @@ class BudgetValidation: _obj.update( { "accumulated_monthly_budget": get_accumulated_monthly_budget( - self.budget_map[key].monthly_distribution, + self.budget_map[key].name, self.doc_date, - self.fiscal_year, - self.budget_map[key].budget_amount, ) } ) From 1f832ca23ede1864dd5a495b2d7e73a011b4d748 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 29 Oct 2025 02:53:37 +0530 Subject: [PATCH 16/51] fix: test cases of budget --- erpnext/accounts/doctype/budget/budget.json | 16 +-- erpnext/accounts/doctype/budget/budget.py | 2 +- .../accounts/doctype/budget/test_budget.py | 113 +++++++++--------- 3 files changed, 57 insertions(+), 74 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 8f03782f0c6..c98b27abbd5 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -37,8 +37,6 @@ "applicable_on_cumulative_expense", "action_if_annual_exceeded_on_cumulative_expense", "action_if_accumulated_monthly_exceeded_on_cumulative_expense", - "section_break_21", - "accounts", "section_break_fpdt", "budget_distribution", "section_break_kkan", @@ -188,18 +186,6 @@ "label": "Action if Accumulated Monthly Budget Exceeded on Actual", "options": "\nStop\nWarn\nIgnore" }, - { - "fieldname": "section_break_21", - "fieldtype": "Section Break", - "hide_border": 1 - }, - { - "fieldname": "accounts", - "fieldtype": "Table", - "label": "Budget Accounts", - "options": "Budget Account", - "reqd": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -301,7 +287,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-26 01:09:56.367821", + "modified": "2025-10-28 13:02:43.456568", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index d2053d46d0b..58595e4f708 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -285,7 +285,7 @@ def validate_expense_against_budget(args, expense_amount=0): budget_records = frappe.db.sql( f""" select - b.name, b.{budget_against} as budget_against, b.budget_amount, b.monthly_distribution, + b.name, b.{budget_against} as budget_against, b.budget_amount, ifnull(b.applicable_on_material_request, 0) as for_material_request, ifnull(applicable_on_purchase_order, 0) as for_purchase_order, ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses, diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 1afde6ed98e..e8402811950 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -9,6 +9,7 @@ from erpnext.accounts.doctype.budget.budget import ( BudgetError, get_accumulated_monthly_budget, get_actual_expense, + revise_budget, ) from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.utils import get_fiscal_year @@ -24,7 +25,7 @@ class TestBudget(ERPNextTestSuite): cls.make_projects() def setUp(self): - frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False) + frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", True) self.company = "_Test Company" self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") self.account = "_Test Account Cost for Goods Sold - _TC" @@ -59,7 +60,8 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -81,9 +83,7 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") - accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount - ) + accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate()) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", "_Test Bank - _TC", @@ -118,7 +118,8 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year) accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) mr = frappe.get_doc( @@ -162,7 +163,8 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year) accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) po = create_purchase_order( transaction_date=nowdate(), qty=1, rate=accumulated_limit + 1, do_not_submit=True @@ -185,7 +187,8 @@ class TestBudget(ERPNextTestSuite): project = frappe.get_value("Project", {"project_name": "_Test Project"}) accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -306,7 +309,8 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -339,7 +343,8 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + budget.name, + nowdate(), ) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -393,9 +398,7 @@ class TestBudget(ERPNextTestSuite): budget = make_budget(budget_against="Cost Center", applicable_on_cumulative_expense=True) - accumulated_limit = get_accumulated_monthly_budget( - budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount - ) + accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate()) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -414,6 +417,8 @@ class TestBudget(ERPNextTestSuite): ) po.set_missing_values() + print(">>>>>>>>>>>>>>>>>>>>>>>>") + self.assertRaises(BudgetError, po.submit) frappe.db.set_value( @@ -432,7 +437,8 @@ class TestBudget(ERPNextTestSuite): budget.fiscal_year = self.fiscal_year budget.budget_against = "Cost Center" budget.cost_center = self.cost_center - budget.append("accounts", {"account": self.account, "budget_amount": 100000}) + budget.account = self.account + budget.budget_amount = 100000 start = getdate("2025-04-10") end = getdate("2025-04-05") @@ -482,29 +488,7 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_evenly_distribute_budget(self): - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.fiscal_year = self.fiscal_year - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.distribute_evenly = 1 - budget.account = ("_Test Account Cost for Goods Sold - _TC",) - budget.budget_amount = 12000 - - budget.budget_start_date = getdate("2025-04-01") - budget.budget_end_date = getdate("2026-03-31") - - for i in range(12): - budget.append( - "budget_distribution", - { - "start_date": add_days(getdate("2025-04-01"), 30 * i), - "end_date": add_days(getdate("2025-04-30"), 30 * i), - }, - ) - - budget.save() - budget.reload() + budget = make_budget(budget_against="Cost Center", budget_amount=120000) total = sum([d.amount for d in budget.budget_distribution]) self.assertEqual(total, 120000) @@ -513,29 +497,26 @@ class TestBudget(ERPNextTestSuite): def test_create_revised_budget(self): budget = make_budget(budget_against="Cost Center", budget_amount=120000) - revised_name = frappe.get_doc("Budget", budget.name).revise_budget() + revised_name = revise_budget(budget.name) revised_budget = frappe.get_doc("Budget", revised_name) self.assertNotEqual(budget.name, revised_budget.name) self.assertEqual(revised_budget.budget_against, budget.budget_against) - self.assertEqual(revised_budget.accounts[0].budget_amount, budget.accounts[0].budget_amount) + self.assertEqual(revised_budget.budget_amount, budget.budget_amount) old_budget = frappe.get_doc("Budget", budget.name) self.assertEqual(old_budget.docstatus, 2) def test_revision_preserves_distribution(self): budget = make_budget(budget_against="Cost Center", budget_amount=120000) - budget.distribute_evenly = 1 - budget.allocate_budget() - budget.save() - revised_name = budget.revise_budget() + revised_name = revise_budget(budget.name) revised_budget = frappe.get_doc("Budget", revised_name) self.assertGreater(len(revised_budget.budget_distribution), 0) total = sum(row.amount for row in revised_budget.budget_distribution) - self.assertEqual(total, revised_budget.accounts[0].budget_amount) + self.assertEqual(total, revised_budget.budget_amount) def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): @@ -589,18 +570,33 @@ def make_budget(**args): budget_against = args.budget_against cost_center = args.cost_center - fiscal_year = get_fiscal_year(nowdate())[0] if budget_against == "Project": - project_name = "{}%".format("_Test Project/" + fiscal_year) - budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", project_name)}) + project = frappe.get_value("Project", {"project_name": "_Test Project"}) + budget_list = frappe.get_all( + "Budget", + filters={ + "project": project, + "account": "_Test Account Cost for Goods Sold - _TC", + }, + pluck="name", + ) else: - cost_center_name = "{}%".format(cost_center or "_Test Cost Center - _TC/" + fiscal_year) - budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", cost_center_name)}) - for d in budget_list: - frappe.db.sql("delete from `tabBudget` where name = %(name)s", d) - frappe.db.sql("delete from `tabBudget Account` where parent = %(name)s", d) + budget_list = frappe.get_all( + "Budget", + filters={ + "cost_center": cost_center or "_Test Cost Center - _TC", + "account": "_Test Account Cost for Goods Sold - _TC", + }, + pluck="name", + ) + + for name in budget_list: + doc = frappe.get_doc("Budget", name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc("Budget", name, force=True, ignore_missing=True) budget = frappe.new_doc("Budget") @@ -609,18 +605,19 @@ def make_budget(**args): else: budget.cost_center = cost_center or "_Test Cost Center - _TC" - monthly_distribution = frappe.get_doc("Monthly Distribution", "_Test Distribution") - monthly_distribution.fiscal_year = fiscal_year - monthly_distribution.save() - budget.fiscal_year = fiscal_year - budget.monthly_distribution = "_Test Distribution" budget.company = "_Test Company" + budget.account = "_Test Account Cost for Goods Sold - _TC" + budget.budget_amount = args.budget_amount or 200000 budget.applicable_on_booking_actual_expenses = 1 budget.action_if_annual_budget_exceeded = "Stop" budget.action_if_accumulated_monthly_budget_exceeded = "Ignore" budget.budget_against = budget_against - budget.append("accounts", {"account": "_Test Account Cost for Goods Sold - _TC", "budget_amount": 200000}) + + budget.budget_start_date = "2025-04-01" + budget.budget_end_date = "2026-03-31" + budget.allocation_frequency = "Monthly" + budget.distribution_type = "Amount" if args.applicable_on_material_request: budget.applicable_on_material_request = 1 From b5d892c802357995441cee4924a9cf845adbf2f0 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 29 Oct 2025 13:00:29 +0530 Subject: [PATCH 17/51] fix: default company currency for amount --- erpnext/accounts/doctype/budget/budget.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index c98b27abbd5..643f4f78d8f 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -270,7 +270,9 @@ { "fieldname": "budget_amount", "fieldtype": "Currency", - "label": "Budget Amount" + "label": "Budget Amount", + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "section_break_kkan", @@ -280,6 +282,7 @@ "fieldname": "revision_of", "fieldtype": "Data", "label": "Revision Of", + "no_copy": 1, "read_only": 1 } ], @@ -287,7 +290,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-28 13:02:43.456568", + "modified": "2025-10-29 03:06:52.730795", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From bd88356a8aba0a00f133011fac249b010e42ccd4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 30 Oct 2025 20:12:12 +0530 Subject: [PATCH 18/51] feat: budget for multiple fiscal year --- erpnext/accounts/doctype/budget/budget.json | 76 ++-- erpnext/accounts/doctype/budget/budget.py | 403 +++++++++++------- .../accounts/doctype/budget/test_budget.py | 121 ++++-- .../budget_distribution.json | 6 +- 4 files changed, 388 insertions(+), 218 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 643f4f78d8f..849d4a5e927 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -13,14 +13,17 @@ "cost_center", "project", "account", - "fiscal_year", "column_break_3", "amended_from", + "from_fiscal_year", + "to_fiscal_year", "distribution_type", "allocation_frequency", - "budget_start_date", - "budget_end_date", "budget_amount", + "section_break_nwug", + "distribute_equally", + "section_break_fpdt", + "budget_distribution", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -37,8 +40,6 @@ "applicable_on_cumulative_expense", "action_if_annual_exceeded_on_cumulative_expense", "action_if_accumulated_monthly_exceeded_on_cumulative_expense", - "section_break_fpdt", - "budget_distribution", "section_break_kkan", "revision_of" ], @@ -51,6 +52,7 @@ "in_standard_filter": 1, "label": "Budget Against", "options": "\nCost Center\nProject", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -60,6 +62,7 @@ "in_standard_filter": 1, "label": "Company", "options": "Company", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -69,7 +72,8 @@ "in_global_search": 1, "in_standard_filter": 1, "label": "Cost Center", - "options": "Cost Center" + "options": "Cost Center", + "read_only_depends_on": "eval: doc.revision_of" }, { "depends_on": "eval:doc.budget_against == 'Project'", @@ -77,16 +81,8 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Project", - "options": "Project" - }, - { - "fieldname": "fiscal_year", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Fiscal Year", - "options": "Fiscal Year", - "reqd": 1 + "options": "Project", + "read_only_depends_on": "eval: doc.revision_of" }, { "fieldname": "column_break_3", @@ -237,6 +233,7 @@ "fieldtype": "Link", "label": "Account", "options": "Account", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -245,18 +242,7 @@ "fieldtype": "Select", "label": "Distribution Type", "options": "Amount\nPercent", - "reqd": 1 - }, - { - "fieldname": "budget_start_date", - "fieldtype": "Date", - "label": "Budget Start Date", - "reqd": 1 - }, - { - "fieldname": "budget_end_date", - "fieldtype": "Date", - "label": "Budget End Date", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -264,14 +250,15 @@ "fieldname": "allocation_frequency", "fieldtype": "Select", "label": "Allocation Frequency", - "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount", - "options": "Company:company:default_currency", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -284,13 +271,40 @@ "label": "Revision Of", "no_copy": 1, "read_only": 1 + }, + { + "default": "1", + "fieldname": "distribute_equally", + "fieldtype": "Check", + "label": "Distribute Equally" + }, + { + "fieldname": "section_break_nwug", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "from_fiscal_year", + "fieldtype": "Link", + "label": "From Fiscal Year", + "options": "Fiscal Year", + "read_only_depends_on": "eval: doc.revision_of", + "reqd": 1 + }, + { + "fieldname": "to_fiscal_year", + "fieldtype": "Link", + "label": "To Fiscal Year", + "options": "Fiscal Year", + "read_only_depends_on": "eval: doc.revision_of", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-29 03:06:52.730795", + "modified": "2025-10-30 19:07:51.022844", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 58595e4f708..e0eaff8b4a3 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff -from frappe.utils.data import get_first_day +from frappe.utils.data import get_first_day, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -45,7 +45,7 @@ class Budget(Document): action_if_annual_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"] - allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly", "Date Range"] + allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -54,15 +54,15 @@ class Budget(Document): budget_against: DF.Literal["", "Cost Center", "Project"] budget_amount: DF.Currency budget_distribution: DF.Table[BudgetDistribution] - budget_end_date: DF.Date - budget_start_date: DF.Date company: DF.Link cost_center: DF.Link | None + distribute_equally: DF.Check distribution_type: DF.Literal["Amount", "Percent"] - fiscal_year: DF.Link + from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None revision_of: DF.Data | None + to_fiscal_year: DF.Link # end: auto-generated types def validate(self): @@ -81,25 +81,38 @@ class Budget(Document): if not account: return - existing_budget = frappe.db.get_all( - "Budget", - filters={ - "docstatus": ("<", 2), - "company": self.company, - budget_against_field: budget_against, - "fiscal_year": self.fiscal_year, - "account": account, - "name": ("!=", self.name), - }, - fields=["name", "account"], + from_start, _ = frappe.get_cached_value( + "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"] + ) + _, to_end = frappe.get_cached_value( + "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"] + ) + + existing_budget = frappe.db.sql( + f""" + SELECT name, account + FROM `tabBudget` + WHERE + docstatus < 2 + AND company = %s + AND {budget_against_field} = %s + AND account = %s + AND name != %s + AND ( + (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s + AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s + ) + """, + (self.company, budget_against, account, self.name, to_end, from_start), + as_dict=True, ) if existing_budget: d = existing_budget[0] frappe.throw( - _("Another Budget record '{0}' already exists against {1} '{2}' and account '{3}'").format( - d.name, self.budget_against, budget_against, d.account - ), + _( + "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." + ).format(d.name, self.budget_against, budget_against, d.account), DuplicateBudgetError, ) @@ -153,75 +166,99 @@ class Budget(Document): if self.revision_of: return - self.set("budget_distribution", []) - if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): + if not self.should_regenerate_budget_distribution(): return - start = getdate(self.budget_start_date) - end = getdate(self.budget_end_date) - freq = self.allocation_frequency + self.set("budget_distribution", []) - months = month_diff(end, start) - if freq == "Monthly": - total_periods = months - elif freq == "Quarterly": - total_periods = months // 3 + (1 if months % 3 else 0) - elif freq == "Half-Yearly": - total_periods = months // 6 + (1 if months % 6 else 0) - else: - total_periods = end.year - start.year + 1 + self.set_budget_date_range() + periods = self.get_budget_periods() + total_periods = len(periods) + row_percent = 100 / total_periods if total_periods else 0 - if self.distribution_type == "Amount": - per_row = flt(self.budget_amount / total_periods, 2) - else: - per_row = flt(100 / total_periods, 2) - - assigned = 0 - current = start - - while current <= end: + for start_date, end_date in periods: row = self.append("budget_distribution", {}) + row.start_date = start_date + row.end_date = end_date + self.add_allocated_amount(row, row_percent) - if freq == "Monthly": - row.start_date = get_first_day(current) - row.end_date = get_last_day(current) - current = add_months(current, 1) - elif freq == "Quarterly": - month = ((current.month - 1) // 3) * 3 + 1 - quarter_start = date(current.year, month, 1) - quarter_end = get_last_day(add_months(quarter_start, 2)) - row.start_date = quarter_start - row.end_date = min(quarter_end, end) - current = add_months(quarter_start, 3) - elif freq == "Half-Yearly": - half = 1 if current.month <= 6 else 2 - half_start = date(current.year, 1, 1) if half == 1 else date(current.year, 7, 1) - half_end = date(current.year, 6, 30) if half == 1 else date(current.year, 12, 31) - row.start_date = half_start - row.end_date = min(half_end, end) - current = add_months(half_start, 6) - else: # Yearly - year_start = date(current.year, 1, 1) - year_end = date(current.year, 12, 31) - row.start_date = year_start - row.end_date = min(year_end, end) - current = date(current.year + 1, 1, 1) + def should_regenerate_budget_distribution(self): + """Check whether budget distribution should be recalculated.""" + old_doc = self.get_doc_before_save() if not self.is_new() else None - if self.distribution_type == "Amount": - if len(self.budget_distribution) == total_periods: - row.amount = flt(self.budget_amount - assigned) + if not self.budget_distribution: + return True - else: - row.amount = per_row - assigned += per_row - row.percent = flt(row.amount * 100 / self.budget_amount) - else: - if len(self.budget_distribution) == total_periods: - row.percent = flt(100 - assigned) - else: - row.percent = per_row - assigned += per_row - row.amount = flt(row.percent * self.budget_amount / 100) + if old_doc: + changed_fields = [ + "from_fiscal_year", + "to_fiscal_year", + "allocation_frequency", + "distribute_equally", + ] + for field in changed_fields: + if old_doc.get(field) != self.get(field): + return True + + return bool(self.distribute_equally) + + def set_budget_date_range(self): + """Set budget start and end dates based on selected fiscal years.""" + from_fiscal_year = frappe.db.get_value( + "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + to_fiscal_year = frappe.db.get_value( + "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + + self.budget_start_date = from_fiscal_year.year_start_date + self.budget_end_date = to_fiscal_year.year_end_date + + def get_budget_periods(self): + """Return list of (start_date, end_date) tuples based on frequency.""" + frequency = self.allocation_frequency + periods = [] + + start_date = getdate(self.budget_start_date) + end_date = getdate(self.budget_end_date) + + while start_date <= end_date: + period_start = get_first_day(start_date) + period_end = self.get_period_end(period_start, frequency) + period_end = min(period_end, end_date) + + periods.append((period_start, period_end)) + start_date = add_months(period_start, self.get_month_increment(frequency)) + + return periods + + def get_period_end(self, start_date, frequency): + """Return the correct end date for a given frequency.""" + if frequency == "Monthly": + return get_last_day(start_date) + elif frequency == "Quarterly": + return get_last_day(add_months(start_date, 2)) + elif frequency == "Half-Yearly": + return get_last_day(add_months(start_date, 5)) + else: # Yearly + return get_last_day(add_months(start_date, 11)) + + def get_month_increment(self, frequency): + """Return how many months to move forward for the next period.""" + return { + "Monthly": 1, + "Quarterly": 3, + "Half-Yearly": 6, + "Yearly": 12, + }.get(frequency, 1) + + def add_allocated_amount(self, row, row_percent): + if not self.distribute_equally: + row.amount = 0 + row.percent = 0 + else: + row.amount = flt(self.budget_amount * row_percent / 100, 2) + row.percent = flt(row_percent, 3) def validate_expense_against_budget(args, expense_amount=0): @@ -229,17 +266,31 @@ def validate_expense_against_budget(args, expense_amount=0): if not frappe.db.count("Budget", cache=True): return - if not args.fiscal_year: - args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0] + posting_date = getdate(args.get("posting_date")) + posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0] + year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year) + + budget_exists = frappe.db.sql( + """ + select name + from `tabBudget` + where company = %s + and docstatus = 1 + and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s + and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s + limit 1 + """, + (args.company, year_end_date, year_start_date), + ) + + if not budget_exists: + return if args.get("company"): frappe.flags.exception_approver_role = frappe.get_cached_value( "Company", args.get("company"), "exception_budget_approver_role" ) - if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}): - return - if not args.account: args.account = args.get("expense_account") @@ -284,22 +335,36 @@ def validate_expense_against_budget(args, expense_amount=0): budget_records = frappe.db.sql( f""" - select - b.name, b.{budget_against} as budget_against, b.budget_amount, - ifnull(b.applicable_on_material_request, 0) as for_material_request, - ifnull(applicable_on_purchase_order, 0) as for_purchase_order, - ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses, - b.action_if_annual_budget_exceeded, b.action_if_accumulated_monthly_budget_exceeded, - b.action_if_annual_budget_exceeded_on_mr, b.action_if_accumulated_monthly_budget_exceeded_on_mr, - b.action_if_annual_budget_exceeded_on_po, b.action_if_accumulated_monthly_budget_exceeded_on_po - from + SELECT + b.name, + b.{budget_against} AS budget_against, + b.budget_amount, + b.from_fiscal_year, + b.to_fiscal_year, + IFNULL(b.applicable_on_material_request, 0) AS for_material_request, + IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order, + IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses, + b.action_if_annual_budget_exceeded, + b.action_if_accumulated_monthly_budget_exceeded, + b.action_if_annual_budget_exceeded_on_mr, + b.action_if_accumulated_monthly_budget_exceeded_on_mr, + b.action_if_annual_budget_exceeded_on_po, + b.action_if_accumulated_monthly_budget_exceeded_on_po + FROM `tabBudget` b - where - b.fiscal_year=%s - and b.account=%s and b.docstatus=1 + WHERE + b.company = %s + AND b.docstatus = 1 + AND ( + %s BETWEEN + (SELECT year_start_date FROM `tabFiscal Year` WHERE name = b.from_fiscal_year) + AND + (SELECT year_end_date FROM `tabFiscal Year` WHERE name = b.to_fiscal_year) + ) + AND b.account = %s {condition} - """, - (args.fiscal_year, args.account), + """, + (args.company, args.posting_date, args.account), as_dict=True, ) # nosec @@ -313,6 +378,7 @@ def validate_budget_records(args, budget_records, expense_amount): yearly_action, monthly_action = get_actions(args, budget) args["for_material_request"] = budget.for_material_request args["for_purchase_order"] = budget.for_purchase_order + args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year if yearly_action in ("Stop", "Warn"): compare_expense_with_budget( @@ -384,7 +450,7 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ def get_expense_breakup(args, currency, budget_against): - msg = "
{{ _('Total Expenses booked through') }} -
    " + msg = "
    {} -
      ".format(_("Total Expenses booked through")) common_filters = frappe._dict( { @@ -394,23 +460,39 @@ def get_expense_breakup(args, currency, budget_against): } ) + from_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date") + to_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date") + gl_filters = common_filters.copy() + gl_filters.update( + { + "from_date": from_date, + "to_date": to_date, + "is_cancelled": 0, + } + ) + msg += ( "
    • " + frappe.utils.get_link_to_report( "General Ledger", label=_("Actual Expenses"), - filters=common_filters.copy().update( - { - "from_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_start_date"), - "to_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_end_date"), - "is_cancelled": 0, - } - ), + filters=gl_filters, ) + " - " + frappe.bold(fmt_money(args.actual_expense, currency=currency)) + "
    • " ) + mr_filters = common_filters.copy() + mr_filters.update( + { + "status": [["!=", "Stopped"]], + "docstatus": 1, + "material_request_type": "Purchase", + "schedule_date": [["between", from_date, to_date]], + "item_code": args.item_code, + "per_ordered": [["<", 100]], + } + ) msg += ( "
    • " @@ -419,22 +501,24 @@ def get_expense_breakup(args, currency, budget_against): label=_("Material Requests"), report_type="Report Builder", doctype="Material Request", - filters=common_filters.copy().update( - { - "status": [["!=", "Stopped"]], - "docstatus": 1, - "material_request_type": "Purchase", - "schedule_date": [["fiscal year", "2023-2024"]], - "item_code": args.item_code, - "per_ordered": [["<", 100]], - } - ), + filters=mr_filters, ) + " - " + frappe.bold(fmt_money(args.requested_amount, currency=currency)) + "
    • " ) + po_filters = common_filters.copy() + po_filters.update( + { + "status": [["!=", "Closed"]], + "docstatus": 1, + "transaction_date": [["between", from_date, to_date]], + "item_code": args.item_code, + "per_billed": [["<", 100]], + } + ) + msg += ( "
    • " + frappe.utils.get_link_to_report( @@ -442,15 +526,7 @@ def get_expense_breakup(args, currency, budget_against): label=_("Unbilled Orders"), report_type="Report Builder", doctype="Purchase Order", - filters=common_filters.copy().update( - { - "status": [["!=", "Closed"]], - "docstatus": 1, - "transaction_date": [["fiscal year", "2023-2024"]], - "item_code": args.item_code, - "per_billed": [["<", 100]], - } - ), + filters=po_filters, ) + " - " + frappe.bold(fmt_money(args.ordered_amount, currency=currency)) @@ -508,20 +584,18 @@ def get_ordered_amount(args): def get_other_condition(args, for_doc): - condition = "expense_account = '%s'" % (args.expense_account) + condition = f"expense_account = '{args.expense_account}'" budget_against_field = args.get("budget_against_field") if budget_against_field and args.get(budget_against_field): condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'" - if args.get("fiscal_year"): - date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" - start_date, end_date = frappe.get_cached_value( - "Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"] - ) + date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" - condition += f""" and parent.{date_field} - between '{start_date}' and '{end_date}' """ + start_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date") + end_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date") + + condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'" return condition @@ -533,36 +607,48 @@ def get_actual_expense(args): budget_against_field = args.get("budget_against_field") condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else "" + from_start, _ = frappe.get_cached_value( + "Fiscal Year", args.from_fiscal_year, ["year_start_date", "year_end_date"] + ) + _, to_end = frappe.get_cached_value( + "Fiscal Year", args.to_fiscal_year, ["year_start_date", "year_end_date"] + ) + + date_condition = f"and gle.posting_date between '{from_start}' and '{to_end}'" + if args.is_tree: lft_rgt = frappe.db.get_value( args.budget_against_doctype, args.get(budget_against_field), ["lft", "rgt"], as_dict=1 ) - args.update(lft_rgt) - condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}` - where lft>=%(lft)s and rgt<=%(rgt)s - and name=gle.{budget_against_field})""" + condition2 = f""" + and exists( + select name from `tab{args.budget_against_doctype}` + where lft >= %(lft)s and rgt <= %(rgt)s + and name = gle.{budget_against_field} + ) + """ else: - condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}` - where name=gle.{budget_against_field} and - gle.{budget_against_field} = %({budget_against_field})s)""" + condition2 = f""" + and gle.{budget_against_field} = %({budget_against_field})s + """ amount = flt( frappe.db.sql( f""" - select sum(gle.debit) - sum(gle.credit) - from `tabGL Entry` gle - where - is_cancelled = 0 - and gle.account=%(account)s - {condition1} - and gle.fiscal_year=%(fiscal_year)s - and gle.company=%(company)s - and gle.docstatus=1 - {condition2} - """, - (args), + select sum(gle.debit) - sum(gle.credit) + from `tabGL Entry` gle + where + is_cancelled = 0 + and gle.account = %(account)s + {condition1} + {date_condition} + and gle.company = %(company)s + and gle.docstatus = 1 + {condition2} + """, + args, )[0][0] ) # nosec @@ -632,6 +718,16 @@ def get_expense_cost_center(doctype, args): ) +def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year): + from_year = frappe.get_cached_value( + "Fiscal Year", from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + to_year = frappe.get_cached_value( + "Fiscal Year", to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + return from_year.year_start_date, to_year.year_end_date + + @frappe.whitelist() def revise_budget(budget_name): old_budget = frappe.get_doc("Budget", budget_name) @@ -643,7 +739,6 @@ def revise_budget(budget_name): new_budget = frappe.copy_doc(old_budget) new_budget.docstatus = 0 new_budget.revision_of = old_budget.name - new_budget.posting_date = frappe.utils.nowdate() new_budget.insert() return new_budget.name diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index e8402811950..d2723bc0db0 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -3,7 +3,8 @@ import unittest import frappe -from frappe.utils import add_days, getdate, now_datetime, nowdate +from frappe.client import submit +from frappe.utils import add_days, flt, get_first_day, get_last_day, getdate, now_datetime, nowdate from erpnext.accounts.doctype.budget.budget import ( BudgetError, @@ -34,7 +35,7 @@ class TestBudget(ERPNextTestSuite): def test_monthly_budget_crossed_ignore(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center") + budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -55,7 +56,7 @@ class TestBudget(ERPNextTestSuite): def test_monthly_budget_crossed_stop1(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center") + budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") @@ -79,7 +80,7 @@ class TestBudget(ERPNextTestSuite): def test_exception_approver_role(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center") + budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") @@ -111,11 +112,11 @@ class TestBudget(ERPNextTestSuite): applicable_on_purchase_order=1, action_if_accumulated_monthly_budget_exceeded_on_mr="Stop", budget_against="Cost Center", + do_not_save=False, + subimit_budget=True, ) - fiscal_year = get_fiscal_year(nowdate())[0] frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") - frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year) accumulated_limit = get_accumulated_monthly_budget( budget.name, @@ -156,11 +157,11 @@ class TestBudget(ERPNextTestSuite): applicable_on_purchase_order=1, action_if_accumulated_monthly_budget_exceeded_on_po="Stop", budget_against="Cost Center", + do_not_save=False, + submit_budget=True, ) - fiscal_year = get_fiscal_year(nowdate())[0] frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") - frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year) accumulated_limit = get_accumulated_monthly_budget( budget.name, @@ -181,7 +182,7 @@ class TestBudget(ERPNextTestSuite): def test_monthly_budget_crossed_stop2(self): set_total_expense_zero(nowdate(), "project") - budget = make_budget(budget_against="Project") + budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") @@ -207,7 +208,7 @@ class TestBudget(ERPNextTestSuite): def test_yearly_budget_crossed_stop1(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center") + budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -224,7 +225,7 @@ class TestBudget(ERPNextTestSuite): def test_yearly_budget_crossed_stop2(self): set_total_expense_zero(nowdate(), "project") - budget = make_budget(budget_against="Project") + budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True) project = frappe.get_value("Project", {"project_name": "_Test Project"}) @@ -244,7 +245,7 @@ class TestBudget(ERPNextTestSuite): def test_monthly_budget_on_cancellation1(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center") + budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True) month = now_datetime().month if month > 9: month = 9 @@ -273,7 +274,7 @@ class TestBudget(ERPNextTestSuite): def test_monthly_budget_on_cancellation2(self): set_total_expense_zero(nowdate(), "project") - budget = make_budget(budget_against="Project") + budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True) month = now_datetime().month if month > 9: month = 9 @@ -305,7 +306,12 @@ class TestBudget(ERPNextTestSuite): set_total_expense_zero(nowdate(), "cost_center") set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center 2 - _TC") - budget = make_budget(budget_against="Cost Center", cost_center="_Test Company - _TC") + budget = make_budget( + budget_against="Cost Center", + cost_center="_Test Company - _TC", + do_not_save=False, + submit_budget=True, + ) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") accumulated_limit = get_accumulated_monthly_budget( @@ -339,7 +345,9 @@ class TestBudget(ERPNextTestSuite): } ).insert(ignore_permissions=True) - budget = make_budget(budget_against="Cost Center", cost_center=cost_center) + budget = make_budget( + budget_against="Cost Center", cost_center=cost_center, do_not_save=False, submit_budget=True + ) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") accumulated_limit = get_accumulated_monthly_budget( @@ -381,7 +389,12 @@ class TestBudget(ERPNextTestSuite): {"Sub Budget Cost Center 1 - _TC": 60, "Sub Budget Cost Center 2 - _TC": 40}, ) - make_budget(budget_against="Cost Center", cost_center="Main Budget Cost Center 1 - _TC") + make_budget( + budget_against="Cost Center", + cost_center="Main Budget Cost Center 1 - _TC", + do_not_save=False, + submit_budget=True, + ) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -396,7 +409,12 @@ class TestBudget(ERPNextTestSuite): def test_action_for_cumulative_limit(self): set_total_expense_zero(nowdate(), "cost_center") - budget = make_budget(budget_against="Cost Center", applicable_on_cumulative_expense=True) + budget = make_budget( + budget_against="Cost Center", + applicable_on_cumulative_expense=True, + do_not_save=False, + submit_budget=True, + ) accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate()) @@ -417,8 +435,6 @@ class TestBudget(ERPNextTestSuite): ) po.set_missing_values() - print(">>>>>>>>>>>>>>>>>>>>>>>>") - self.assertRaises(BudgetError, po.submit) frappe.db.set_value( @@ -434,7 +450,6 @@ class TestBudget(ERPNextTestSuite): def test_distribution_date_validation(self): budget = frappe.new_doc("Budget") budget.company = self.company - budget.fiscal_year = self.fiscal_year budget.budget_against = "Cost Center" budget.cost_center = self.cost_center budget.account = self.account @@ -456,9 +471,14 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_total_distribution_equals_budget(self): + budget = make_budget( + budget_against="Cost Center", + applicable_on_cumulative_expense=True, + do_not_save=False, + submit_budget=True, + ) budget = frappe.new_doc("Budget") budget.company = self.company - budget.fiscal_year = self.fiscal_year budget.budget_against = "Cost Center" budget.cost_center = self.cost_center budget.account = ("_Test Account Cost for Goods Sold - _TC",) @@ -488,14 +508,18 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_evenly_distribute_budget(self): - budget = make_budget(budget_against="Cost Center", budget_amount=120000) + budget = make_budget( + budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True + ) total = sum([d.amount for d in budget.budget_distribution]) - self.assertEqual(total, 120000) + self.assertEqual(flt(total), 120000) self.assertTrue(all(d.amount == 10000 for d in budget.budget_distribution)) def test_create_revised_budget(self): - budget = make_budget(budget_against="Cost Center", budget_amount=120000) + budget = make_budget( + budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True + ) revised_name = revise_budget(budget.name) @@ -508,7 +532,9 @@ class TestBudget(ERPNextTestSuite): self.assertEqual(old_budget.docstatus, 2) def test_revision_preserves_distribution(self): - budget = make_budget(budget_against="Cost Center", budget_amount=120000) + budget = make_budget( + budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True + ) revised_name = revise_budget(budget.name) revised_budget = frappe.get_doc("Budget", revised_name) @@ -518,6 +544,32 @@ class TestBudget(ERPNextTestSuite): total = sum(row.amount for row in revised_budget.budget_distribution) self.assertEqual(total, revised_budget.budget_amount) + def test_manual_budget_amount_total(self): + budget = make_budget( + budget_against="Cost Center", + distribute_equally=0, + budget_amount=30000, + budget_start_date="2025-04-01", + budget_end_date="2025-06-30", + do_not_save=True, + submit_budget=False, + ) + + budget.budget_distribution = [] + + for row in [ + {"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000}, + {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000}, + {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000}, + ]: + budget.append("budget_distribution", row) + + budget.save() + + total_child_amount = sum(row.amount for row in budget.budget_distribution) + + self.assertEqual(total_child_amount, budget.budget_amount) + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project": @@ -533,7 +585,8 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again "cost_center": "_Test Cost Center - _TC", "monthly_end_date": posting_date, "company": "_Test Company", - "fiscal_year": fiscal_year, + "from_fiscal_year": fiscal_year, + "to_fiscal_year": fiscal_year, "budget_against_field": budget_against_field, } ) @@ -605,7 +658,8 @@ def make_budget(**args): else: budget.cost_center = cost_center or "_Test Cost Center - _TC" - budget.fiscal_year = fiscal_year + budget.from_fiscal_year = fiscal_year + budget.to_fiscal_year = fiscal_year budget.company = "_Test Company" budget.account = "_Test Account Cost for Goods Sold - _TC" budget.budget_amount = args.budget_amount or 200000 @@ -614,10 +668,9 @@ def make_budget(**args): budget.action_if_accumulated_monthly_budget_exceeded = "Ignore" budget.budget_against = budget_against - budget.budget_start_date = "2025-04-01" - budget.budget_end_date = "2026-03-31" budget.allocation_frequency = "Monthly" budget.distribution_type = "Amount" + budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: budget.applicable_on_material_request = 1 @@ -642,7 +695,13 @@ def make_budget(**args): args.action_if_accumulated_monthly_exceeded_on_cumulative_expense or "Warn" ) - budget.insert() - budget.submit() + if not args.do_not_save: + try: + budget.insert(ignore_if_duplicate=True) + except frappe.DuplicateEntryError: + pass + + if args.submit_budget: + budget.submit() return budget diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json index 7602956e0c6..1a367010c98 100644 --- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -17,13 +17,15 @@ "fieldtype": "Date", "in_list_view": 1, "label": "Start Date", + "read_only": 1, "search_index": 1 }, { "fieldname": "end_date", "fieldtype": "Date", "in_list_view": 1, - "label": "End Date" + "label": "End Date", + "read_only": 1 }, { "fieldname": "amount", @@ -44,7 +46,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-15 16:53:23.462653", + "modified": "2025-10-30 12:35:31.310931", "modified_by": "Administrator", "module": "Accounts", "name": "Budget Distribution", From 1cb03db43bfb5a55e67e9383a495e4dcb6242ef9 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 31 Oct 2025 00:56:25 +0530 Subject: [PATCH 19/51] test: test cases to validate budget distribution and revision --- erpnext/accounts/doctype/budget/budget.py | 10 ++-- .../accounts/doctype/budget/test_budget.py | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index e0eaff8b4a3..120d78b7430 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -81,11 +81,8 @@ class Budget(Document): if not account: return - from_start, _ = frappe.get_cached_value( - "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"] - ) - _, to_end = frappe.get_cached_value( - "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"] + year_start_date, year_end_date = get_fiscal_year_date_range( + self.from_fiscal_year, self.to_fiscal_year ) existing_budget = frappe.db.sql( @@ -103,12 +100,13 @@ class Budget(Document): AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s ) """, - (self.company, budget_against, account, self.name, to_end, from_start), + (self.company, budget_against, account, self.name, year_end_date, year_start_date), as_dict=True, ) if existing_budget: d = existing_budget[0] + print(d) frappe.throw( _( "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index d2723bc0db0..6f1e751993b 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -570,6 +570,62 @@ class TestBudget(ERPNextTestSuite): self.assertEqual(total_child_amount, budget.budget_amount) + def test_fiscal_year_company_mismatch(self): + budget = make_budget(budget_against="Cost Center", do_not_save=True, submit_budget=False) + + fy = frappe.get_doc( + { + "doctype": "Fiscal Year", + "year": "2099", + "year_start_date": "2099-04-01", + "year_end_date": "2100-03-31", + "company": "_Test Company 2", + } + ).insert(ignore_permissions=True) + + budget.from_fiscal_year = fy.name + budget.to_fiscal_year = fy.name + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_manual_distribution_total_equals_budget_amount(self): + budget = make_budget( + budget_against="Cost Center", + cost_center="_Test Cost Center - _TC", + distribute_equally=0, + budget_amount=12000, + do_not_save=False, + submit_budget=False, + ) + + for d in budget.budget_distribution: + d.amount = 2000 + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_duplicate_budget_validation(self): + make_budget( + budget_against="Cost Center", + distribute_equally=0, + budget_amount=15000, + do_not_save=False, + submit_budget=False, + ) + + budget = frappe.new_doc("Budget") + budget.company = "_Test Company" + budget.from_fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") + budget.to_fiscal_year = budget.from_fiscal_year + budget.budget_against = "Cost Center" + budget.cost_center = "_Test Cost Center - _TC" + budget.account = "_Test Account Cost for Goods Sold - _TC" + budget.budget_amount = 10000 + + with self.assertRaises(frappe.ValidationError): + budget.insert() + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project": From e40fe9919cea709a377d679dba36bbb223238db7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 31 Oct 2025 13:43:07 +0530 Subject: [PATCH 20/51] refactor: better manual budget distribution ux --- erpnext/accounts/doctype/budget/budget.js | 26 +++++++++++++++++ erpnext/accounts/doctype/budget/budget.json | 4 +-- erpnext/accounts/doctype/budget/budget.py | 28 +++++++++++++++++-- .../accounts/doctype/budget/test_budget.py | 12 ++++---- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 1186b60ab11..4b2aefffb57 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -49,6 +49,15 @@ frappe.ui.form.on("Budget", { frm.trigger("toggle_reqd_fields"); }, + budget_amount(frm) { + if (frm.doc.budget_distribution?.length) { + frm.doc.budget_distribution.forEach((row) => { + row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2); + }); + frm.refresh_field("budget_distribution"); + } + }, + set_null_value: function (frm) { if (frm.doc.budget_against == "Cost Center") { frm.set_value("project", null); @@ -85,3 +94,20 @@ frappe.ui.form.on("Budget", { ); }, }); + +frappe.ui.form.on("Budget Distribution", { + amount(frm, cdt, cdn) { + let row = frappe.get_doc(cdt, cdn); + if (frm.doc.budget_amount) { + row.percent = flt((row.amount / frm.doc.budget_amount) * 100, 2); + frm.refresh_field("budget_distribution"); + } + }, + percent(frm, cdt, cdn) { + let row = frappe.get_doc(cdt, cdn); + if (frm.doc.budget_amount) { + row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2); + frm.refresh_field("budget_distribution"); + } + }, +}); diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 849d4a5e927..e88daf00565 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -231,6 +231,7 @@ { "fieldname": "account", "fieldtype": "Link", + "in_list_view": 1, "label": "Account", "options": "Account", "read_only_depends_on": "eval: doc.revision_of", @@ -258,7 +259,6 @@ "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount", - "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -304,7 +304,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-30 19:07:51.022844", + "modified": "2025-10-31 01:13:15.114440", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 120d78b7430..b9a0909ef45 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -160,6 +160,9 @@ class Budget(Document): def before_save(self): self.allocate_budget() + def on_update(self): + self.validate_distribution_totals() + def allocate_budget(self): if self.revision_of: return @@ -183,14 +186,14 @@ class Budget(Document): def should_regenerate_budget_distribution(self): """Check whether budget distribution should be recalculated.""" old_doc = self.get_doc_before_save() if not self.is_new() else None - - if not self.budget_distribution: + if not old_doc or not self.budget_distribution: return True if old_doc: changed_fields = [ "from_fiscal_year", "to_fiscal_year", + "budget_amount", "allocation_frequency", "distribute_equally", ] @@ -255,9 +258,28 @@ class Budget(Document): row.amount = 0 row.percent = 0 else: - row.amount = flt(self.budget_amount * row_percent / 100, 2) + row.amount = flt(self.budget_amount * row_percent / 100, 3) row.percent = flt(row_percent, 3) + def validate_distribution_totals(self): + if self.should_regenerate_budget_distribution(): + return + + total_amount = sum(d.amount for d in self.budget_distribution) + total_percent = sum(d.percent for d in self.budget_distribution) + + if flt(abs(total_amount - self.budget_amount), 2) > 0.10: + frappe.throw( + _("Total distributed amount {0} must equal Budget Amount {1}").format( + flt(total_amount, 2), self.budget_amount + ) + ) + + if round(total_percent, 2) != 100: + frappe.throw( + _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2)) + ) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 6f1e751993b..1768d69349a 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -551,16 +551,16 @@ class TestBudget(ERPNextTestSuite): budget_amount=30000, budget_start_date="2025-04-01", budget_end_date="2025-06-30", - do_not_save=True, + do_not_save=False, submit_budget=False, ) budget.budget_distribution = [] for row in [ - {"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000}, - {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000}, - {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000}, + {"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000, "percent": 33.33}, + {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000, "percent": 50.00}, + {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000, "percent": 16.67}, ]: budget.append("budget_distribution", row) @@ -608,10 +608,10 @@ class TestBudget(ERPNextTestSuite): def test_duplicate_budget_validation(self): make_budget( budget_against="Cost Center", - distribute_equally=0, + distribute_equally=1, budget_amount=15000, do_not_save=False, - submit_budget=False, + submit_budget=True, ) budget = frappe.new_doc("Budget") From e4bae765807b6477e4ad05c8ec78d790122f1db6 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 5 Nov 2025 01:21:54 +0530 Subject: [PATCH 21/51] refactor: add budget start and end date field on the parent --- erpnext/accounts/doctype/budget/budget.json | 26 +++++++------ erpnext/accounts/doctype/budget/budget.py | 37 ++++++++++--------- .../accounts/doctype/budget/test_budget.py | 1 - .../budget_distribution.json | 8 ++-- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index e88daf00565..9565f16e673 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -17,7 +17,8 @@ "amended_from", "from_fiscal_year", "to_fiscal_year", - "distribution_type", + "budget_start_date", + "budget_end_date", "allocation_frequency", "budget_amount", "section_break_nwug", @@ -237,15 +238,6 @@ "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, - { - "default": "Percent", - "fieldname": "distribution_type", - "fieldtype": "Select", - "label": "Distribution Type", - "options": "Amount\nPercent", - "read_only_depends_on": "eval: doc.revision_of", - "reqd": 1 - }, { "default": "Monthly", "fieldname": "allocation_frequency", @@ -298,13 +290,25 @@ "options": "Fiscal Year", "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 + }, + { + "fieldname": "budget_start_date", + "fieldtype": "Date", + "hidden": 1, + "label": "Budget Start Date" + }, + { + "fieldname": "budget_end_date", + "fieldtype": "Date", + "hidden": 1, + "label": "Budget End Date" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-31 01:13:15.114440", + "modified": "2025-11-05 01:00:46.470251", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index b9a0909ef45..2eff6cbac02 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -54,10 +54,11 @@ class Budget(Document): budget_against: DF.Literal["", "Cost Center", "Project"] budget_amount: DF.Currency budget_distribution: DF.Table[BudgetDistribution] + budget_end_date: DF.Date | None + budget_start_date: DF.Date | None company: DF.Link cost_center: DF.Link | None distribute_equally: DF.Check - distribution_type: DF.Literal["Amount", "Percent"] from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None @@ -68,11 +69,23 @@ class Budget(Document): def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) + self.set_fiscal_year_dates() self.validate_duplicate() self.validate_account() self.set_null_value() self.validate_applicable_for() + def set_fiscal_year_dates(self): + if self.from_fiscal_year: + self.budget_start_date = frappe.get_cached_value( + "Fiscal Year", self.from_fiscal_year, "year_start_date" + ) + + if self.to_fiscal_year: + self.budget_end_date = frappe.get_cached_value( + "Fiscal Year", self.to_fiscal_year, "year_end_date" + ) + def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) @@ -275,7 +288,7 @@ class Budget(Document): ) ) - if round(total_percent, 2) != 100: + if flt(abs(total_percent - 100), 2) > 0.10: frappe.throw( _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2)) ) @@ -361,6 +374,8 @@ def validate_expense_against_budget(args, expense_amount=0): b.budget_amount, b.from_fiscal_year, b.to_fiscal_year, + b.budget_start_date, + b.budget_end_date, IFNULL(b.applicable_on_material_request, 0) AS for_material_request, IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order, IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses, @@ -375,12 +390,7 @@ def validate_expense_against_budget(args, expense_amount=0): WHERE b.company = %s AND b.docstatus = 1 - AND ( - %s BETWEEN - (SELECT year_start_date FROM `tabFiscal Year` WHERE name = b.from_fiscal_year) - AND - (SELECT year_end_date FROM `tabFiscal Year` WHERE name = b.to_fiscal_year) - ) + AND %s BETWEEN b.budget_start_date AND b.budget_end_date AND b.account = %s {condition} """, @@ -627,14 +637,7 @@ def get_actual_expense(args): budget_against_field = args.get("budget_against_field") condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else "" - from_start, _ = frappe.get_cached_value( - "Fiscal Year", args.from_fiscal_year, ["year_start_date", "year_end_date"] - ) - _, to_end = frappe.get_cached_value( - "Fiscal Year", args.to_fiscal_year, ["year_start_date", "year_end_date"] - ) - - date_condition = f"and gle.posting_date between '{from_start}' and '{to_end}'" + date_condition = f"and gle.posting_date between '{args.budget_start_date}' and '{args.budget_end_date}'" if args.is_tree: lft_rgt = frappe.db.get_value( @@ -687,7 +690,7 @@ def get_accumulated_monthly_budget(budget_name, posting_date): .on(bd.parent == b.name) .select(Sum(bd.amount).as_("accumulated_amount")) .where(b.name == budget_name) - .where(bd.end_date >= posting_date) + .where(bd.start_date <= posting_date) .run(as_dict=True) ) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 1768d69349a..a6261e6188c 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -725,7 +725,6 @@ def make_budget(**args): budget.budget_against = budget_against budget.allocation_frequency = "Monthly" - budget.distribution_type = "Amount" budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json index 1a367010c98..85d14599cec 100644 --- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -31,22 +31,20 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount", - "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" + "label": "Amount" }, { "fieldname": "percent", "fieldtype": "Percent", "in_list_view": 1, - "label": "Percent", - "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" + "label": "Percent" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-30 12:35:31.310931", + "modified": "2025-11-03 13:18:28.398198", "modified_by": "Administrator", "module": "Accounts", "name": "Budget Distribution", From 04a44e7e14c67e00912fed8da8e1d704bd9bbee7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 5 Nov 2025 16:07:26 +0530 Subject: [PATCH 22/51] refactor: budget controller --- erpnext/accounts/doctype/budget/budget.py | 4 ++++ erpnext/controllers/budget_controller.py | 23 ++++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 2eff6cbac02..69c1e1eb8f3 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -409,6 +409,10 @@ def validate_budget_records(args, budget_records, expense_amount): args["for_material_request"] = budget.for_material_request args["for_purchase_order"] = budget.for_purchase_order args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year + args["budget_start_date"], args["budget_end_date"] = ( + budget.budget_start_date, + budget.budget_end_date, + ) if yearly_action in ("Stop", "Warn"): compare_expense_with_budget( diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 0325f18b972..33afac4df22 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -162,16 +162,19 @@ class BudgetValidation: def get_budget_records(self) -> list: bud = qb.DocType("Budget") - bud_acc = qb.DocType("Budget Account") + query = ( qb.from_(bud) - .inner_join(bud_acc) - .on(bud.name == bud_acc.parent) .select( bud.name, bud.budget_against, bud.company, - bud.monthly_distribution, + bud.account, + bud.budget_amount, + bud.from_fiscal_year, + bud.to_fiscal_year, + bud.budget_start_date, + bud.budget_end_date, bud.applicable_on_material_request, bud.action_if_annual_budget_exceeded_on_mr, bud.action_if_accumulated_monthly_budget_exceeded_on_mr, @@ -184,13 +187,15 @@ class BudgetValidation: bud.applicable_on_cumulative_expense, bud.action_if_annual_exceeded_on_cumulative_expense, bud.action_if_accumulated_monthly_exceeded_on_cumulative_expense, - bud_acc.account, - bud_acc.budget_amount, ) - .where(bud.docstatus.eq(1) & bud.fiscal_year.eq(self.fiscal_year) & bud.company.eq(self.company)) + .where( + (bud.docstatus == 1) + & (bud.company == self.company) + & (bud.budget_start_date <= self.doc_date) + & (bud.budget_end_date >= self.doc_date) + ) ) - # add dimension fields for x in self.dimensions: query = query.select(bud[x.get("fieldname")]) @@ -312,8 +317,8 @@ class BudgetValidation: frappe.bold(key[2]), frappe.bold(frappe.unscrub(key[0])), frappe.bold(key[1]), - frappe.bold(fmt_money(annual_diff, currency=currency)), frappe.bold(fmt_money(budget_amt, currency=currency)), + frappe.bold(fmt_money(annual_diff, currency=currency)), ) self.execute_action(config.action_for_annual, _msg) From 09ed3066d8d2b581b03270c8daee0c3c0b0695ae Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 6 Nov 2025 03:15:10 +0530 Subject: [PATCH 23/51] fix: test cases and fiscal year validation --- erpnext/accounts/doctype/budget/budget.py | 42 +++++++++---------- .../accounts/doctype/budget/test_budget.py | 28 ++++++------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 69c1e1eb8f3..ffbdb3c0955 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -69,12 +69,26 @@ class Budget(Document): def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) + self.validate_fiscal_year() self.set_fiscal_year_dates() self.validate_duplicate() self.validate_account() self.set_null_value() self.validate_applicable_for() + def validate_fiscal_year(self): + if self.from_fiscal_year: + self.validate_fiscal_year_company(self.from_fiscal_year, self.company) + if self.to_fiscal_year: + self.validate_fiscal_year_company(self.to_fiscal_year, self.company) + + def validate_fiscal_year_company(self, fiscal_year, company): + linked_companies = frappe.get_all( + "Fiscal Year Company", filters={"parent": fiscal_year}, pluck="company" + ) + if linked_companies and company not in linked_companies: + frappe.throw(_("Fiscal Year {0} is not available for Company {1}.").format(fiscal_year, company)) + def set_fiscal_year_dates(self): if self.from_fiscal_year: self.budget_start_date = frappe.get_cached_value( @@ -94,10 +108,6 @@ class Budget(Document): if not account: return - year_start_date, year_end_date = get_fiscal_year_date_range( - self.from_fiscal_year, self.to_fiscal_year - ) - existing_budget = frappe.db.sql( f""" SELECT name, account @@ -113,13 +123,12 @@ class Budget(Document): AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s ) """, - (self.company, budget_against, account, self.name, year_end_date, year_start_date), + (self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date), as_dict=True, ) if existing_budget: d = existing_budget[0] - print(d) frappe.throw( _( "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." @@ -185,7 +194,6 @@ class Budget(Document): self.set("budget_distribution", []) - self.set_budget_date_range() periods = self.get_budget_periods() total_periods = len(periods) row_percent = 100 / total_periods if total_periods else 0 @@ -216,18 +224,6 @@ class Budget(Document): return bool(self.distribute_equally) - def set_budget_date_range(self): - """Set budget start and end dates based on selected fiscal years.""" - from_fiscal_year = frappe.db.get_value( - "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True - ) - to_fiscal_year = frappe.db.get_value( - "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True - ) - - self.budget_start_date = from_fiscal_year.year_start_date - self.budget_end_date = to_fiscal_year.year_end_date - def get_budget_periods(self): """Return list of (start_date, end_date) tuples based on frequency.""" frequency = self.allocation_frequency @@ -299,6 +295,9 @@ def validate_expense_against_budget(args, expense_amount=0): if not frappe.db.count("Budget", cache=True): return + if not args.fiscal_year: + args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0] + posting_date = getdate(args.get("posting_date")) posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0] year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year) @@ -522,7 +521,7 @@ def get_expense_breakup(args, currency, budget_against): "status": [["!=", "Stopped"]], "docstatus": 1, "material_request_type": "Purchase", - "schedule_date": [["between", from_date, to_date]], + "schedule_date": [["between", [from_date, to_date]]], "item_code": args.item_code, "per_ordered": [["<", 100]], } @@ -547,7 +546,7 @@ def get_expense_breakup(args, currency, budget_against): { "status": [["!=", "Closed"]], "docstatus": 1, - "transaction_date": [["between", from_date, to_date]], + "transaction_date": [["between", [from_date, to_date]]], "item_code": args.item_code, "per_billed": [["<", 100]], } @@ -761,7 +760,6 @@ def revise_budget(budget_name): if old_budget.docstatus == 1: old_budget.cancel() - frappe.db.commit() new_budget = frappe.copy_doc(old_budget) new_budget.docstatus = 0 diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index a6261e6188c..537942e7c8e 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -26,7 +26,7 @@ class TestBudget(ERPNextTestSuite): cls.make_projects() def setUp(self): - frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", True) + frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False) self.company = "_Test Company" self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") self.account = "_Test Account Cost for Goods Sold - _TC" @@ -113,7 +113,7 @@ class TestBudget(ERPNextTestSuite): action_if_accumulated_monthly_budget_exceeded_on_mr="Stop", budget_against="Cost Center", do_not_save=False, - subimit_budget=True, + submit_budget=True, ) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") @@ -122,7 +122,6 @@ class TestBudget(ERPNextTestSuite): budget.name, nowdate(), ) - mr = frappe.get_doc( { "doctype": "Material Request", @@ -579,12 +578,13 @@ class TestBudget(ERPNextTestSuite): "year": "2099", "year_start_date": "2099-04-01", "year_end_date": "2100-03-31", - "company": "_Test Company 2", + "companies": [{"company": "_Test Company 2"}], } ).insert(ignore_permissions=True) budget.from_fiscal_year = fy.name budget.to_fiscal_year = fy.name + budget.company = "_Test Company" with self.assertRaises(frappe.ValidationError): budget.save() @@ -606,7 +606,7 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_duplicate_budget_validation(self): - make_budget( + budget = make_budget( budget_against="Cost Center", distribute_equally=1, budget_amount=15000, @@ -614,17 +614,17 @@ class TestBudget(ERPNextTestSuite): submit_budget=True, ) - budget = frappe.new_doc("Budget") - budget.company = "_Test Company" - budget.from_fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") - budget.to_fiscal_year = budget.from_fiscal_year - budget.budget_against = "Cost Center" - budget.cost_center = "_Test Cost Center - _TC" - budget.account = "_Test Account Cost for Goods Sold - _TC" - budget.budget_amount = 10000 + new_budget = frappe.new_doc("Budget") + new_budget.company = "_Test Company" + new_budget.from_fiscal_year = budget.from_fiscal_year + new_budget.to_fiscal_year = new_budget.from_fiscal_year + new_budget.budget_against = "Cost Center" + new_budget.cost_center = "_Test Cost Center - _TC" + new_budget.account = "_Test Account Cost for Goods Sold - _TC" + new_budget.budget_amount = 10000 with self.assertRaises(frappe.ValidationError): - budget.insert() + new_budget.insert() def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): From 57f9faa15af93c80acab7ea43b9d960a660964e4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 6 Nov 2025 14:03:09 +0530 Subject: [PATCH 24/51] fix: validate existing expenses when revising or modifying budget amounts --- erpnext/accounts/doctype/budget/budget.json | 22 ++--- erpnext/accounts/doctype/budget/budget.py | 54 +++++++++++-- .../accounts/doctype/budget/test_budget.py | 81 ++++++++----------- 3 files changed, 95 insertions(+), 62 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 9565f16e673..a17919c52b0 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -19,7 +19,7 @@ "to_fiscal_year", "budget_start_date", "budget_end_date", - "allocation_frequency", + "distribution_frequency", "budget_amount", "section_break_nwug", "distribute_equally", @@ -238,15 +238,6 @@ "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, - { - "default": "Monthly", - "fieldname": "allocation_frequency", - "fieldtype": "Select", - "label": "Allocation Frequency", - "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", - "read_only_depends_on": "eval: doc.revision_of", - "reqd": 1 - }, { "fieldname": "budget_amount", "fieldtype": "Currency", @@ -302,13 +293,22 @@ "fieldtype": "Date", "hidden": 1, "label": "Budget End Date" + }, + { + "default": "Monthly", + "fieldname": "distribution_frequency", + "fieldtype": "Select", + "label": "Distribution Frequency", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", + "read_only_depends_on": "eval: doc.revision_of", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-05 01:00:46.470251", + "modified": "2025-11-06 10:36:35.565701", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ffbdb3c0955..5bbf8dfc0bd 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -45,7 +45,6 @@ class Budget(Document): action_if_annual_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"] - allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -59,6 +58,7 @@ class Budget(Document): company: DF.Link cost_center: DF.Link | None distribute_equally: DF.Check + distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None @@ -75,6 +75,7 @@ class Budget(Document): self.validate_account() self.set_null_value() self.validate_applicable_for() + self.validate_existing_expenses() def validate_fiscal_year(self): if self.from_fiscal_year: @@ -94,12 +95,14 @@ class Budget(Document): self.budget_start_date = frappe.get_cached_value( "Fiscal Year", self.from_fiscal_year, "year_start_date" ) - if self.to_fiscal_year: self.budget_end_date = frappe.get_cached_value( "Fiscal Year", self.to_fiscal_year, "year_end_date" ) + if self.budget_start_date > self.budget_end_date: + frappe.throw(_("From Fiscal Year cannot be greater than To Fiscal Year")) + def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) @@ -179,6 +182,47 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 + def validate_existing_expenses(self): + if self.is_new() and self.revision_of: + return + + args = frappe._dict( + { + "company": self.company, + "account": self.account, + "budget_start_date": self.budget_start_date, + "budget_end_date": self.budget_end_date, + "budget_against_field": frappe.scrub(self.budget_against), + "budget_against_doctype": frappe.unscrub(self.budget_against), + } + ) + + args[args.budget_against_field] = self.get(args.budget_against_field) + + if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): + args.is_tree = True + else: + args.is_tree = False + + actual_spent = get_actual_expense(args) + + if actual_spent > self.budget_amount: + frappe.throw( + _( + "Spending for Account {0} ({1}) between {2} and {3} " + "has already exceeded the new allocated budget. " + "Spent: {4}, Budget: {5}" + ).format( + frappe.bold(self.account), + frappe.bold(self.company), + frappe.bold(self.budget_start_date), + frappe.bold(self.budget_end_date), + frappe.bold(frappe.utils.fmt_money(actual_spent)), + frappe.bold(frappe.utils.fmt_money(self.budget_amount)), + ), + title=_("Budget Limit Exceeded"), + ) + def before_save(self): self.allocate_budget() @@ -215,7 +259,7 @@ class Budget(Document): "from_fiscal_year", "to_fiscal_year", "budget_amount", - "allocation_frequency", + "distribution_frequency", "distribute_equally", ] for field in changed_fields: @@ -226,7 +270,7 @@ class Budget(Document): def get_budget_periods(self): """Return list of (start_date, end_date) tuples based on frequency.""" - frequency = self.allocation_frequency + frequency = self.distribution_frequency periods = [] start_date = getdate(self.budget_start_date) @@ -279,7 +323,7 @@ class Budget(Document): if flt(abs(total_amount - self.budget_amount), 2) > 0.10: frappe.throw( - _("Total distributed amount {0} must equal Budget Amount {1}").format( + _("Total distributed amount {0} must be equal to Budget Amount {1}").format( flt(total_amount, 2), self.budget_amount ) ) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 537942e7c8e..dd31cdc636f 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -446,24 +446,23 @@ class TestBudget(ERPNextTestSuite): po.cancel() jv.cancel() - def test_distribution_date_validation(self): - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = self.account - budget.budget_amount = 100000 - - start = getdate("2025-04-10") - end = getdate("2025-04-05") - - budget.append( - "budget_distribution", + def test_fiscal_year_validation(self): + frappe.get_doc( { - "start_date": start, - "end_date": end, - "amount": 50000, - }, + "doctype": "Fiscal Year", + "year": "2100", + "year_start_date": "2100-04-01", + "year_end_date": "2101-03-31", + "companies": [{"company": "_Test Company"}], + } + ).insert(ignore_permissions=True) + + budget = make_budget( + budget_against="Cost Center", + from_fiscal_year="2100", + to_fiscal_year="2099", + do_not_save=True, + submit_budget=False, ) with self.assertRaises(frappe.ValidationError): @@ -473,35 +472,14 @@ class TestBudget(ERPNextTestSuite): budget = make_budget( budget_against="Cost Center", applicable_on_cumulative_expense=True, + distribute_equally=0, + budget_amount=12000, do_not_save=False, - submit_budget=True, + submit_budget=False, ) - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = ("_Test Account Cost for Goods Sold - _TC",) - budget.budget_amount = 12000 - budget.start_date = getdate("2025-04-01") - budget.end_date = getdate("2025-06-30") - - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-04-01"), - "end_date": getdate("2025-04-30"), - "amount": 6000, - }, - ) - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-05-01"), - "end_date": getdate("2025-06-30"), - "amount": 5000, - }, - ) + for row in budget.budget_distribution: + row.amount = 2000 with self.assertRaises(frappe.ValidationError): budget.save() @@ -531,6 +509,7 @@ class TestBudget(ERPNextTestSuite): self.assertEqual(old_budget.docstatus, 2) def test_revision_preserves_distribution(self): + set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center - _TC") budget = make_budget( budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True ) @@ -634,6 +613,7 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again budget_against = budget_against_CC or "_Test Cost Center - _TC" fiscal_year = get_fiscal_year(nowdate())[0] + fiscal_year_start_date, fiscal_year_end_date = get_fiscal_year(nowdate())[1:3] args = frappe._dict( { @@ -644,12 +624,21 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again "from_fiscal_year": fiscal_year, "to_fiscal_year": fiscal_year, "budget_against_field": budget_against_field, + "budget_start_date": fiscal_year_start_date, + "budget_end_date": fiscal_year_end_date, } ) if not args.get(budget_against_field): args[budget_against_field] = budget_against + args.budget_against_doctype = frappe.unscrub(budget_against_field) + + if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): + args.is_tree = True + else: + args.is_tree = False + existing_expense = get_actual_expense(args) if existing_expense: @@ -714,8 +703,8 @@ def make_budget(**args): else: budget.cost_center = cost_center or "_Test Cost Center - _TC" - budget.from_fiscal_year = fiscal_year - budget.to_fiscal_year = fiscal_year + budget.from_fiscal_year = args.from_fiscal_year or fiscal_year + budget.to_fiscal_year = args.to_fiscal_year or fiscal_year budget.company = "_Test Company" budget.account = "_Test Account Cost for Goods Sold - _TC" budget.budget_amount = args.budget_amount or 200000 @@ -724,7 +713,7 @@ def make_budget(**args): budget.action_if_accumulated_monthly_budget_exceeded = "Ignore" budget.budget_against = budget_against - budget.allocation_frequency = "Monthly" + budget.distribution_frequency = "Monthly" budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: From 4abe2e82a066326573644d0f641f9f1c400e38c4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 14:35:27 +0530 Subject: [PATCH 25/51] fix(patch): migrate old Budget data to new structure --- erpnext/patches.txt | 2 +- ...rate_submitted_budgets_to_new_structure.py | 102 ++++++++++++++++++ .../patches/v16_0/set_total_budget_amount.py | 16 --- 3 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py delete mode 100644 erpnext/patches/v16_0/set_total_budget_amount.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index a894f8cb44a..7d3296fe327 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -448,4 +448,4 @@ erpnext.patches.v16_0.update_serial_batch_entries erpnext.patches.v16_0.set_company_wise_warehouses erpnext.patches.v16_0.set_valuation_method_on_companies erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table -erpnext.patches.v16_0.set_total_budget_amount +erpnext.patches.v16_0.migrate_submitted_budgets_to_new_structure diff --git a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py b/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py new file mode 100644 index 00000000000..d803a852d3f --- /dev/null +++ b/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py @@ -0,0 +1,102 @@ +import frappe +from frappe.utils import add_months, flt, get_first_day, get_last_day + + +def execute(): + submitted_budgets = frappe.get_all("Budget", filters={"docstatus": 1}, pluck="name") + + for old_budget in submitted_budgets: + old_bud = frappe.get_doc("Budget", old_budget) + + old_accounts = frappe.get_all( + "Budget Account", + filters={"parent": old_bud.name}, + fields=["account", "budget_amount"], + order_by="idx asc", + ) + + if not old_accounts: + continue + + old_distribution = [] + if old_bud.monthly_distribution: + old_distribution = frappe.get_all( + "Monthly Distribution Percentage", + filters={"parent": old_bud.monthly_distribution}, + fields=["percentage_allocation"], + order_by="idx asc", + ) + + if old_distribution: + percentage_list = [flt(d.percentage) for d in old_distribution] + else: + percentage_list = [100 / 12] * 12 + + fy = frappe.get_doc("Fiscal Year", old_bud.fiscal_year) + fy_start = fy.year_start_date + fy_end = fy.year_end_date + + for acc in old_accounts: + new = frappe.new_doc("Budget") + + new.company = old_bud.company + new.cost_center = old_bud.cost_center + new.project = old_bud.project + new.fiscal_year = fy.name + + new.from_fiscal_year = fy.name + new.to_fiscal_year = fy.name + new.budget_start_date = fy_start + new.budget_end_date = fy_end + + new.account = acc.account + new.budget_amount = flt(acc.budget_amount) + new.distribution_frequency = "Monthly" + + new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 + + fields_to_copy = [ + "applicable_on_material_request", + "action_if_annual_budget_exceeded_on_mr", + "action_if_accumulated_monthly_budget_exceeded_on_mr", + "applicable_on_purchase_order", + "action_if_annual_budget_exceeded_on_po", + "action_if_accumulated_monthly_budget_exceeded_on_po", + "applicable_on_booking_actual_expenses", + "action_if_annual_budget_exceeded", + "action_if_accumulated_monthly_budget_exceeded", + "applicable_on_cumulative_expense", + "action_if_annual_exceeded_on_cumulative_expense", + "action_if_accumulated_monthly_exceeded_on_cumulative_expense", + ] + + for field in fields_to_copy: + if hasattr(old_bud, field): + new.set(field, old_bud.get(field)) + + start = fy_start + for percentage in percentage_list: + row_start = get_first_day(start) + row_end = get_last_day(start) + + new.append( + "budget_distribution", + { + "start_date": row_start, + "end_date": row_end, + "percent": percentage, + "amount": new.budget_amount * percentage / 100, + }, + ) + + start = add_months(start, 1) + + new.flags.ignore_validate = True + new.flags.ignore_mandatory = True + new.flags.ignore_links = True + new.flags.ignore_permissions = True + + new.insert(ignore_permissions=True, ignore_mandatory=True) + new.submit() + + old_bud.cancel() diff --git a/erpnext/patches/v16_0/set_total_budget_amount.py b/erpnext/patches/v16_0/set_total_budget_amount.py deleted file mode 100644 index 163f0f17274..00000000000 --- a/erpnext/patches/v16_0/set_total_budget_amount.py +++ /dev/null @@ -1,16 +0,0 @@ -import frappe - - -def execute(): - if frappe.db.has_column("Budget", "total_budget_amount"): - frappe.db.sql( - """ - UPDATE `tabBudget` b - SET b.total_budget_amount = ( - SELECT SUM(ba.budget_amount) - FROM `tabBudget Account` ba - WHERE ba.parent = b.name - ) - WHERE IFNULL(b.total_budget_amount, 0) = 0 - """ - ) From e08793cb8fd82e5be35ec7d921d8fa4ad4c1673b Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 17:43:41 +0530 Subject: [PATCH 26/51] fix(patch): update naming series for budget --- erpnext/accounts/doctype/budget/budget.json | 4 +-- erpnext/accounts/doctype/budget/budget.py | 2 +- erpnext/patches.txt | 2 +- ...igrate_budget_records_to_new_structure.py} | 35 ++++++++++--------- 4 files changed, 23 insertions(+), 20 deletions(-) rename erpnext/patches/v16_0/{migrate_submitted_budgets_to_new_structure.py => migrate_budget_records_to_new_structure.py} (76%) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index a17919c52b0..bf1e7d091c1 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -188,7 +188,7 @@ "fieldtype": "Select", "label": "Series", "no_copy": 1, - "options": "BUDGET-.YYYY.-", + "options": "BUDGET-.########", "print_hide": 1, "reqd": 1, "set_only_once": 1 @@ -308,7 +308,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-06 10:36:35.565701", + "modified": "2025-11-17 17:38:27.759355", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 5bbf8dfc0bd..ad16cf00a9a 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -60,7 +60,7 @@ class Budget(Document): distribute_equally: DF.Check distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] from_fiscal_year: DF.Link - naming_series: DF.Literal["BUDGET-.YYYY.-"] + naming_series: DF.Literal["BUDGET-.########"] project: DF.Link | None revision_of: DF.Data | None to_fiscal_year: DF.Link diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7d3296fe327..74f6e8a275b 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -448,4 +448,4 @@ erpnext.patches.v16_0.update_serial_batch_entries erpnext.patches.v16_0.set_company_wise_warehouses erpnext.patches.v16_0.set_valuation_method_on_companies erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table -erpnext.patches.v16_0.migrate_submitted_budgets_to_new_structure +erpnext.patches.v16_0.migrate_budget_records_to_new_structure diff --git a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py similarity index 76% rename from erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py rename to erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index d803a852d3f..1c94f0e34bd 100644 --- a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -3,14 +3,14 @@ from frappe.utils import add_months, flt, get_first_day, get_last_day def execute(): - submitted_budgets = frappe.get_all("Budget", filters={"docstatus": 1}, pluck="name") + budgets = frappe.get_all("Budget", filters={"docstatus": ["in", [0, 1]]}, pluck="name") - for old_budget in submitted_budgets: - old_bud = frappe.get_doc("Budget", old_budget) + for budget in budgets: + old_budget = frappe.get_doc("Budget", budget) old_accounts = frappe.get_all( "Budget Account", - filters={"parent": old_bud.name}, + filters={"parent": old_budget.name}, fields=["account", "budget_amount"], order_by="idx asc", ) @@ -19,10 +19,10 @@ def execute(): continue old_distribution = [] - if old_bud.monthly_distribution: + if old_budget.monthly_distribution: old_distribution = frappe.get_all( "Monthly Distribution Percentage", - filters={"parent": old_bud.monthly_distribution}, + filters={"parent": old_budget.monthly_distribution}, fields=["percentage_allocation"], order_by="idx asc", ) @@ -32,16 +32,16 @@ def execute(): else: percentage_list = [100 / 12] * 12 - fy = frappe.get_doc("Fiscal Year", old_bud.fiscal_year) + fy = frappe.get_doc("Fiscal Year", old_budget.fiscal_year) fy_start = fy.year_start_date fy_end = fy.year_end_date for acc in old_accounts: new = frappe.new_doc("Budget") - new.company = old_bud.company - new.cost_center = old_bud.cost_center - new.project = old_bud.project + new.company = old_budget.company + new.cost_center = old_budget.cost_center + new.project = old_budget.project new.fiscal_year = fy.name new.from_fiscal_year = fy.name @@ -71,8 +71,8 @@ def execute(): ] for field in fields_to_copy: - if hasattr(old_bud, field): - new.set(field, old_bud.get(field)) + if hasattr(old_budget, field): + new.set(field, old_budget.get(field)) start = fy_start for percentage in percentage_list: @@ -92,11 +92,14 @@ def execute(): start = add_months(start, 1) new.flags.ignore_validate = True - new.flags.ignore_mandatory = True new.flags.ignore_links = True - new.flags.ignore_permissions = True new.insert(ignore_permissions=True, ignore_mandatory=True) - new.submit() - old_bud.cancel() + if old_budget.docstatus == 1: + new.submit() + + if old_budget.docstatus == 1: + old_budget.cancel() + else: + old_budget.delete() From 22ec48159e1ef396e370abf6c517a005c92eff8a Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 17:59:20 +0530 Subject: [PATCH 27/51] fix(minor): use corrct field name in patch --- .../patches/v16_0/migrate_budget_records_to_new_structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index 1c94f0e34bd..fd6920cd99e 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -28,7 +28,7 @@ def execute(): ) if old_distribution: - percentage_list = [flt(d.percentage) for d in old_distribution] + percentage_list = [flt(d.percentage_allocation) for d in old_distribution] else: percentage_list = [100 / 12] * 12 From 4576ccbbdc29c79d504149171782fe4db184c9fc Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 18 Nov 2025 11:31:52 +0530 Subject: [PATCH 28/51] fix: use new naming series --- .../v16_0/migrate_budget_records_to_new_structure.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index fd6920cd99e..1051184c4a2 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -8,14 +8,14 @@ def execute(): for budget in budgets: old_budget = frappe.get_doc("Budget", budget) - old_accounts = frappe.get_all( + accounts = frappe.get_all( "Budget Account", filters={"parent": old_budget.name}, fields=["account", "budget_amount"], order_by="idx asc", ) - if not old_accounts: + if not accounts: continue old_distribution = [] @@ -36,9 +36,11 @@ def execute(): fy_start = fy.year_start_date fy_end = fy.year_end_date - for acc in old_accounts: + for account in accounts: new = frappe.new_doc("Budget") + new.naming_series = "BUDGET-.########" + new.budget_against = old_budget.budget_against new.company = old_budget.company new.cost_center = old_budget.cost_center new.project = old_budget.project @@ -49,8 +51,8 @@ def execute(): new.budget_start_date = fy_start new.budget_end_date = fy_end - new.account = acc.account - new.budget_amount = flt(acc.budget_amount) + new.account = account.account + new.budget_amount = flt(account.budget_amount) new.distribution_frequency = "Monthly" new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 From 4a03462890ac8fce7e558f1aa51f59c43d4a3e0e Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 18 Nov 2025 11:46:45 +0530 Subject: [PATCH 29/51] refactor: use params instead of args --- erpnext/accounts/doctype/budget/budget.py | 126 +++++++++++----------- 1 file changed, 66 insertions(+), 60 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ad16cf00a9a..5a0f2b0dde4 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -334,16 +334,16 @@ class Budget(Document): ) -def validate_expense_against_budget(args, expense_amount=0): - args = frappe._dict(args) +def validate_expense_against_budget(params, expense_amount=0): + params = frappe._dict(params) if not frappe.db.count("Budget", cache=True): return - if not args.fiscal_year: - args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0] + if not params.fiscal_year: + params.fiscal_year = get_fiscal_year(params.get("posting_date"), company=params.get("company"))[0] - posting_date = getdate(args.get("posting_date")) - posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0] + posting_date = getdate(params.get("posting_date")) + posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0] year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year) budget_exists = frappe.db.sql( @@ -356,24 +356,24 @@ def validate_expense_against_budget(args, expense_amount=0): and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s limit 1 """, - (args.company, year_end_date, year_start_date), + (params.company, year_end_date, year_start_date), ) if not budget_exists: return - if args.get("company"): + if params.get("company"): frappe.flags.exception_approver_role = frappe.get_cached_value( - "Company", args.get("company"), "exception_budget_approver_role" + "Company", params.get("company"), "exception_budget_approver_role" ) - if not args.account: - args.account = args.get("expense_account") + if not params.account: + params.account = params.get("expense_account") - if not (args.get("account") and args.get("cost_center")) and args.item_code: - args.cost_center, args.account = get_item_details(args) + if not (params.get("account") and params.get("cost_center")) and params.item_code: + params.cost_center, params.account = get_item_details(params) - if not args.account: + if not params.account: return default_dimensions = [ @@ -391,23 +391,23 @@ def validate_expense_against_budget(args, expense_amount=0): budget_against = dimension.get("fieldname") if ( - args.get(budget_against) - and args.account - and (frappe.get_cached_value("Account", args.account, "root_type") == "Expense") + params.get(budget_against) + and params.account + and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense") ): doctype = dimension.get("document_type") if frappe.get_cached_value("DocType", doctype, "is_tree"): - lft, rgt = frappe.get_cached_value(doctype, args.get(budget_against), ["lft", "rgt"]) + lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"]) condition = f"""and exists(select name from `tab{doctype}` where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec - args.is_tree = True + params.is_tree = True else: - condition = f"and b.{budget_against}={frappe.db.escape(args.get(budget_against))}" - args.is_tree = False + condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}" + params.is_tree = False - args.budget_against_field = budget_against - args.budget_against_doctype = doctype + params.budget_against_field = budget_against + params.budget_against_doctype = doctype budget_records = frappe.db.sql( f""" @@ -437,29 +437,32 @@ def validate_expense_against_budget(args, expense_amount=0): AND b.account = %s {condition} """, - (args.company, args.posting_date, args.account), + (params.company, params.posting_date, params.account), as_dict=True, ) # nosec if budget_records: - validate_budget_records(args, budget_records, expense_amount) + validate_budget_records(params, budget_records, expense_amount) -def validate_budget_records(args, budget_records, expense_amount): +def validate_budget_records(params, budget_records, expense_amount): for budget in budget_records: if flt(budget.budget_amount): - yearly_action, monthly_action = get_actions(args, budget) - args["for_material_request"] = budget.for_material_request - args["for_purchase_order"] = budget.for_purchase_order - args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year - args["budget_start_date"], args["budget_end_date"] = ( + yearly_action, monthly_action = get_actions(params, budget) + params["for_material_request"] = budget.for_material_request + params["for_purchase_order"] = budget.for_purchase_order + params["from_fiscal_year"], params["to_fiscal_year"] = ( + budget.from_fiscal_year, + budget.to_fiscal_year, + ) + params["budget_start_date"], params["budget_end_date"] = ( budget.budget_start_date, budget.budget_end_date, ) if yearly_action in ("Stop", "Warn"): compare_expense_with_budget( - args, + params, flt(budget.budget_amount), _("Annual"), yearly_action, @@ -468,12 +471,12 @@ def validate_budget_records(args, budget_records, expense_amount): ) if monthly_action in ["Stop", "Warn"]: - budget_amount = get_accumulated_monthly_budget(budget.name, args.posting_date) + budget_amount = get_accumulated_monthly_budget(budget.name, params.posting_date) - args["month_end_date"] = get_last_day(args.posting_date) + params["month_end_date"] = get_last_day(params.posting_date) compare_expense_with_budget( - args, + params, budget_amount, _("Accumulated Monthly"), monthly_action, @@ -482,38 +485,41 @@ def validate_budget_records(args, budget_records, expense_amount): ) -def compare_expense_with_budget(args, budget_amount, action_for, action, budget_against, amount=0): - args.actual_expense, args.requested_amount, args.ordered_amount = get_actual_expense(args), 0, 0 +def compare_expense_with_budget(params, budget_amount, action_for, action, budget_against, amount=0): + params.actual_expense, params.requested_amount, params.ordered_amount = get_actual_expense(params), 0, 0 if not amount: - args.requested_amount, args.ordered_amount = get_requested_amount(args), get_ordered_amount(args) + params.requested_amount, params.ordered_amount = ( + get_requested_amount(params), + get_ordered_amount(params), + ) - if args.get("doctype") == "Material Request" and args.for_material_request: - amount = args.requested_amount + args.ordered_amount + if params.get("doctype") == "Material Request" and params.for_material_request: + amount = params.requested_amount + params.ordered_amount - elif args.get("doctype") == "Purchase Order" and args.for_purchase_order: - amount = args.ordered_amount + elif params.get("doctype") == "Purchase Order" and params.for_purchase_order: + amount = params.ordered_amount - total_expense = args.actual_expense + amount + total_expense = params.actual_expense + amount if total_expense > budget_amount: - if args.actual_expense > budget_amount: - diff = args.actual_expense - budget_amount + if params.actual_expense > budget_amount: + diff = params.actual_expense - budget_amount _msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It is already exceeded by {5}.") else: diff = total_expense - budget_amount _msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It will be exceeded by {5}.") - currency = frappe.get_cached_value("Company", args.company, "default_currency") + currency = frappe.get_cached_value("Company", params.company, "default_currency") msg = _msg.format( _(action_for), - frappe.bold(args.account), - frappe.unscrub(args.budget_against_field), + frappe.bold(params.account), + frappe.unscrub(params.budget_against_field), frappe.bold(budget_against), frappe.bold(fmt_money(budget_amount, currency=currency)), frappe.bold(fmt_money(diff, currency=currency)), ) - msg += get_expense_breakup(args, currency, budget_against) + msg += get_expense_breakup(params, currency, budget_against) if frappe.flags.exception_approver_role and frappe.flags.exception_approver_role in frappe.get_roles( frappe.session.user @@ -526,19 +532,19 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ frappe.msgprint(msg, indicator="orange", title=_("Budget Exceeded")) -def get_expense_breakup(args, currency, budget_against): +def get_expense_breakup(params, currency, budget_against): msg = "
      {} -
        ".format(_("Total Expenses booked through")) common_filters = frappe._dict( { - args.budget_against_field: budget_against, - "account": args.account, - "company": args.company, + params.budget_against_field: budget_against, + "account": params.account, + "company": params.company, } ) - from_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date") - to_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date") + from_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date") + to_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date") gl_filters = common_filters.copy() gl_filters.update( { @@ -556,7 +562,7 @@ def get_expense_breakup(args, currency, budget_against): filters=gl_filters, ) + " - " - + frappe.bold(fmt_money(args.actual_expense, currency=currency)) + + frappe.bold(fmt_money(params.actual_expense, currency=currency)) + "" ) mr_filters = common_filters.copy() @@ -566,7 +572,7 @@ def get_expense_breakup(args, currency, budget_against): "docstatus": 1, "material_request_type": "Purchase", "schedule_date": [["between", [from_date, to_date]]], - "item_code": args.item_code, + "item_code": params.item_code, "per_ordered": [["<", 100]], } ) @@ -581,7 +587,7 @@ def get_expense_breakup(args, currency, budget_against): filters=mr_filters, ) + " - " - + frappe.bold(fmt_money(args.requested_amount, currency=currency)) + + frappe.bold(fmt_money(params.requested_amount, currency=currency)) + "" ) @@ -591,7 +597,7 @@ def get_expense_breakup(args, currency, budget_against): "status": [["!=", "Closed"]], "docstatus": 1, "transaction_date": [["between", [from_date, to_date]]], - "item_code": args.item_code, + "item_code": params.item_code, "per_billed": [["<", 100]], } ) @@ -606,7 +612,7 @@ def get_expense_breakup(args, currency, budget_against): filters=po_filters, ) + " - " - + frappe.bold(fmt_money(args.ordered_amount, currency=currency)) + + frappe.bold(fmt_money(params.ordered_amount, currency=currency)) + "
      " ) From 9ebf546e1faadde11d1d730b9dc43a397115f895 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 15:16:09 +0530 Subject: [PATCH 30/51] refactor: patch for migration --- ...migrate_budget_records_to_new_structure.py | 215 ++++++++++-------- 1 file changed, 118 insertions(+), 97 deletions(-) diff --git a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index 1051184c4a2..bde5c2984c7 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -3,105 +3,126 @@ from frappe.utils import add_months, flt, get_first_day, get_last_day def execute(): - budgets = frappe.get_all("Budget", filters={"docstatus": ["in", [0, 1]]}, pluck="name") + remove_old_property_setter() - for budget in budgets: - old_budget = frappe.get_doc("Budget", budget) + budget_names = frappe.db.get_list( + "Budget", + filters={"docstatus": ["in", [0, 1]]}, + pluck="name", + ) - accounts = frappe.get_all( - "Budget Account", - filters={"parent": old_budget.name}, - fields=["account", "budget_amount"], - order_by="idx asc", + for budget in budget_names: + migrate_single_budget(budget) + + +def remove_old_property_setter(): + old_property_setter = frappe.db.get_value( + "Property Setter", + { + "doc_type": "Budget", + "field_name": "naming_series", + "property": "options", + "value": "Budget-.YYYY.-", + }, + "name", + ) + + if old_property_setter: + frappe.delete_doc("Property Setter", old_property_setter, force=1) + + +def migrate_single_budget(budget_name): + budget_doc = frappe.get_doc("Budget", budget_name) + + account_rows = frappe.get_all( + "Budget Account", + filters={"parent": budget_name}, + fields=["account", "budget_amount"], + order_by="idx asc", + ) + + if not account_rows: + return + + frappe.db.delete("Budget Account", {"parent": budget_doc.name}) + + percentage_allocations = get_percentage_allocations(budget_doc) + + fiscal_year = frappe.get_cached_value( + "Fiscal Year", + budget_doc.fiscal_year, + ["name", "year_start_date", "year_end_date"], + as_dict=True, + ) + + for row in account_rows: + create_new_budget_from_row(budget_doc, fiscal_year, row, percentage_allocations) + + if budget_doc.docstatus == 1: + budget_doc.cancel() + else: + frappe.delete_doc("Budget", budget_name) + + +def get_percentage_allocations(budget_doc): + if budget_doc.monthly_distribution: + distribution_doc = frappe.get_cached_doc("Monthly Distribution", budget_doc.monthly_distribution) + return [flt(row.percentage_allocation) for row in distribution_doc.percentages] + + return [100 / 12] * 12 + + +def create_new_budget_from_row(budget_doc, fiscal_year, account_row, percentage_allocations): + new_budget = frappe.new_doc("Budget") + + core_fields = ["budget_against", "company", "cost_center", "project"] + for field in core_fields: + new_budget.set(field, budget_doc.get(field)) + + new_budget.from_fiscal_year = fiscal_year.name + new_budget.to_fiscal_year = fiscal_year.name + new_budget.budget_start_date = fiscal_year.year_start_date + new_budget.budget_end_date = fiscal_year.year_end_date + + new_budget.account = account_row.account + new_budget.budget_amount = flt(account_row.budget_amount) + new_budget.distribution_frequency = "Monthly" + new_budget.distribute_equally = 1 if len(set(percentage_allocations)) == 1 else 0 + + copy_fields = [ + "applicable_on_material_request", + "action_if_annual_budget_exceeded_on_mr", + "action_if_accumulated_monthly_budget_exceeded_on_mr", + "applicable_on_purchase_order", + "action_if_annual_budget_exceeded_on_po", + "action_if_accumulated_monthly_budget_exceeded_on_po", + "applicable_on_booking_actual_expenses", + "action_if_annual_budget_exceeded", + "action_if_accumulated_monthly_budget_exceeded", + "applicable_on_cumulative_expense", + "action_if_annual_exceeded_on_cumulative_expense", + "action_if_accumulated_monthly_exceeded_on_cumulative_expense", + ] + + for field in copy_fields: + new_budget.set(field, budget_doc.get(field)) + + current_start = fiscal_year.year_start_date + for percentage in percentage_allocations: + new_budget.append( + "budget_distribution", + { + "start_date": get_first_day(current_start), + "end_date": get_last_day(current_start), + "percent": percentage, + "amount": new_budget.budget_amount * percentage / 100, + }, ) + current_start = add_months(current_start, 1) - if not accounts: - continue + new_budget.flags.ignore_validate = True + new_budget.flags.ignore_links = True + new_budget.insert(ignore_permissions=True, ignore_mandatory=True) - old_distribution = [] - if old_budget.monthly_distribution: - old_distribution = frappe.get_all( - "Monthly Distribution Percentage", - filters={"parent": old_budget.monthly_distribution}, - fields=["percentage_allocation"], - order_by="idx asc", - ) - - if old_distribution: - percentage_list = [flt(d.percentage_allocation) for d in old_distribution] - else: - percentage_list = [100 / 12] * 12 - - fy = frappe.get_doc("Fiscal Year", old_budget.fiscal_year) - fy_start = fy.year_start_date - fy_end = fy.year_end_date - - for account in accounts: - new = frappe.new_doc("Budget") - - new.naming_series = "BUDGET-.########" - new.budget_against = old_budget.budget_against - new.company = old_budget.company - new.cost_center = old_budget.cost_center - new.project = old_budget.project - new.fiscal_year = fy.name - - new.from_fiscal_year = fy.name - new.to_fiscal_year = fy.name - new.budget_start_date = fy_start - new.budget_end_date = fy_end - - new.account = account.account - new.budget_amount = flt(account.budget_amount) - new.distribution_frequency = "Monthly" - - new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 - - fields_to_copy = [ - "applicable_on_material_request", - "action_if_annual_budget_exceeded_on_mr", - "action_if_accumulated_monthly_budget_exceeded_on_mr", - "applicable_on_purchase_order", - "action_if_annual_budget_exceeded_on_po", - "action_if_accumulated_monthly_budget_exceeded_on_po", - "applicable_on_booking_actual_expenses", - "action_if_annual_budget_exceeded", - "action_if_accumulated_monthly_budget_exceeded", - "applicable_on_cumulative_expense", - "action_if_annual_exceeded_on_cumulative_expense", - "action_if_accumulated_monthly_exceeded_on_cumulative_expense", - ] - - for field in fields_to_copy: - if hasattr(old_budget, field): - new.set(field, old_budget.get(field)) - - start = fy_start - for percentage in percentage_list: - row_start = get_first_day(start) - row_end = get_last_day(start) - - new.append( - "budget_distribution", - { - "start_date": row_start, - "end_date": row_end, - "percent": percentage, - "amount": new.budget_amount * percentage / 100, - }, - ) - - start = add_months(start, 1) - - new.flags.ignore_validate = True - new.flags.ignore_links = True - - new.insert(ignore_permissions=True, ignore_mandatory=True) - - if old_budget.docstatus == 1: - new.submit() - - if old_budget.docstatus == 1: - old_budget.cancel() - else: - old_budget.delete() + if budget_doc.docstatus == 1: + new_budget.submit() From 8fd5d7187a99ffc32114244b41a2bc9290eccaaa Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 15:55:57 +0530 Subject: [PATCH 31/51] refactor: replace args with params --- erpnext/accounts/doctype/budget/budget.py | 84 ++++++++++++----------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 5a0f2b0dde4..15ed0eb7317 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -186,7 +186,7 @@ class Budget(Document): if self.is_new() and self.revision_of: return - args = frappe._dict( + params = frappe._dict( { "company": self.company, "account": self.account, @@ -197,14 +197,14 @@ class Budget(Document): } ) - args[args.budget_against_field] = self.get(args.budget_against_field) + params[params.budget_against_field] = self.get(params.budget_against_field) - if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): - args.is_tree = True + if frappe.get_cached_value("DocType", params.budget_against_doctype, "is_tree"): + params.is_tree = True else: - args.is_tree = False + params.is_tree = False - actual_spent = get_actual_expense(args) + actual_spent = get_actual_expense(params) if actual_spent > self.budget_amount: frappe.throw( @@ -619,24 +619,24 @@ def get_expense_breakup(params, currency, budget_against): return msg -def get_actions(args, budget): +def get_actions(params, budget): yearly_action = budget.action_if_annual_budget_exceeded monthly_action = budget.action_if_accumulated_monthly_budget_exceeded - if args.get("doctype") == "Material Request" and budget.for_material_request: + if params.get("doctype") == "Material Request" and budget.for_material_request: yearly_action = budget.action_if_annual_budget_exceeded_on_mr monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_mr - elif args.get("doctype") == "Purchase Order" and budget.for_purchase_order: + elif params.get("doctype") == "Purchase Order" and budget.for_purchase_order: yearly_action = budget.action_if_annual_budget_exceeded_on_po monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_po return yearly_action, monthly_action -def get_requested_amount(args): - item_code = args.get("item_code") - condition = get_other_condition(args, "Material Request") +def get_requested_amount(params): + item_code = params.get("item_code") + condition = get_other_condition(params, "Material Request") data = frappe.db.sql( """ select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount @@ -650,9 +650,9 @@ def get_requested_amount(args): return data[0][0] if data else 0 -def get_ordered_amount(args): - item_code = args.get("item_code") - condition = get_other_condition(args, "Purchase Order") +def get_ordered_amount(params): + item_code = params.get("item_code") + condition = get_other_condition(params, "Purchase Order") data = frappe.db.sql( f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount @@ -666,41 +666,43 @@ def get_ordered_amount(args): return data[0][0] if data else 0 -def get_other_condition(args, for_doc): - condition = f"expense_account = '{args.expense_account}'" - budget_against_field = args.get("budget_against_field") +def get_other_condition(params, for_doc): + condition = f"expense_account = '{params.expense_account}'" + budget_against_field = params.get("budget_against_field") - if budget_against_field and args.get(budget_against_field): - condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'" + if budget_against_field and params.get(budget_against_field): + condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'" date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" - start_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date") - end_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date") + start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date") + end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date") condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'" return condition -def get_actual_expense(args): - if not args.budget_against_doctype: - args.budget_against_doctype = frappe.unscrub(args.budget_against_field) +def get_actual_expense(params): + if not params.budget_against_doctype: + params.budget_against_doctype = frappe.unscrub(params.budget_against_field) - budget_against_field = args.get("budget_against_field") - condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else "" + budget_against_field = params.get("budget_against_field") + condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else "" - date_condition = f"and gle.posting_date between '{args.budget_start_date}' and '{args.budget_end_date}'" + date_condition = ( + f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'" + ) - if args.is_tree: + if params.is_tree: lft_rgt = frappe.db.get_value( - args.budget_against_doctype, args.get(budget_against_field), ["lft", "rgt"], as_dict=1 + params.budget_against_doctype, params.get(budget_against_field), ["lft", "rgt"], as_dict=1 ) - args.update(lft_rgt) + params.update(lft_rgt) condition2 = f""" and exists( - select name from `tab{args.budget_against_doctype}` + select name from `tab{params.budget_against_doctype}` where lft >= %(lft)s and rgt <= %(rgt)s and name = gle.{budget_against_field} ) @@ -724,7 +726,7 @@ def get_actual_expense(args): and gle.docstatus = 1 {condition2} """, - args, + params, )[0][0] ) # nosec @@ -750,16 +752,16 @@ def get_accumulated_monthly_budget(budget_name, posting_date): return flt(result[0]["accumulated_amount"]) if result else 0.0 -def get_item_details(args): +def get_item_details(params): cost_center, expense_account = None, None - if not args.get("company"): + if not params.get("company"): return cost_center, expense_account - if args.item_code: + if params.item_code: item_defaults = frappe.db.get_value( "Item Default", - {"parent": args.item_code, "company": args.get("company")}, + {"parent": params.item_code, "company": params.get("company")}, ["buying_cost_center", "expense_account"], ) if item_defaults: @@ -767,7 +769,7 @@ def get_item_details(args): if not (cost_center and expense_account): for doctype in ["Item Group", "Company"]: - data = get_expense_cost_center(doctype, args) + data = get_expense_cost_center(doctype, params) if not cost_center and data: cost_center = data[0] @@ -781,16 +783,16 @@ def get_item_details(args): return cost_center, expense_account -def get_expense_cost_center(doctype, args): +def get_expense_cost_center(doctype, params): if doctype == "Item Group": return frappe.db.get_value( "Item Default", - {"parent": args.get(frappe.scrub(doctype)), "company": args.get("company")}, + {"parent": params.get(frappe.scrub(doctype)), "company": params.get("company")}, ["buying_cost_center", "expense_account"], ) else: return frappe.db.get_value( - doctype, args.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"] + doctype, params.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"] ) From 0973dbac65f0a855220b1bc6a682ae05e8e6b275 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:56:38 +0530 Subject: [PATCH 32/51] fix: create job card button --- erpnext/manufacturing/doctype/work_order/work_order.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index b651b211e11..6cf512f7288 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -166,9 +166,10 @@ class WorkOrder(Document): operation_details = frappe._dict( frappe.get_all( "Job Card", - fields=["operation", "for_quantity"], + fields=["operation", "sum(for_quantity)"], filters={"docstatus": ("<", 2), "work_order": self.name}, as_list=1, + group_by="operation_id", ) ) From 3327799524e8239843c44842b6fb4251de69528d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:57:15 +0530 Subject: [PATCH 33/51] test: add test case --- .../doctype/job_card/test_job_card.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 4ab07321bd2..425367c519d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -708,6 +708,119 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def test_op_cost_calculation(self): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + make_workstation(workstation_name="Test Workstation Z", hour_rate_rent=240) + operations = [ + {"operation": "Test Operation A1", "workstation": "Test Workstation Z", "time_in_mins": 30}, + ] + + warehouse = create_warehouse("Test Warehouse 123 for Job Card") + setup_operations(operations) + + item_code = "Test Job Card Process Qty Item" + for item in [item_code, item_code + "RM 1", item_code + "RM 2"]: + if not frappe.db.exists("Item", item): + make_item( + item, + { + "item_name": item, + "stock_uom": "Nos", + "is_stock_item": 1, + }, + ) + + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom( + item_code=item_code, + routing=routing_doc.name, + raw_materials=[item_code + "RM 1", item_code + "RM 2"], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry( + item_code=row.item_code, + target=row.source_warehouse, + qty=10, + basic_rate=100, + ) + + wo_doc = make_wo_order_test_record( + production_item=item_code, + bom_no=bom_doc.name, + qty=10, + skip_transfer=1, + wip_warehouse=warehouse, + source_warehouse=warehouse, + ) + + first_job_card = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 1}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", first_job_card) + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": now(), + "to_time": add_to_date(now(), minutes=1), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4)) + s.submit() + + self.assertEqual(s.additional_costs[0].amount, 4) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": "Test Operation A1", + "qty": 6, + "pending_qty": 6, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=1), + "to_time": add_to_date(now(), hours=1, minutes=2), + "completed_qty": 6, + }, + ) + job_card.for_quantity = 6 + job_card.save() + job_card.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) + self.assertEqual(s.additional_costs[0].amount, 8) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" From 3ca3a6d9bba4878a73172cd5cf49976742007b0f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 16:07:50 +0530 Subject: [PATCH 34/51] fix: add filter company and status to job card employee --- erpnext/manufacturing/doctype/job_card/job_card.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 996b3a080d9..d03b16d25cd 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -58,6 +58,15 @@ frappe.ui.form.on("Job Card", { return doc.status === "Complete" ? "green" : "orange"; } }); + + frm.set_query("employee", () => { + return { + filters: { + company: frm.doc.company, + status: "Active", + }, + }; + }); }, set_company_filters(frm, fieldname) { From 3271eaaf0ebbb16553724a11620e9fbffc4cf9f2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 16:13:56 +0530 Subject: [PATCH 35/51] fix: show current company warehouse only in get material from bom MR --- erpnext/stock/doctype/material_request/material_request.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 00f80fbbeca..6614ca1f03f 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -346,6 +346,9 @@ frappe.ui.form.on("Material Request", { label: __("For Warehouse"), options: "Warehouse", reqd: 1, + get_query: function () { + return { filters: { company: frm.doc.company } }; + }, }, { fieldname: "qty", fieldtype: "Float", label: __("Quantity"), reqd: 1, default: 1 }, { From 124293bd63d2e8d87e67dfde6b87356bcbbb8c3a Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 19 Nov 2025 16:03:14 +0530 Subject: [PATCH 36/51] fix: desktop icons --- erpnext/desktop_icon/banking.json | 3 +- erpnext/desktop_icon/opening_&_closing.json | 3 +- erpnext/desktop_icon/subscription.json | 4 +- erpnext/desktop_icon/taxes.json | 3 +- erpnext/workspace_sidebar/settings.json | 269 ++++++++++++++++++++ 5 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 erpnext/workspace_sidebar/settings.json diff --git a/erpnext/desktop_icon/banking.json b/erpnext/desktop_icon/banking.json index f261d6e34b3..34308c086ba 100644 --- a/erpnext/desktop_icon/banking.json +++ b/erpnext/desktop_icon/banking.json @@ -4,12 +4,13 @@ "docstatus": 0, "doctype": "Desktop Icon", "hidden": 0, + "icon": "dollar-sign", "icon_type": "Link", "idx": 5, "label": "Banking", "link_to": "Bank Reconciliation Tool", "link_type": "DocType", - "modified": "2025-11-17 13:34:23.484506", + "modified": "2025-11-19 15:57:20.139306", "modified_by": "Administrator", "name": "Banking", "owner": "Administrator", diff --git a/erpnext/desktop_icon/opening_&_closing.json b/erpnext/desktop_icon/opening_&_closing.json index 1f091b1aedf..9b3e96b6d32 100644 --- a/erpnext/desktop_icon/opening_&_closing.json +++ b/erpnext/desktop_icon/opening_&_closing.json @@ -4,12 +4,13 @@ "docstatus": 0, "doctype": "Desktop Icon", "hidden": 0, + "icon": "panel-top-open", "icon_type": "Link", "idx": 2, "label": "Opening & Closing", "link_to": "Period Closing Voucher", "link_type": "DocType", - "modified": "2025-11-17 13:33:51.092576", + "modified": "2025-11-19 15:59:14.805915", "modified_by": "Administrator", "name": "Opening & Closing", "owner": "Administrator", diff --git a/erpnext/desktop_icon/subscription.json b/erpnext/desktop_icon/subscription.json index f0865dd8218..be04b0398f7 100644 --- a/erpnext/desktop_icon/subscription.json +++ b/erpnext/desktop_icon/subscription.json @@ -4,13 +4,13 @@ "docstatus": 0, "doctype": "Desktop Icon", "hidden": 0, - "icon": "accounting", + "icon": "monitor-check", "icon_type": "Link", "idx": 6, "label": "Subscription", "link_to": "Subscription", "link_type": "DocType", - "modified": "2025-11-17 13:34:40.653317", + "modified": "2025-11-19 16:02:32.686833", "modified_by": "Administrator", "name": "Subscription", "owner": "Administrator", diff --git a/erpnext/desktop_icon/taxes.json b/erpnext/desktop_icon/taxes.json index 6f90195cebd..69ffd6c7568 100644 --- a/erpnext/desktop_icon/taxes.json +++ b/erpnext/desktop_icon/taxes.json @@ -4,12 +4,13 @@ "docstatus": 0, "doctype": "Desktop Icon", "hidden": 0, + "icon": "book-text", "icon_type": "Link", "idx": 3, "label": "Taxes", "link_to": "Item Tax Template", "link_type": "DocType", - "modified": "2025-11-17 13:34:03.502433", + "modified": "2025-11-19 15:58:21.226664", "modified_by": "Administrator", "name": "Taxes", "owner": "Administrator", diff --git a/erpnext/workspace_sidebar/settings.json b/erpnext/workspace_sidebar/settings.json new file mode 100644 index 00000000000..2814c840a36 --- /dev/null +++ b/erpnext/workspace_sidebar/settings.json @@ -0,0 +1,269 @@ +{ + "app": "erpnext", + "creation": "2025-11-17 13:19:05.050624", + "docstatus": 0, + "doctype": "Workspace Sidebar", + "header_icon": "setting", + "idx": 0, + "items": [ + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.051033", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "home", + "idx": 1, + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "Settings", + "link_type": "Workspace", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj96mk2v27", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.050624", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "crm", + "idx": 2, + "indent": 0, + "keep_closed": 0, + "label": "CRM Settings", + "link_to": "CRM Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "evs5nlr1q0", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.053190", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "sell", + "idx": 3, + "indent": 0, + "keep_closed": 0, + "label": "Selling Settings", + "link_to": "Selling Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9u1k3i56", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.053492", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "buying", + "idx": 4, + "indent": 0, + "keep_closed": 0, + "label": "Buying Settings", + "link_to": "Buying Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9ct0hfhj", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.052094", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "accounting", + "idx": 5, + "indent": 0, + "keep_closed": 0, + "label": "Accounts Settings", + "link_to": "Accounts Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj996hcboc", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.052884", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "stock", + "idx": 6, + "indent": 0, + "keep_closed": 0, + "label": "Stock Settings", + "link_to": "Stock Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9hup4r96", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.050624", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "building-2", + "idx": 7, + "indent": 0, + "keep_closed": 0, + "label": "Manufacturing Settings", + "link_to": "Manufacturing Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "e0c9du5q9a", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.051420", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "printer", + "idx": 8, + "indent": 0, + "keep_closed": 0, + "label": "Print Settings", + "link_to": "Print Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9j587oit", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.051734", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "computer", + "idx": 9, + "indent": 0, + "keep_closed": 0, + "label": "System Settings", + "link_to": "System Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9uqukv96", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.052565", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "earth", + "idx": 10, + "indent": 0, + "keep_closed": 0, + "label": "Global Defaults", + "link_to": "Global Defaults", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "gj9koe7aa7", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "creation": "2025-11-17 13:19:05.050624", + "docstatus": 0, + "doctype": "Workspace Sidebar Item", + "icon": "projects", + "idx": 11, + "indent": 0, + "keep_closed": 0, + "label": "Projects Settings", + "link_to": "Projects Settings", + "link_type": "DocType", + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "name": "e0ccorpl3c", + "owner": "Administrator", + "parent": "Settings", + "parentfield": "items", + "parenttype": "Workspace Sidebar", + "show_arrow": 0, + "type": "Link" + } + ], + "modified": "2025-11-19 16:15:06.422138", + "modified_by": "Administrator", + "module": "Setup", + "name": "Settings", + "owner": "Administrator", + "title": "Settings" +} From 1ee700fff34870fa514c117a0381608ef6b1829e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 16:37:23 +0530 Subject: [PATCH 37/51] fix: process loss % can be negative (#50629) --- erpnext/manufacturing/doctype/bom/bom.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 115239f7db5..a3c4515bc9e 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -558,12 +558,14 @@ { "fieldname": "process_loss_percentage", "fieldtype": "Percent", - "label": "% Process Loss" + "label": "% Process Loss", + "non_negative": 1 }, { "fieldname": "process_loss_qty", "fieldtype": "Float", "label": "Process Loss Qty", + "non_negative": 1, "read_only": 1 }, { @@ -682,7 +684,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2025-11-06 15:27:54.806116", + "modified": "2025-11-19 16:17:15.925156", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", From acec1a7a9d7c1b6690c4c69ad916f06b27e67eb4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 17:05:26 +0530 Subject: [PATCH 38/51] fix: permission based revision of budget --- erpnext/accounts/doctype/budget/budget.js | 34 ++++++++++----------- erpnext/accounts/doctype/budget/budget.json | 3 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 4b2aefffb57..3ac7b8fe8f8 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -4,16 +4,6 @@ frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on("Budget", { onload: function (frm) { - frm.set_query("account", "accounts", function () { - return { - filters: { - company: frm.doc.company, - report_type: "Profit and Loss", - is_group: 0, - }, - }; - }); - frm.set_query("monthly_distribution", function () { return { filters: { @@ -30,17 +20,27 @@ frappe.ui.form.on("Budget", { }); }, - refresh: function (frm) { + refresh: async function (frm) { frm.trigger("toggle_reqd_fields"); if (!frm.doc.__islocal && frm.doc.docstatus == 1) { - frm.add_custom_button( - __("Revise Budget"), - function () { - frm.events.revise_budget_action(frm); - }, - __("Actions") + let exception_role = await frappe.db.get_value( + "Company", + frm.doc.company, + "exception_budget_approver_role" ); + + const role = exception_role.message.exception_budget_approver_role; + + if (role && frappe.user.has_role(role)) { + frm.add_custom_button( + __("Revise Budget"), + function () { + frm.events.revise_budget_action(frm); + }, + __("Actions") + ); + } } }, diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index bf1e7d091c1..8476a2831f0 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -184,6 +184,7 @@ "options": "\nStop\nWarn\nIgnore" }, { + "default": "BUDGET-.########", "fieldname": "naming_series", "fieldtype": "Select", "label": "Series", @@ -308,7 +309,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-17 17:38:27.759355", + "modified": "2025-11-19 17:00:00.648224", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From d40e660a527c5329dd8b1bdf148ed8bf8b097c39 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 17 Nov 2025 12:55:10 +0530 Subject: [PATCH 39/51] fix: use qb for functions --- .../bank_statement_import.py | 3 ++- .../opening_invoice_creation_tool.py | 2 +- .../opportunity_summary_by_sales_stage.py | 2 +- .../sales_pipeline_analytics.py | 2 +- erpnext/manufacturing/doctype/bom/bom.py | 17 ++++++++++------- .../bom_operations_time/bom_operations_time.py | 2 +- ...stock_ledger_entries_for_target_warehouse.py | 2 +- .../setup/doctype/email_digest/email_digest.py | 2 +- erpnext/stock/doctype/warehouse/warehouse.py | 6 +++++- 9 files changed, 23 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index 28915c8e6d0..0a291d3668e 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -14,6 +14,7 @@ import openpyxl from frappe import _ from frappe.core.doctype.data_import.data_import import DataImport from frappe.core.doctype.data_import.importer import Importer, ImportFile +from frappe.query_builder.functions import Count from frappe.utils.background_jobs import enqueue from frappe.utils.file_manager import get_file, save_file from frappe.utils.xlsxutils import ILLEGAL_CHARACTERS_RE, handle_html @@ -371,7 +372,7 @@ def get_import_status(docname): logs = frappe.get_all( "Data Import Log", - fields=["count(*) as count", "success"], + fields=[{"COUNT": "*", "as": "count"}, "success"], filters={"data_import": docname}, group_by="success", ) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index be545ac980b..bab64406bd0 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -71,7 +71,7 @@ class OpeningInvoiceCreationTool(Document): max_count = {} fields = [ "company", - "count(name) as total_invoices", + {"COUNT": "*", "as": "total_invoices"}, "sum(outstanding_amount) as outstanding_amount", ] companies = frappe.get_all("Company", fields=["name as company", "default_currency as currency"]) diff --git a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py index 370d3a85333..d2fc4ca849b 100644 --- a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py +++ b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py @@ -74,7 +74,7 @@ class OpportunitySummaryBySalesStage: }[self.filters.get("based_on")] data_based_on = { - "Number": "count(name) as count", + "Number": {"COUNT": "*", "as": "count"}, "Amount": "opportunity_amount as amount", }[self.filters.get("data_based_on")] diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index 217d9ca31be..0bb050a6fe2 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -74,7 +74,7 @@ class SalesPipelineAnalytics: ] self.data_based_on = { - "Number": "count(name) as count", + "Number": {"COUNT": "*", "as": "count"}, "Amount": "opportunity_amount as amount", }[self.filters.get("based_on")] diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index a484361dd0a..8a7e8d4571f 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -10,6 +10,8 @@ import frappe from frappe import _, bold from frappe.core.doctype.version.version import get_diff from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Field +from frappe.query_builder.functions import Count, IfNull, Sum from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json, today from frappe.website.website_generator import WebsiteGenerator @@ -1191,7 +1193,6 @@ def get_valuation_rate(data): 2) If no value, get last valuation rate from SLE 3) If no value, get valuation rate from Item """ - from frappe.query_builder.functions import Count, IfNull, Sum from pypika import Case item_code, company = data.get("item_code"), data.get("company") @@ -1482,7 +1483,10 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card= non_stock_items = frappe.get_all( "Item", fields="name", - filters={"name": ("in", list(items.keys())), "ifnull(is_stock_item, 0)": 0}, + filters=[ + ["name", "in", list(items.keys())], + [IfNull(Field("is_stock_item"), 0), "=", 0], + ], as_list=1, ) @@ -1601,8 +1605,6 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_ ) def get_max_operation_quantity(): - from frappe.query_builder.functions import Sum - table = frappe.qb.DocType("Job Card") query = ( frappe.qb.from_(table) @@ -1617,8 +1619,6 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_ return min([d.qty for d in query.run(as_dict=True)], default=0) def get_utilised_corrective_cost(): - from frappe.query_builder.functions import Sum - table = frappe.qb.DocType("Stock Entry") subquery = ( frappe.qb.from_(table) @@ -1728,7 +1728,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if not searchfields: searchfields = ["name"] - query_filters = {"disabled": 0, "ifnull(end_of_life, '3099-12-31')": (">", today())} + query_filters = [ + ["disabled", "=", 0], + [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()], + ] or_cond_filters = {} if txt: diff --git a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py index 92c69cf3e0a..3e1a905915f 100644 --- a/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py +++ b/erpnext/manufacturing/report/bom_operations_time/bom_operations_time.py @@ -80,7 +80,7 @@ def get_filtered_data(filters): def get_bom_count(bom_data): data = frappe.get_all( "BOM Item", - fields=["count(name) as count", "bom_no"], + fields=[{"COUNT": "*", "as": "count"}, "bom_no"], filters={"bom_no": ("in", bom_data)}, group_by="bom_no", ) diff --git a/erpnext/patches/v12_0/repost_stock_ledger_entries_for_target_warehouse.py b/erpnext/patches/v12_0/repost_stock_ledger_entries_for_target_warehouse.py index 69ddb603d7d..83fa2cef8eb 100644 --- a/erpnext/patches/v12_0/repost_stock_ledger_entries_for_target_warehouse.py +++ b/erpnext/patches/v12_0/repost_stock_ledger_entries_for_target_warehouse.py @@ -8,7 +8,7 @@ import frappe def execute(): warehouse_perm = frappe.get_all( "User Permission", - fields=["count(*) as p_count", "is_default", "user"], + fields=[{"COUNT": "*", "as": "p_count"}, "is_default", "user"], filters={"allow": "Warehouse"}, group_by="user", ) diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index ba837c6d9dc..b7d80af85f5 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -799,7 +799,7 @@ class EmailDigest(Document): "status": ["not in", ("Cancelled")], "company": self.company, }, - fields=["count(*) as count", "sum(grand_total) as grand_total"], + fields=[{"COUNT": "*", "as": "count"}, "sum(grand_total) as grand_total"], ) def get_from_to_date(self): diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 63a2f209d02..8fabedfa49c 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -7,6 +7,8 @@ import json import frappe from frappe import _, throw from frappe.contacts.address_and_contact import load_address_and_contact +from frappe.query_builder import Field +from frappe.query_builder.functions import IfNull from frappe.utils import cint from frappe.utils.caching import request_cache from frappe.utils.nestedset import NestedSet @@ -197,10 +199,12 @@ def get_children(doctype, parent=None, company=None, is_root=False, include_disa include_disabled = json.loads(include_disabled) fields = ["name as value", "is_group as expandable"] + filters = [ - ["ifnull(`parent_warehouse`, '')", "=", parent], + [IfNull(Field("parent_warehouse"), ""), "=", parent], ["company", "in", (company, None, "")], ] + if frappe.db.has_column(doctype, "disabled") and not include_disabled: filters.append(["disabled", "=", False]) From 1cf9f903e5dd44b120902f2d7aa69b1dc7d4cac6 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sun, 16 Nov 2025 22:50:58 +0530 Subject: [PATCH 40/51] fix: adapt to query builder Signed-off-by: Akhil Narang --- .../test_exchange_rate_revaluation.py | 26 ++++++--- .../opening_invoice_creation_tool.py | 2 +- .../payment_reconciliation.py | 2 +- .../test_payment_reconciliation.py | 4 +- .../doctype/pricing_rule/pricing_rule.py | 3 +- .../purchase_invoice/test_purchase_invoice.py | 4 +- .../repost_accounting_ledger.py | 11 +++- .../sales_invoice/test_sales_invoice.py | 2 +- .../test_tax_withholding_category.py | 2 +- erpnext/accounts/party.py | 6 +-- .../accounts_receivable_summary.py | 2 +- .../controllers/sales_and_purchase_return.py | 12 +++-- erpnext/controllers/status_updater.py | 7 ++- erpnext/controllers/stock_controller.py | 2 +- .../controllers/subcontracting_controller.py | 6 ++- .../tests/test_item_wise_inventory_account.py | 4 +- .../sales_pipeline_analytics.py | 53 ++++++++++++------- erpnext/manufacturing/doctype/bom/bom.py | 7 +-- .../doctype/job_card/job_card.py | 11 ++-- .../production_plan/production_plan.py | 2 +- .../production_plan/test_production_plan.py | 3 +- .../doctype/work_order/test_work_order.py | 3 +- .../doctype/work_order/work_order.py | 6 +-- .../job_card_summary/job_card_summary.py | 2 +- .../production_planning_report.py | 7 ++- .../v11_0/make_quality_inspection_template.py | 2 +- erpnext/projects/doctype/project/project.py | 5 +- .../report/uae_vat_201/uae_vat_201.py | 24 ++++++--- erpnext/selling/doctype/customer/customer.py | 11 ++-- .../selling/doctype/quotation/quotation.py | 2 +- .../doctype/sales_order/sales_order.py | 6 ++- .../customer_wise_item_price.py | 2 +- .../doctype/email_digest/email_digest.py | 2 +- erpnext/startup/leaderboard.py | 18 +++---- .../warehouse_wise_stock_value.py | 2 +- erpnext/stock/doctype/batch/batch.py | 3 +- .../inventory_dimension.py | 6 ++- .../stock/doctype/pick_list/test_pick_list.py | 6 +-- .../purchase_receipt/purchase_receipt.py | 2 +- .../purchase_receipt/test_purchase_receipt.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 4 +- .../test_stock_ledger_entry.py | 17 +++--- .../incorrect_serial_no_valuation.py | 2 +- .../item_variant_details.py | 10 ++-- .../serial_and_batch_summary.py | 5 +- .../stock_and_account_value_comparison.py | 7 ++- .../stock/report/stock_ledger/stock_ledger.py | 5 +- erpnext/stock/serial_batch_bundle.py | 2 +- erpnext/stock/tests/test_utils.py | 23 ++++++-- 49 files changed, 225 insertions(+), 132 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index f1ffee6bec8..96079284dd9 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -3,6 +3,8 @@ import frappe +from frappe.query_builder import functions +from frappe.query_builder.utils import DocType from frappe.tests import IntegrationTestCase from frappe.utils import add_days, flt, today @@ -81,10 +83,11 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase): self.assertEqual(je.total_debit, 8500.0) self.assertEqual(je.total_credit, 8500.0) + gl = DocType("GL Entry") acc_balance = frappe.db.get_all( "GL Entry", filters={"account": self.debtors_usd, "is_cancelled": 0}, - fields=["sum(debit)-sum(credit) as balance"], + fields=[(functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance")], )[0] self.assertEqual(acc_balance.balance, 8500.0) @@ -146,12 +149,15 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase): self.assertEqual(je.total_debit, 500.0) self.assertEqual(je.total_credit, 500.0) + gl = DocType("GL Entry") acc_balance = frappe.db.get_all( "GL Entry", filters={"account": self.debtors_usd, "is_cancelled": 0}, fields=[ - "sum(debit)-sum(credit) as balance", - "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + (functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"), + ( + functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency) + ).as_("balance_in_account_currency"), ], )[0] # account shouldn't have balance in base and account currency @@ -193,12 +199,15 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase): pe.references = [] pe.save().submit() + gl = DocType("GL Entry") acc_balance = frappe.db.get_all( "GL Entry", filters={"account": self.debtors_usd, "is_cancelled": 0}, fields=[ - "sum(debit)-sum(credit) as balance", - "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + (functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"), + ( + functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency) + ).as_("balance_in_account_currency"), ], )[0] # account should have balance only in account currency @@ -235,12 +244,15 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase): self.assertEqual(flt(je.total_debit, precision), 0.0) self.assertEqual(flt(je.total_credit, precision), 0.0) + gl = DocType("GL Entry") acc_balance = frappe.db.get_all( "GL Entry", filters={"account": self.debtors_usd, "is_cancelled": 0}, fields=[ - "sum(debit)-sum(credit) as balance", - "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + (functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"), + ( + functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency) + ).as_("balance_in_account_currency"), ], )[0] # account shouldn't have balance in base and account currency post revaluation diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index bab64406bd0..7555e6d957f 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -72,7 +72,7 @@ class OpeningInvoiceCreationTool(Document): fields = [ "company", {"COUNT": "*", "as": "total_invoices"}, - "sum(outstanding_amount) as outstanding_amount", + {"SUM": "outstanding_amount", "as": "outstanding_amount"}, ] companies = frappe.get_all("Company", fields=["name as company", "default_currency as currency"]) if not companies: diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 99433b3fe7e..4d0888f078e 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -669,7 +669,7 @@ class PaymentReconciliation(Document): "party": self.party, }, fields=[ - "parent as `name`", + "parent as name", "exchange_rate", ], as_list=1, diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index cb23d27df44..b11f20ec90b 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -975,7 +975,7 @@ class TestPaymentReconciliation(IntegrationTestCase): total_credit_amount = frappe.db.get_all( "Journal Entry Account", {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, - "sum(credit) as amount", + [{"SUM": "credit", "as": "amount"}], group_by="reference_name", )[0].amount @@ -1069,7 +1069,7 @@ class TestPaymentReconciliation(IntegrationTestCase): total_credit_amount = frappe.db.get_all( "Journal Entry Account", {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, - "sum(credit) as amount", + [{"SUM": "credit", "as": "amount"}], group_by="reference_name", )[0].amount diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 685759dd1f3..378d349e83d 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -713,6 +713,7 @@ def get_item_uoms(doctype, txt, searchfield, start, page_len, filters): return frappe.get_all( "UOM Conversion Detail", filters={"parent": ("in", items), "uom": ("like", f"{txt}%")}, - fields=["distinct uom"], + fields=["uom"], as_list=1, + distinct=True, ) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index cd63a3f757d..c6324c10373 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1374,7 +1374,7 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): total_debit_amount = frappe.db.get_all( "Journal Entry Account", {"account": creditors_account, "docstatus": 1, "reference_name": pi.name}, - "sum(debit) as amount", + [{"SUM": "debit", "as": "amount"}], group_by="reference_name", )[0].amount self.assertEqual(flt(total_debit_amount, 2), 2500) @@ -1456,7 +1456,7 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): total_debit_amount = frappe.db.get_all( "Journal Entry Account", {"account": creditors_account, "docstatus": 1, "reference_name": pi_2.name}, - "sum(debit) as amount", + [{"SUM": "debit", "as": "amount"}], group_by="reference_name", )[0].amount self.assertEqual(flt(total_debit_amount, 2), 1500) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py index c656cd66bfd..d5755cb3719 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -213,7 +213,10 @@ def get_allowed_types_from_settings(child_doc: bool = False): repost_docs = [ x.document_type for x in frappe.db.get_all( - "Repost Allowed Types", filters={"allowed": True}, fields=["distinct(document_type)"] + "Repost Allowed Types", + filters={"allowed": True}, + fields=["document_type"], + distinct=True, ) ] result = repost_docs @@ -287,7 +290,11 @@ def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters filters.update({"document_type": ("like", f"%{txt}%")}) if allowed_types := frappe.db.get_all( - "Repost Allowed Types", filters=filters, fields=["distinct(document_type)"], as_list=1 + "Repost Allowed Types", + filters=filters, + fields=["document_type"], + as_list=1, + distinct=True, ): return allowed_types return [] diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 9aa9d61951b..a2983e4555b 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3612,7 +3612,7 @@ class TestSalesInvoice(ERPNextTestSuite): frappe.db.get_all( "Payment Ledger Entry", filters={"against_voucher_no": si.name, "delinked": 0}, - fields=["sum(amount), sum(amount_in_account_currency)"], + fields=[{"SUM": "amount"}, {"SUM": "amount_in_account_currency"}], as_list=1, ) diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index b2b05045c3e..4aa0dccc65f 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -121,7 +121,7 @@ class TestTaxWithholdingCategory(IntegrationTestCase): gl_entries = frappe.db.get_all( "GL Entry", filters={"voucher_no": pi.name}, - fields=["account", "sum(debit) as debit", "sum(credit) as credit"], + fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}], group_by="account", ) self.assertEqual(len(gl_entries), 3) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index ffdfb2f7405..eef82a398b9 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -854,8 +854,8 @@ def get_dashboard_info(party_type, party, loyalty_program=None): group_by="company", fields=[ "company", - "sum(grand_total) as grand_total", - "sum(base_grand_total) as base_grand_total", + {"SUM": "grand_total", "as": "grand_total"}, + {"SUM": "base_grand_total", "as": "base_grand_total"}, ], ) @@ -870,7 +870,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): "expiry_date": (">=", getdate()), }, group_by="company", - fields=["company", "sum(loyalty_points) as loyalty_points"], + fields=["company", {"SUM": "loyalty_points", "as": "loyalty_points"}], as_list=1, ) ) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 20351846cc4..600a782c71b 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -210,7 +210,7 @@ def get_gl_balance(report_date, company): return frappe._dict( frappe.db.get_all( "GL Entry", - fields=["party", "sum(debit - credit)"], + fields=["party", {"SUM": [{"SUB": ["debit", "credit"]}], "as": "balance"}], filters={"posting_date": ("<=", report_date), "is_cancelled": 0, "company": company}, group_by="party", as_list=1, diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 262bff7d640..5b4307f0ccf 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -323,22 +323,24 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype): party_type = "customer" fields = [ - f"sum(abs(`tab{child_doctype}`.qty)) as qty", + {"SUM": [{"ABS": f"`tab{child_doctype}`.qty"}], "as": "qty"}, ] if doctype != "Subcontracting Receipt": fields += [ - f"sum(abs(`tab{child_doctype}`.stock_qty)) as stock_qty", + {"SUM": [{"ABS": f"`tab{child_doctype}`.stock_qty"}], "as": "stock_qty"}, ] if doctype in ("Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"): fields += [ - f"sum(abs(`tab{child_doctype}`.rejected_qty)) as rejected_qty", - f"sum(abs(`tab{child_doctype}`.received_qty)) as received_qty", + {"SUM": [{"ABS": f"`tab{child_doctype}`.rejected_qty"}], "as": "rejected_qty"}, + {"SUM": [{"ABS": f"`tab{child_doctype}`.received_qty"}], "as": "received_qty"}, ] if doctype == "Purchase Receipt": - fields += [f"sum(abs(`tab{child_doctype}`.received_stock_qty)) as received_stock_qty"] + fields += [ + {"SUM": [{"ABS": f"`tab{child_doctype}`.received_stock_qty"}], "as": "received_stock_qty"} + ] # Used retrun against and supplier and is_retrun because there is an index added for it data = frappe.get_all( diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 84a11270040..ea933b2967c 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -563,11 +563,14 @@ class StatusUpdater(Document): fields=[target_ref_field, target_field], ) - sum_ref = sum(abs(record[target_ref_field]) for record in child_records) + # For operator dicts, the alias is in the "as" key; for strings, use the field name directly + ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field + + sum_ref = sum(abs(record[ref_key]) for record in child_records) if sum_ref > 0: percentage = round( - sum(min(abs(record[target_field]), abs(record[target_ref_field])) for record in child_records) + sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records) / sum_ref * 100, 6, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 5847d9d3adf..8d4aeffd9d5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1320,7 +1320,7 @@ class StockController(AccountsController): total_returned += flt(item.returned_qty * item.rate) if total_returned < total_amount: - target_ref_field = "(amount - (returned_qty * rate))" + target_ref_field = {"SUB": ["amount", {"MUL": ["returned_qty", "rate"]}], "as": "ref_amount"} self._update_percent_field( { diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 6848a345d9b..ff7342607c3 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -292,7 +292,7 @@ class SubcontractingController(StockController): ): for row in frappe.get_all( f"{self.subcontract_data.order_doctype} Item", - fields=["item_code", "(qty - received_qty) as qty", "parent", "name"], + fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "name"], filters={"docstatus": 1, "parent": ("in", self.subcontract_orders)}, ): self.qty_to_be_received[(row.item_code, row.parent)] += row.qty @@ -553,7 +553,9 @@ class SubcontractingController(StockController): data = [] doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" - fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] + fields = [ + {"DIV": [f"`tab{doctype}`.`stock_qty`", "`tabBOM`.`quantity`"], "as": "qty_consumed_per_unit"} + ] alias_dict = { "item_code": "rm_item_code", diff --git a/erpnext/controllers/tests/test_item_wise_inventory_account.py b/erpnext/controllers/tests/test_item_wise_inventory_account.py index dc8a9798fe9..e8b2c1343e7 100644 --- a/erpnext/controllers/tests/test_item_wise_inventory_account.py +++ b/erpnext/controllers/tests/test_item_wise_inventory_account.py @@ -328,7 +328,7 @@ class TestItemWiseInventoryAccount(IntegrationTestCase): "voucher_no": pr.name, "item_code": ("in", items), }, - fields=["sum(stock_value_difference) as value"], + fields=[{"SUM": "stock_value_difference", "as": "value"}], ) gl_value = frappe.db.get_value( @@ -435,7 +435,7 @@ class TestItemWiseInventoryAccount(IntegrationTestCase): sle_value = frappe.get_all( "Stock Ledger Entry", filters={"voucher_type": "Delivery Note", "voucher_no": dn.name, "item_code": ("in", items)}, - fields=["sum(stock_value_difference) as value"], + fields=[{"SUM": "stock_value_difference", "as": "value"}], ) gl_value = ( diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index 0bb050a6fe2..47e21d98cb3 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -8,6 +8,7 @@ from itertools import groupby import frappe from dateutil.relativedelta import relativedelta from frappe import _ +from frappe.query_builder.custom import Month, MonthName, Quarter from frappe.utils import cint, flt, getdate from erpnext.setup.utils import get_exchange_rate @@ -82,40 +83,52 @@ class SalesPipelineAnalytics: self.filters.get("pipeline_by") ] - self.group_by_period = { - "Monthly": "month(expected_closing)", - "Quarterly": "QUARTER(expected_closing)", - }[self.filters.get("range")] + opp = frappe.qb.DocType("Opportunity") + + if self.filters.get("range") == "Monthly": + self.group_by_period = Month(opp.expected_closing) + self.duration = MonthName(opp.expected_closing).as_("month") + else: + self.group_by_period = Quarter(opp.expected_closing) + self.duration = Quarter(opp.expected_closing).as_("quarter") self.pipeline_by = {"Owner": "opportunity_owner", "Sales Stage": "sales_stage"}[ self.filters.get("pipeline_by") ] - self.duration = { - "Monthly": "monthname(expected_closing) as month", - "Quarterly": "QUARTER(expected_closing) as quarter", - }[self.filters.get("range")] - self.period_by = {"Monthly": "month", "Quarterly": "quarter"}[self.filters.get("range")] def get_data(self): self.get_fields() + opp = frappe.qb.DocType("Opportunity") + query = frappe.qb.get_query( + "Opportunity", + filters=self.get_conditions(), + ignore_permissions=True, + ) + + pipeline_field = opp._assign if self.group_by_based_on == "_assign" else opp.sales_stage + if self.filters.get("based_on") == "Number": - self.query_result = frappe.db.get_list( - "Opportunity", - filters=self.get_conditions(), - fields=[self.based_on, self.data_based_on, self.duration], - group_by=f"{self.group_by_based_on},{self.group_by_period}", - order_by=self.group_by_period, + self.query_result = ( + query.select( + pipeline_field.as_(self.pipeline_by), + frappe.query_builder.functions.Count("*").as_("count"), + self.duration, + ) + .groupby(pipeline_field, self.group_by_period) + .orderby(self.group_by_period) + .run(as_dict=True) ) if self.filters.get("based_on") == "Amount": - self.query_result = frappe.db.get_list( - "Opportunity", - filters=self.get_conditions(), - fields=[self.based_on, self.data_based_on, self.duration, "currency"], - ) + self.query_result = query.select( + pipeline_field.as_(self.pipeline_by), + opp.opportunity_amount.as_("amount"), + self.duration, + opp.currency, + ).run(as_dict=True) self.convert_to_base_currency() diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 8a7e8d4571f..46c73199fbd 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1740,8 +1740,9 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): barcodes = frappe.get_all( "Item Barcode", - fields=["distinct parent as item_code"], + fields=["parent as item_code"], filters={"barcode": ("like", f"%{txt}%")}, + distinct=True, ) barcodes = [d.item_code for d in barcodes] @@ -1751,11 +1752,11 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if filters and filters.get("item_code"): has_variants = frappe.get_cached_value("Item", filters.get("item_code"), "has_variants") if not has_variants: - query_filters["has_variants"] = 0 + query_filters.append(["has_variants", "=", 0]) if filters: for fieldname, value in filters.items(): - query_filters[fieldname] = value + query_filters.append([fieldname, "=", value]) return frappe.get_list( "Item", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 1bd0cc4a58f..e2d44c150bb 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -207,7 +207,7 @@ class JobCard(Document): job_card_qty = frappe.get_all( "Job Card", - fields=["sum(for_quantity)"], + fields=[{"SUM": "for_quantity"}], filters={ "work_order": self.work_order, "operation_id": self.operation_id, @@ -933,9 +933,9 @@ class JobCard(Document): return frappe.get_all( "Job Card", fields=[ - "sum(total_time_in_mins) as time_in_mins", - "sum(total_completed_qty) as completed_qty", - "sum(process_loss_qty) as process_loss_qty", + {"SUM": "total_time_in_mins", "as": "time_in_mins"}, + {"SUM": "total_completed_qty", "as": "completed_qty"}, + {"SUM": "process_loss_qty", "as": "process_loss_qty"}, ], filters={ "docstatus": 1, @@ -1423,11 +1423,12 @@ def get_operations(doctype, txt, searchfield, start, page_len, filters): return frappe.get_all( "Work Order Operation", filters=args, - fields=["distinct operation as operation"], + fields=["operation"], limit_start=start, limit_page_length=page_len, order_by="idx asc", as_list=1, + distinct=True, ) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index ff737dff630..85a5b79efd2 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -624,7 +624,7 @@ class ProductionPlan(Document): so_wise_planned_qty = frappe._dict() data = frappe.get_all( "Production Plan Item", - fields=["sales_order", "sales_order_item", "SUM(planned_qty) as qty"], + fields=["sales_order", "sales_order_item", {"SUM": "planned_qty", "as": "qty"}], filters={ "sales_order": ("in", sales_orders), "docstatus": 1, diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 841a1e42b22..22738c5f639 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -73,9 +73,10 @@ class TestProductionPlan(IntegrationTestCase): material_requests = frappe.get_all( "Material Request Item", - fields=["distinct parent"], + fields=["parent"], filters={"production_plan": pln.name}, as_list=1, + distinct=True, ) self.assertTrue(len(material_requests), 2) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 282da4775f6..8c22611b461 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -976,8 +976,9 @@ class TestWorkOrder(IntegrationTestCase): job_cards = frappe.get_all( "Job Card Time Log", - fields=["distinct parent as name", "docstatus"], + fields=["parent as name", "docstatus"], order_by="creation asc", + distinct=True, ) for job_card in job_cards: diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 6cf512f7288..2a987313345 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -166,7 +166,7 @@ class WorkOrder(Document): operation_details = frappe._dict( frappe.get_all( "Job Card", - fields=["operation", "sum(for_quantity)"], + fields=["operation", {"SUM": "for_quantity"}], filters={"docstatus": ("<", 2), "work_order": self.name}, as_list=1, group_by="operation_id", @@ -718,7 +718,7 @@ class WorkOrder(Document): if self.production_plan_item: total_qty = frappe.get_all( "Work Order", - fields="sum(produced_qty) as produced_qty", + fields=[{"SUM": "produced_qty", "as": "produced_qty"}], filters={ "docstatus": 1, "production_plan": self.production_plan, @@ -1347,7 +1347,7 @@ class WorkOrder(Document): else: data = frappe.get_all( "Stock Entry", - fields=["timestamp(posting_date, posting_time) as posting_datetime"], + fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}], filters={ "work_order": self.name, "purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]), diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py index 8d72ef1f36f..b1b0bf5dd82 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py @@ -59,7 +59,7 @@ def get_data(filters): job_card_time_details = {} for job_card_data in frappe.get_all( "Job Card Time Log", - fields=["min(from_time) as from_time", "max(to_time) as to_time", "parent"], + fields=[{"MIN": "from_time", "as": "from_time"}, {"MAX": "to_time", "as": "to_time"}, "parent"], filters=job_card_time_filter, group_by="parent", ): diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py index 9867db0dd1c..7130c2c63ea 100644 --- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py +++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py @@ -230,7 +230,12 @@ class ProductionPlanReport: purchased_items = frappe.get_all( "Purchase Order Item", - fields=["item_code", "min(schedule_date) as arrival_date", "qty as arrival_qty", "warehouse"], + fields=[ + "item_code", + {"MIN": "schedule_date", "as": "arrival_date"}, + "qty as arrival_qty", + "warehouse", + ], filters={ "item_code": ("in", self.item_codes), "warehouse": ("in", self.warehouses), diff --git a/erpnext/patches/v11_0/make_quality_inspection_template.py b/erpnext/patches/v11_0/make_quality_inspection_template.py index deebfa88e6e..fef31dcde5a 100644 --- a/erpnext/patches/v11_0/make_quality_inspection_template.py +++ b/erpnext/patches/v11_0/make_quality_inspection_template.py @@ -10,7 +10,7 @@ def execute(): frappe.reload_doc("stock", "doctype", "item") for data in frappe.get_all( - "Item Quality Inspection Parameter", fields=["distinct parent"], filters={"parenttype": "Item"} + "Item Quality Inspection Parameter", fields=["parent"], filters={"parenttype": "Item"}, distinct=True ): qc_doc = frappe.new_doc("Quality Inspection Template") qc_doc.quality_inspection_template_name = "QIT/%s" % data.parent diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index d377281f0ba..b529e3df7a7 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -401,8 +401,6 @@ def get_project_list(doctype, txt, filters, limit_start, limit_page_length=20, o meta = frappe.get_meta(doctype) - fields = "distinct *" - or_filters = [] if txt: @@ -424,13 +422,14 @@ def get_project_list(doctype, txt, filters, limit_start, limit_page_length=20, o return frappe.get_list( doctype, - fields=fields, + fields="*", filters=filters, or_filters=or_filters, limit_start=limit_start, limit_page_length=limit_page_length, order_by=order_by, ignore_permissions=ignore_permissions, + distinct=True, ) diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index 7cf86adbe01..fa4b2dc6693 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -179,7 +179,11 @@ def get_reverse_charge_total(filters): try: return ( frappe.db.get_all( - "Purchase Invoice", filters=query_filters, fields=["sum(base_total)"], as_list=True, limit=1 + "Purchase Invoice", + filters=query_filters, + fields=[{"SUM": "base_total"}], + as_list=True, + limit=1, )[0][0] or 0 ) @@ -219,7 +223,11 @@ def get_reverse_charge_recoverable_total(filters): try: return ( frappe.db.get_all( - "Purchase Invoice", filters=query_filters, fields=["sum(base_total)"], as_list=True, limit=1 + "Purchase Invoice", + filters=query_filters, + fields=[{"SUM": "base_total"}], + as_list=True, + limit=1, )[0][0] or 0 ) @@ -274,7 +282,11 @@ def get_standard_rated_expenses_total(filters): try: return ( frappe.db.get_all( - "Purchase Invoice", filters=query_filters, fields=["sum(base_total)"], as_list=True, limit=1 + "Purchase Invoice", + filters=query_filters, + fields=[{"SUM": "base_total"}], + as_list=True, + limit=1, )[0][0] or 0 ) @@ -292,7 +304,7 @@ def get_standard_rated_expenses_tax(filters): frappe.db.get_all( "Purchase Invoice", filters=query_filters, - fields=["sum(recoverable_standard_rated_expenses)"], + fields=[{"SUM": "recoverable_standard_rated_expenses"}], as_list=True, limit=1, )[0][0] @@ -310,7 +322,7 @@ def get_tourist_tax_return_total(filters): try: return ( frappe.db.get_all( - "Sales Invoice", filters=query_filters, fields=["sum(base_total)"], as_list=True, limit=1 + "Sales Invoice", filters=query_filters, fields=[{"SUM": "base_total"}], as_list=True, limit=1 )[0][0] or 0 ) @@ -328,7 +340,7 @@ def get_tourist_tax_return_tax(filters): frappe.db.get_all( "Sales Invoice", filters=query_filters, - fields=["sum(tourist_tax_return)"], + fields=[{"SUM": "tourist_tax_return"}], as_list=True, limit=1, )[0][0] diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 4fc2dac10e8..2af62cfb181 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -14,6 +14,7 @@ from frappe.contacts.address_and_contact import ( from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes +from frappe.query_builder import Field, functions from frappe.utils import cint, cstr, flt, get_formatted_email, today from frappe.utils.user import get_users_with_role @@ -503,11 +504,11 @@ def get_loyalty_programs(doc): loyalty_programs = frappe.get_all( "Loyalty Program", fields=["name", "customer_group", "customer_territory"], - filters={ - "auto_opt_in": 1, - "from_date": ["<=", today()], - "ifnull(to_date, '2500-01-01')": [">=", today()], - }, + filters=[ + ["auto_opt_in", "=", 1], + ["from_date", "<=", today()], + [functions.IfNull(Field("to_date"), "2500-01-01"), ">=", today()], + ], ) for loyalty_program in loyalty_programs: diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index e894e36b8ca..8ca6688852f 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -630,7 +630,7 @@ def get_ordered_items(quotation: str): frappe.get_all( "Sales Order Item", filters={"prevdoc_docname": quotation, "docstatus": 1}, - fields=["quotation_item", "sum(qty)"], + fields=["quotation_item", {"SUM": "qty"}], group_by="quotation_item", as_list=1, ) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index fbda1f0b41a..811c7d0c08c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -992,7 +992,11 @@ def get_requested_item_qty(sales_order): for d in frappe.db.get_all( "Material Request Item", filters={"docstatus": 1, "sales_order": sales_order}, - fields=["sales_order_item", "sum(qty) as qty", "sum(received_qty) as received_qty"], + fields=[ + "sales_order_item", + {"SUM": "qty", "as": "qty"}, + {"SUM": "received_qty", "as": "received_qty"}, + ], group_by="sales_order_item", ): result[d.sales_order_item] = frappe._dict({"qty": d.qty, "received_qty": d.received_qty}) diff --git a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py index 84da765d930..d9caa9b8bad 100644 --- a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py +++ b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py @@ -95,7 +95,7 @@ def get_data(filters=None): items = get_selling_items(filters) item_stock_map = frappe.get_all( - "Bin", fields=["item_code", "sum(actual_qty) AS available"], group_by="item_code" + "Bin", fields=["item_code", {"SUM": "actual_qty", "as": "available"}], group_by="item_code" ) item_stock_map = {item.item_code: item.available for item in item_stock_map} price_list_map = fetch_item_prices( diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index b7d80af85f5..3777e330e75 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -799,7 +799,7 @@ class EmailDigest(Document): "status": ["not in", ("Cancelled")], "company": self.company, }, - fields=[{"COUNT": "*", "as": "count"}, "sum(grand_total) as grand_total"], + fields=[{"COUNT": "*", "as": "count"}, {"SUM": "grand_total", "as": "grand_total"}], ) def get_from_to_date(self): diff --git a/erpnext/startup/leaderboard.py b/erpnext/startup/leaderboard.py index b8d01d56ef5..f5a5216b51d 100644 --- a/erpnext/startup/leaderboard.py +++ b/erpnext/startup/leaderboard.py @@ -63,7 +63,7 @@ def get_all_customers(date_range, company, field, limit=None): return frappe.get_list( "Sales Invoice", - fields=["customer as name", "sum(outstanding_amount) as value"], + fields=["customer as name", {"SUM": "outstanding_amount", "as": "value"}], filters=filters, group_by="customer", order_by="value desc", @@ -80,7 +80,7 @@ def get_all_customers(date_range, company, field, limit=None): return frappe.get_list( "Sales Order", - fields=["customer as name", f"sum({select_field}) as value"], + fields=["customer as name", {"SUM": select_field, "as": "value"}], filters=filters, group_by="customer", order_by="value desc", @@ -91,10 +91,10 @@ def get_all_customers(date_range, company, field, limit=None): @frappe.whitelist() def get_all_items(date_range, company, field, limit=None): if field in ("available_stock_qty", "available_stock_value"): - select_field = "sum(actual_qty)" if field == "available_stock_qty" else "sum(stock_value)" + sum_field = "actual_qty" if field == "available_stock_qty" else "stock_value" results = frappe.db.get_all( "Bin", - fields=["item_code as name", f"{select_field} as value"], + fields=["item_code as name", {"SUM": sum_field, "as": "value"}], group_by="item_code", order_by="value desc", limit=limit, @@ -125,7 +125,7 @@ def get_all_items(date_range, company, field, limit=None): select_doctype, fields=[ f"`tab{child_doctype}`.item_code as name", - f"sum(`tab{child_doctype}`.{select_field}) as value", + {"SUM": f"`tab{child_doctype}`.{select_field}", "as": "value"}, ], filters=filters, order_by="value desc", @@ -145,7 +145,7 @@ def get_all_suppliers(date_range, company, field, limit=None): return frappe.get_list( "Purchase Invoice", - fields=["supplier as name", "sum(outstanding_amount) as value"], + fields=["supplier as name", {"SUM": "outstanding_amount", "as": "value"}], filters=filters, group_by="supplier", order_by="value desc", @@ -162,7 +162,7 @@ def get_all_suppliers(date_range, company, field, limit=None): return frappe.get_list( "Purchase Order", - fields=["supplier as name", f"sum({select_field}) as value"], + fields=["supplier as name", {"SUM": select_field, "as": "value"}], filters=filters, group_by="supplier", order_by="value desc", @@ -186,7 +186,7 @@ def get_all_sales_partner(date_range, company, field, limit=None): "Sales Order", fields=[ "sales_partner as name", - f"sum({select_field}) as value", + {"SUM": select_field, "as": "value"}, ], filters=filters, group_by="sales_partner", @@ -210,7 +210,7 @@ def get_all_sales_person(date_range, company, field=None, limit=0): "Sales Order", fields=[ "`tabSales Team`.sales_person as name", - "sum(`tabSales Team`.allocated_amount) as value", + {"SUM": "`tabSales Team`.allocated_amount", "as": "value"}, ], filters=filters, group_by="`tabSales Team`.sales_person", diff --git a/erpnext/stock/dashboard_chart_source/warehouse_wise_stock_value/warehouse_wise_stock_value.py b/erpnext/stock/dashboard_chart_source/warehouse_wise_stock_value/warehouse_wise_stock_value.py index cbc4fc76ecf..13ac541256e 100644 --- a/erpnext/stock/dashboard_chart_source/warehouse_wise_stock_value/warehouse_wise_stock_value.py +++ b/erpnext/stock/dashboard_chart_source/warehouse_wise_stock_value/warehouse_wise_stock_value.py @@ -31,7 +31,7 @@ def get( warehouses = frappe.get_list( "Bin", - fields=["warehouse", "sum(stock_value) stock_value"], + fields=["warehouse", {"SUM": "stock_value", "as": "stock_value"}], filters={"warehouse": ["IN", warehouses], "stock_value": [">", 0]}, group_by="warehouse", order_by="stock_value DESC", diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 2c08635e03f..217a5c15806 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -405,8 +405,9 @@ def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): serial_nos = get_serial_nos(serial_no) batches = frappe.get_all( "Serial No", - fields=["distinct batch_no"], + fields=["batch_no"], filters={"item_code": item_code, "warehouse": warehouse, "name": ("in", serial_nos)}, + distinct=True, ) if not batches: diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index f6c53beca2b..aac2ae46ed2 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -320,12 +320,13 @@ def get_inventory_documents( return frappe.get_all( "DocField", - fields=["distinct parent"], + fields=["parent"], filters=and_filters, or_filters=or_filters, start=start, page_length=page_len, as_list=1, + distinct=True, ) @@ -382,7 +383,7 @@ def get_inventory_dimensions(): return frappe.get_all( "Inventory Dimension", fields=[ - "distinct target_fieldname as fieldname", + "target_fieldname as fieldname", "source_fieldname", "reference_document as doctype", "validate_negative_stock", @@ -390,6 +391,7 @@ def get_inventory_dimensions(): ], filters={"disabled": 0}, order_by="creation", + distinct=True, ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index d24d241e6ab..7effd0ea7ba 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -593,7 +593,7 @@ class TestPickList(IntegrationTestCase): for dn in frappe.get_all( "Delivery Note", filters={"against_pick_list": pick_list.name, "customer": "_Test Customer"}, - fields={"name"}, + fields=["name"], ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item") @@ -604,7 +604,7 @@ class TestPickList(IntegrationTestCase): for dn in frappe.get_all( "Delivery Note", filters={"against_pick_list": pick_list.name, "customer": "_Test Customer 1"}, - fields={"name"}, + fields=["name"], ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item 2") @@ -637,7 +637,7 @@ class TestPickList(IntegrationTestCase): pick_list_1.submit() create_delivery_note(pick_list_1.name) for dn in frappe.get_all( - "Delivery Note", filters={"against_pick_list": pick_list_1.name}, fields={"name"} + "Delivery Note", filters={"against_pick_list": pick_list_1.name}, fields=["name"] ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): if dn_item.item_code == "_Test Item": diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 9708f3dfa1a..238f0ea0590 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1333,7 +1333,7 @@ def get_item_wise_returned_qty(pr_doc): "Purchase Receipt", fields=[ "`tabPurchase Receipt Item`.purchase_receipt_item", - "sum(abs(`tabPurchase Receipt Item`.qty)) as qty", + {"SUM": [{"ABS": "`tabPurchase Receipt Item`.qty"}], "as": "qty"}, ], filters=[ ["Purchase Receipt", "docstatus", "=", 1], diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 946997ecfe3..48acf7b0649 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1900,7 +1900,7 @@ class TestPurchaseReceipt(IntegrationTestCase): data = frappe.get_all( "Stock Ledger Entry", filters={"voucher_no": pr_return.name, "docstatus": 1}, - fields=["SUM(stock_value_difference) as stock_value_difference"], + fields=[{"SUM": "stock_value_difference", "as": "stock_value_difference"}], )[0] self.assertEqual(abs(data["stock_value_difference"]), 400.00) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b386955f9a5..9e7935a4abf 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2420,7 +2420,7 @@ class StockEntry(StockController, SubcontractingInwardController): data = frappe.get_all( "Work Order Operation", filters={"parent": self.work_order}, - fields=["max(process_loss_qty) as process_loss_qty"], + fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], ) if data and data[0].process_loss_qty is not None: @@ -3145,7 +3145,7 @@ class StockEntry(StockController, SubcontractingInwardController): stock_entries_child_list.append(d.ste_detail) transferred_qty = frappe.get_all( "Stock Entry Detail", - fields=["sum(qty) as qty"], + fields=[{"SUM": "qty", "as": "qty"}], filters={ "against_stock_entry": d.against_stock_entry, "ste_detail": d.ste_detail, diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 7c6361a4626..c0ea44c59d5 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -8,6 +8,7 @@ from uuid import uuid4 import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.query_builder.functions import Timestamp from frappe.tests import IntegrationTestCase from frappe.utils import add_days, add_to_date, flt, today @@ -1281,12 +1282,16 @@ class TestStockLedgerEntry(IntegrationTestCase, StockTestMixin): item=item, from_warehouse=source_warehouse, to_warehouse=target_warehouse, qty=1_728.0 ) - filters = {"voucher_no": transfer.name, "voucher_type": transfer.doctype, "is_cancelled": 0} - sles = frappe.get_all( - "Stock Ledger Entry", - fields=["*"], - filters=filters, - order_by="timestamp(posting_date, posting_time), creation", + sle = frappe.qb.DocType("Stock Ledger Entry") + sles = ( + frappe.qb.from_(sle) + .select("*") + .where(sle.voucher_no == transfer.name) + .where(sle.voucher_type == transfer.doctype) + .where(sle.is_cancelled == 0) + .orderby(Timestamp(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) + .run(as_dict=True) ) self.assertEqual(abs(sles[0].stock_value_difference), sles[1].stock_value_difference) diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py index d3c5d2e8db5..101b6a21461 100644 --- a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -115,7 +115,7 @@ def get_stock_ledger_entries(report_filters): "posting_time", "company", "warehouse", - "(stock_value_difference / actual_qty) as valuation_rate", + {"DIV": ["stock_value_difference", "actual_qty"], "as": "valuation_rate"}, ] filters = {"is_cancelled": 0} diff --git a/erpnext/stock/report/item_variant_details/item_variant_details.py b/erpnext/stock/report/item_variant_details/item_variant_details.py index e3a2a65d8fe..9e6c89193d8 100644 --- a/erpnext/stock/report/item_variant_details/item_variant_details.py +++ b/erpnext/stock/report/item_variant_details/item_variant_details.py @@ -143,9 +143,9 @@ def get_stock_details_map(variant_list): stock_details = frappe.db.get_all( "Bin", fields=[ - "sum(planned_qty) as planned_qty", - "sum(actual_qty) as actual_qty", - "sum(projected_qty) as projected_qty", + {"SUM": "planned_qty", "as": "planned_qty"}, + {"SUM": "actual_qty", "as": "actual_qty"}, + {"SUM": "projected_qty", "as": "projected_qty"}, "item_code", ], filters={"item_code": ["in", variant_list]}, @@ -167,7 +167,7 @@ def get_buying_price_map(variant_list): buying = frappe.db.get_all( "Item Price", fields=[ - "avg(price_list_rate) as avg_rate", + {"AVG": "price_list_rate", "as": "avg_rate"}, "item_code", ], filters={"item_code": ["in", variant_list], "buying": 1}, @@ -185,7 +185,7 @@ def get_selling_price_map(variant_list): selling = frappe.db.get_all( "Item Price", fields=[ - "avg(price_list_rate) as avg_rate", + {"AVG": "price_list_rate", "as": "avg_rate"}, "item_code", ], filters={"item_code": ["in", variant_list], "selling": 1}, diff --git a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py index 431670d5775..8d26e74a0f5 100644 --- a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py +++ b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py @@ -183,14 +183,15 @@ def get_voucher_type(doctype, txt, searchfield, start, page_len, filters): child_doctypes = frappe.get_all( "DocField", filters={"fieldname": "serial_and_batch_bundle"}, - fields=["distinct parent as parent"], + fields=["parent"], + distinct=True, ) query_filters = {"options": ["in", [d.parent for d in child_doctypes]]} if txt: query_filters["parent"] = ["like", f"%{txt}%"] - return frappe.get_all("DocField", filters=query_filters, fields=["distinct parent"], as_list=True) + return frappe.get_all("DocField", filters=query_filters, fields=["parent"], as_list=True, distinct=True) @frappe.whitelist() diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 0db7e40b77f..172e0fa6a41 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -61,7 +61,7 @@ def get_stock_ledger_data(report_filters, filters): "name", "voucher_type", "voucher_no", - "sum(stock_value_difference) as stock_value", + {"SUM": "stock_value_difference", "as": "stock_value"}, "posting_date", "posting_time", ], @@ -88,7 +88,10 @@ def get_gl_data(report_filters, filters): "name", "voucher_type", "voucher_no", - "sum(debit_in_account_currency) - sum(credit_in_account_currency) as account_value", + { + "SUB": [{"SUM": "debit_in_account_currency"}, {"SUM": "credit_in_account_currency"}], + "as": "account_value", + }, ], group_by="voucher_type, voucher_no", ) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index b9275417847..b55a8e43f67 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -546,7 +546,10 @@ def get_opening_balance_from_batch(filters, columns, sl_entries): opening_data = frappe.get_all( "Stock Ledger Entry", - fields=["sum(actual_qty) as qty_after_transaction", "sum(stock_value_difference) as stock_value"], + fields=[ + {"SUM": "actual_qty", "as": "qty_after_transaction"}, + {"SUM": "stock_value_difference", "as": "stock_value"}, + ], filters=query_filters, )[0] diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 686478d07b2..208f9c048a0 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -1511,7 +1511,7 @@ def get_batchwise_qty(voucher_type, voucher_no): batches = frappe.get_all( "Serial and Batch Entry", filters={"parent": ("in", bundles), "batch_no": ("is", "set")}, - fields=["batch_no", "SUM(qty) as qty"], + fields=["batch_no", {"SUM": "qty", "as": "qty"}], group_by="batch_no", as_list=1, ) diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py index 4a127a1b9aa..085d3d41cbe 100644 --- a/erpnext/stock/tests/test_utils.py +++ b/erpnext/stock/tests/test_utils.py @@ -1,6 +1,7 @@ import json import frappe +from frappe.query_builder.functions import Timestamp from frappe.tests import IntegrationTestCase from erpnext.stock.utils import scan_barcode @@ -20,11 +21,23 @@ class StockTestMixin: filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} if sle_filters: filters.update(sle_filters) - sles = frappe.get_all( - "Stock Ledger Entry", - fields=["*"], - filters=filters, - order_by="timestamp(posting_date, posting_time), creation", + + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select("*") + .where(sle.voucher_no == doc.name) + .where(sle.voucher_type == doc.doctype) + .where(sle.is_cancelled == 0) + ) + if sle_filters: + for key, value in sle_filters.items(): + query = query.where(sle[key] == value) + + sles = ( + query.orderby(Timestamp(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) + .run(as_dict=True) ) self.assertGreaterEqual(len(sles), len(expected_sles)) From 6141071a1879eb3693497fc9b64b6e5a822bc7ec Mon Sep 17 00:00:00 2001 From: SowmyaArunachalam Date: Wed, 19 Nov 2025 22:05:19 +0530 Subject: [PATCH 41/51] fix(uom): validate negative conversion factor --- .../uom_conversion_detail/uom_conversion_detail.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json index a5b880e28fc..8e8aa678723 100644 --- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json +++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json @@ -24,6 +24,7 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Conversion Factor", + "non_negative": 1, "oldfieldname": "conversion_factor", "oldfieldtype": "Float" } @@ -31,13 +32,14 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:57.645955", + "modified": "2025-11-19 21:27:13.968771", "modified_by": "Administrator", "module": "Stock", "name": "UOM Conversion Detail", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} From 29f9e423b2a3eb1d77668b6d0f2f3b60230a4ea5 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Wed, 19 Nov 2025 14:23:29 -0800 Subject: [PATCH 42/51] fix: Swedish translations --- erpnext/locale/sv.po | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/erpnext/locale/sv.po b/erpnext/locale/sv.po index ac64ea6005d..cf75ddb2bd0 100644 --- a/erpnext/locale/sv.po +++ b/erpnext/locale/sv.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2025-11-16 09:35+0000\n" -"PO-Revision-Date: 2025-11-18 22:13\n" +"PO-Revision-Date: 2025-11-19 22:23\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Swedish\n" "MIME-Version: 1.0\n" @@ -1988,7 +1988,7 @@ msgstr "Bokföring" #. Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json msgid "Accounts Closing" -msgstr "Bokföring Låsning" +msgstr "Bokföring Stängning" #. Label of the acc_frozen_upto (Date) field in DocType 'Accounts Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -2111,7 +2111,7 @@ msgstr "Konton att slå ihop" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:158 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:264 msgid "Accrued Expenses" -msgstr "Upplupna Kostnader" +msgstr "Ackumulerade Kostnader" #. Option for the 'Account Type' (Select) field in DocType 'Account' #: erpnext/accounts/doctype/account/account.json @@ -16349,7 +16349,7 @@ msgstr "Förfallodatum kan inte vara före {0}" #: erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py:129 msgid "Due to stock closing entry {0}, you cannot repost item valuation before {1}" -msgstr "På grund av lagerlåsning post {0} kan du inte lägga om artikel varuvärdering innan {1}" +msgstr "På grund av lagerstängning post {0} kan du inte lägga om artikel varuvärdering innan {1}" #. Name of a DocType #. Label of a Card Break in the Receivables Workspace @@ -16443,7 +16443,7 @@ msgstr "Dubbletter av Försäljning Fakturor hittades" #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py:78 msgid "Duplicate Stock Closing Entry" -msgstr "Kopiera Lagerlåsning Post" +msgstr "Duplicera Lagestängning Post" #: erpnext/accounts/doctype/pos_profile/pos_profile.py:169 msgid "Duplicate customer group found in the customer group table" @@ -18405,7 +18405,7 @@ msgstr "Bokföringsår Start Datum" #. 'Accounts Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json msgid "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) " -msgstr "Finansiella Rapporter kommer att genereras med hjälp av Bokföring Poster (ska vara aktiverat om Period Låsning Verifikat inte publiceras för alla år i följd eller saknas)" +msgstr "Finansiella Rapporter kommer att genereras med hjälp av Bokföring Poster (ska vara aktiverat om Period Stängning Verifikat inte publiceras för alla år i följd eller saknas) " #: erpnext/manufacturing/doctype/work_order/work_order.js:843 #: erpnext/manufacturing/doctype/work_order/work_order.js:858 @@ -19797,7 +19797,7 @@ msgstr "Skapa Schema" #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.js:12 msgid "Generate Stock Closing Entry" -msgstr "Skapa Lagerlåsning Post" +msgstr "Skapa Lagerstängning Post" #. Description of a DocType #: erpnext/stock/doctype/packing_slip/packing_slip.json @@ -25425,7 +25425,7 @@ msgstr "Jobb Ansvarig Kontakt" #. Order' #: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json msgid "Job Worker Currency" -msgstr "Jobb Arbetare Valuta" +msgstr "Jobb Ansvarig Valuta" #. Label of the supplier_delivery_note (Data) field in DocType 'Subcontracting #. Receipt' @@ -33432,13 +33432,13 @@ msgstr "Period Stängd" #: erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js:69 #: erpnext/accounts/report/trial_balance/trial_balance.js:89 msgid "Period Closing Entry For Current Period" -msgstr "Period Låsning Post för Aktuell Period" +msgstr "Period Stängning Post för Aktuell Period" #. Label of the period_closing_settings_section (Section Break) field in #. DocType 'Accounts Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json msgid "Period Closing Settings" -msgstr "Period Låsning Inställningar" +msgstr "Period Stängning Inställningar" #. Label of the period_closing_voucher (Link) field in DocType 'Account Closing #. Balance' @@ -33448,7 +33448,7 @@ msgstr "Period Låsning Inställningar" #: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json #: erpnext/accounts/workspace/accounting/accounting.json msgid "Period Closing Voucher" -msgstr "Period Låsning Verifikat" +msgstr "Period Stängning Verifikat" #: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py:499 msgid "Period Closing Voucher {0} GL Entry Cancellation Failed" @@ -39672,7 +39672,7 @@ msgstr "Hälsningar," #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.js:27 msgid "Regenerate Stock Closing Entry" -msgstr "Återskapa Lagerlåsning Post" +msgstr "Återskapa Lagerstängning Post" #. Label of a Card Break in the Buying Workspace #: erpnext/buying/workspace/buying/buying.json @@ -46734,12 +46734,12 @@ msgstr "Lager Kapacitet" #. Label of the stock_closing_tab (Tab Break) field in DocType 'Stock Settings' #: erpnext/stock/doctype/stock_settings/stock_settings.json msgid "Stock Closing" -msgstr "Lager Låsning" +msgstr "Lagerstängning" #. Name of a DocType #: erpnext/stock/doctype/stock_closing_balance/stock_closing_balance.json msgid "Stock Closing Balance" -msgstr "Lagerlåsning Saldo" +msgstr "Lagerstängning Saldo" #. Label of the stock_closing_entry (Link) field in DocType 'Stock Closing #. Balance' @@ -46747,19 +46747,19 @@ msgstr "Lagerlåsning Saldo" #: erpnext/stock/doctype/stock_closing_balance/stock_closing_balance.json #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.json msgid "Stock Closing Entry" -msgstr "Lagerlåsning Post" +msgstr "Lagerstängning Post" #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py:77 msgid "Stock Closing Entry {0} already exists for the selected date range" -msgstr "Lagerlåsning Post {0} finns redan för vald datumintervall" +msgstr "Lagerstängning Post {0} finns redan för vald datumintervall" #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py:98 msgid "Stock Closing Entry {0} has been queued for processing, system will take sometime to complete it." -msgstr "Lagerlåsning Post {0} är i kö för behandling, och kommer att ta lite tid att slutföra." +msgstr "Lagerstängning Post {0} är i kö för behandling, och kommer att ta lite tid att slutföra." #: erpnext/stock/doctype/stock_closing_entry/stock_closing_entry_dashboard.py:9 msgid "Stock Closing Log" -msgstr "Lagerlåsning Logg" +msgstr "Lagerstängning Logg" #. Label of the warehouse_and_reference (Section Break) field in DocType 'POS #. Invoice Item' From fdb790b00f4f07677c1e97b9dae0e86cdcdf8a40 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Wed, 19 Nov 2025 14:23:35 -0800 Subject: [PATCH 43/51] fix: Persian translations --- erpnext/locale/fa.po | 101 +++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/erpnext/locale/fa.po b/erpnext/locale/fa.po index f70103e2348..7d0e60afde5 100644 --- a/erpnext/locale/fa.po +++ b/erpnext/locale/fa.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2025-11-16 09:35+0000\n" -"PO-Revision-Date: 2025-11-18 22:14\n" +"PO-Revision-Date: 2025-11-19 22:23\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Persian\n" "MIME-Version: 1.0\n" @@ -1178,13 +1178,13 @@ msgstr "تراز حساب" #: erpnext/accounts/doctype/account/account.json #: erpnext/accounts/doctype/account_category/account_category.json msgid "Account Category" -msgstr "" +msgstr "دسته بندی حساب" #. Label of the account_category_name (Data) field in DocType 'Account #. Category' #: erpnext/accounts/doctype/account_category/account_category.json msgid "Account Category Name" -msgstr "" +msgstr "نام دسته حساب" #. Name of a DocType #: erpnext/accounts/doctype/account_closing_balance/account_closing_balance.json @@ -2024,7 +2024,7 @@ msgstr "حساب‌ها برای ادغام" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:158 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:264 msgid "Accrued Expenses" -msgstr "" +msgstr "مخارج انباشته" #. Option for the 'Account Type' (Select) field in DocType 'Account' #: erpnext/accounts/doctype/account/account.json @@ -3189,7 +3189,7 @@ msgstr "پیش‌پرداخت‌های تخصیص یافته در برابر س #. Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Advanced Filtering" -msgstr "" +msgstr "فیلتر پیشرفته" #. Label of the advances (Table) field in DocType 'POS Invoice' #. Label of the advances (Table) field in DocType 'Purchase Invoice' @@ -5485,16 +5485,16 @@ msgstr "تنظیمات دارایی" #. Name of a DocType #: erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.json msgid "Asset Shift Allocation" -msgstr "تخصیص تغییر دارایی" +msgstr "تخصیص شیفت دارایی" #. Name of a DocType #: erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.json msgid "Asset Shift Factor" -msgstr "عامل تغییر دارایی" +msgstr "ضریب شیفت دارایی" #: erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.py:32 msgid "Asset Shift Factor {0} is set as default currently. Please change it first." -msgstr "عامل تغییر دارایی {0} در حال حاضر به عنوان پیش‌فرض تنظیم شده است. لطفا ابتدا آن را تغییر دهید." +msgstr "ضریب شیفت دارایی {0} در حال حاضر به عنوان پیش‌فرض تنظیم شده است. لطفا ابتدا آن را تغییر دهید." #. Label of the asset_status (Select) field in DocType 'Serial No' #: erpnext/stock/doctype/serial_no/serial_no.json @@ -5661,7 +5661,7 @@ msgstr "دارایی {assets_link} برای {item_code} ایجاد شد" #: erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.py:223 msgid "Asset's depreciation schedule updated after Asset Shift Allocation {0}" -msgstr "برنامه استهلاک دارایی پس از تخصیص تغییر دارایی {0} به روز شد" +msgstr "زمان‌بندی استهلاک دارایی پس از تخصیص شیفت دارایی {0} به روز شد" #: erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py:81 msgid "Asset's value adjusted after cancellation of Asset Value Adjustment {0}" @@ -5752,7 +5752,7 @@ msgstr "حداقل یکی از موارد فروش یا خرید باید انت #: erpnext/accounts/doctype/financial_report_template/financial_report_template.js:25 msgid "At least one row is required for a financial report template" -msgstr "" +msgstr "حداقل یک ردیف برای الگوی گزارش مالی لازم است" #: erpnext/stock/doctype/stock_entry/stock_entry.py:704 msgid "At least one warehouse is mandatory" @@ -7912,7 +7912,7 @@ msgstr "" #. Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Bold text for emphasis (totals, major headings)" -msgstr "" +msgstr "متن پررنگ برای تأکید (مجموع، عناوین اصلی)" #: erpnext/accounts/doctype/payment_entry/payment_entry.py:282 msgid "Book Advance Payments as Liability option is chosen. Paid From account changed from {0} to {1}." @@ -7963,7 +7963,7 @@ msgstr "یک قرار ملاقات رزرو کنید" #: erpnext/stock/doctype/shipment/shipment.json #: erpnext/stock/doctype/shipment/shipment_list.js:5 msgid "Booked" -msgstr "رزرو" +msgstr "رزرو شده" #. Label of the booked_fixed_asset (Check) field in DocType 'Asset' #: erpnext/assets/doctype/asset/asset.json @@ -8427,7 +8427,7 @@ msgstr "محاسبه قیمت باندل محصول بر اساس نرخ آیت #. DocType 'Financial Report Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Calculate but don't show on final report" -msgstr "" +msgstr "محاسبه می‌شود اما در گزارش نهایی نمایش داده نمی‌شود" #. Label of the calculate_depr_using_total_days (Check) field in DocType #. 'Accounts Settings' @@ -8439,7 +8439,7 @@ msgstr "محاسبه استهلاک روزانه با استفاده از کل #. Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Calculated Amount" -msgstr "" +msgstr "مبلغ محاسبه شده" #: erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py:53 msgid "Calculated Bank Statement balance" @@ -10967,7 +10967,7 @@ msgstr "" #: erpnext/stock/doctype/serial_no/serial_no.json #: erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py:60 msgid "Consumed" -msgstr "مصرف شده است" +msgstr "مصرف شده" #: erpnext/stock/report/supplier_wise_sales_analytics/supplier_wise_sales_analytics.py:62 msgid "Consumed Amount" @@ -12249,12 +12249,14 @@ msgstr "ایجاد {1}(ها) با موفقیت" #: erpnext/utilities/bulk_transaction.py:206 msgid "Creation of {0} failed.\n" "\t\t\t\tCheck Bulk Transaction Log" -msgstr "" +msgstr "ایجاد {0} ناموفق بود.\n" +"\t\t\t\tبررسی لاگ تراکنش‌های انبوه" #: erpnext/utilities/bulk_transaction.py:197 msgid "Creation of {0} partially successful.\n" "\t\t\t\tCheck Bulk Transaction Log" -msgstr "" +msgstr "ایجاد {0} تا حدودی موفقیت‌آمیز بود.\n" +"\t\t\t\tبررسی لاگ تراکنش‌های انبوه" #. Option for the 'Balance must be' (Select) field in DocType 'Account' #. Label of the credit_in_account_currency (Currency) field in DocType 'Journal @@ -12815,7 +12817,7 @@ msgstr "حضانت" #. Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Custom API" -msgstr "" +msgstr "API سفارشی" #. Option for the 'Report Type' (Select) field in DocType 'Financial Report #. Template' @@ -12823,7 +12825,7 @@ msgstr "" #: erpnext/accounts/doctype/financial_report_template/financial_report_template.json #: erpnext/accounts/report/custom_financial_statement/custom_financial_statement.json msgid "Custom Financial Statement" -msgstr "" +msgstr "صورت‌های مالی سفارشی" #. Label of the custom_remarks (Check) field in DocType 'Payment Entry' #: erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -14251,7 +14253,7 @@ msgstr "نوع درخواست مواد پیش‌فرض" #. 'Company' #: erpnext/setup/doctype/company/company.json msgid "Default Operating Cost Account" -msgstr "" +msgstr "حساب هزینه عملیاتی پیش‌فرض" #. Label of the default_payable_account (Link) field in DocType 'Company' #. Label of the default_payable_account (Section Break) field in DocType @@ -15517,7 +15519,7 @@ msgstr "پرداخت وام" #: erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json #: erpnext/accounts/doctype/invoice_discounting/invoice_discounting_list.js:9 msgid "Disbursed" -msgstr "پرداخت شد" +msgstr "پرداخت شده" #. Option for the 'Action on New Invoice' (Select) field in DocType 'POS #. Profile' @@ -18280,11 +18282,11 @@ msgstr "" #: erpnext/accounts/doctype/financial_report_template/financial_report_engine.py:242 msgid "Financial Report Template {0} is disabled" -msgstr "" +msgstr "الگوی گزارش مالی {0} غیرفعال است" #: erpnext/accounts/doctype/financial_report_template/financial_report_engine.py:239 msgid "Financial Report Template {0} not found" -msgstr "" +msgstr "الگوی گزارش مالی {0} یافت نشد" #. Name of a Workspace #: erpnext/accounts/workspace/financial_reports/financial_reports.json @@ -19013,7 +19015,7 @@ msgstr "آدرس انجمن" #: erpnext/setup/install.py:200 msgid "Frappe School" -msgstr "" +msgstr "مدرسه Frappe" #. Title of an incoterm #: erpnext/setup/doctype/incoterm/incoterms.csv:4 @@ -20780,7 +20782,7 @@ msgstr "ساعت" #: erpnext/manufacturing/doctype/job_card/job_card.json #: erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json msgid "Hour Rate" -msgstr "" +msgstr "نرخ ساعتی" #. Label of the hours (Float) field in DocType 'Workstation Working Hour' #: erpnext/manufacturing/doctype/workstation_working_hour/workstation_working_hour.json @@ -21543,7 +21545,7 @@ msgstr "در درصد" #: erpnext/manufacturing/doctype/work_order/work_order.json #: erpnext/stock/doctype/quality_inspection/quality_inspection.json msgid "In Process" -msgstr "" +msgstr "در حال انجام" #: erpnext/stock/report/item_variant_details/item_variant_details.py:107 msgid "In Production" @@ -22595,7 +22597,7 @@ msgstr "تخفیف نامعتبر" #: erpnext/controllers/taxes_and_totals.py:738 msgid "Invalid Discount Amount" -msgstr "" +msgstr "مبلغ تخفیف نامعتبر است" #: erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py:122 msgid "Invalid Document" @@ -23643,13 +23645,13 @@ msgstr "" #. Label of the italic_text (Check) field in DocType 'Financial Report Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Italic Text" -msgstr "" +msgstr "متن ایتالیک" #. Description of the 'Italic Text' (Check) field in DocType 'Financial Report #. Row' #: erpnext/accounts/doctype/financial_report_row/financial_report_row.json msgid "Italic text for subtotals or notes" -msgstr "" +msgstr "متن ایتالیک برای جمع‌های جزئی یا یادداشت‌ها" #. Label of the item_code (Link) field in DocType 'POS Invoice Item' #. Label of the item_code (Link) field in DocType 'Purchase Invoice Item' @@ -26598,7 +26600,7 @@ msgstr "نگهداری موجودی" #: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json #: erpnext/support/workspace/support/support.json msgid "Maintenance" -msgstr "نگهداری" +msgstr "تعمیر و نگهداری" #. Label of the mntc_date (Date) field in DocType 'Maintenance Visit' #: erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json @@ -27331,7 +27333,7 @@ msgstr "هزینه های بازاریابی" #: erpnext/setup/setup_wizard/data/designation.txt:23 msgid "Marketing Specialist" -msgstr "کارشناس بازاریابی" +msgstr "متخصص بازاریابی" #. Option for the 'Marital Status' (Select) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json @@ -28337,7 +28339,7 @@ msgstr "نحوه پرداخت‌ها" #. Label of the model (Data) field in DocType 'Vehicle' #: erpnext/setup/doctype/vehicle/vehicle.json msgid "Model" -msgstr "" +msgstr "مدل" #. Label of the section_break_11 (Section Break) field in DocType 'POS Closing #. Entry' @@ -31455,7 +31457,7 @@ msgstr "" #: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:667 msgid "POS Invoices will be consolidated in a background process" -msgstr "فاکتورهای POS در یک فرآیند پس زمینه تلفیق می‌شوند" +msgstr "فاکتورهای POS در یک فرآیند پس‌زمینه تلفیق می‌شوند" #: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:669 msgid "POS Invoices will be unconsolidated in a background process" @@ -34330,7 +34332,7 @@ msgstr "لطفا اول ذخیره کنید" #: erpnext/selling/doctype/sales_order/sales_order.js:859 msgid "Please save the Sales Order before adding a delivery schedule." -msgstr "" +msgstr "لطفا قبل از اضافه کردن زمان‌بندی تحویل، سفارش فروش را ذخیره کنید." #: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js:79 msgid "Please select Template Type to download template" @@ -40023,11 +40025,11 @@ msgstr "وضعیت بازنشر" #: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:146 msgid "Repost has started in the background" -msgstr "ارسال مجدد در پس زمینه شروع شده است" +msgstr "ارسال مجدد در پس‌زمینه شروع شده است" #: erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js:40 msgid "Repost in background" -msgstr "بازنشر در پس زمینه" +msgstr "بازنشر در پس‌زمینه" #: erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py:118 msgid "Repost started in the background" @@ -40064,7 +40066,7 @@ msgstr "ارسال مجدد در پس‌زمینه آغاز شده است." #: erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js:49 msgid "Reposting in the background." -msgstr "بازنشر در پس زمینه" +msgstr "بازنشر در پس‌زمینه" #. Label of the represents_company (Link) field in DocType 'Purchase Invoice' #. Label of the represents_company (Link) field in DocType 'Sales Invoice' @@ -41045,7 +41047,7 @@ msgstr "" #: erpnext/quality_management/doctype/quality_procedure/quality_procedure.json #: erpnext/quality_management/doctype/quality_review/quality_review.json msgid "Reviews" -msgstr "" +msgstr "بررسی ها" #. Label of the rgt (Int) field in DocType 'Account' #. Label of the rgt (Int) field in DocType 'Company' @@ -41809,7 +41811,10 @@ msgid "Row #{0}: Selling rate for item {1} is lower than its {2}.\n" "\t\t\t\t\tSelling {3} should be atleast {4}.

      Alternatively,\n" "\t\t\t\t\tyou can disable selling price validation in {5} to bypass\n" "\t\t\t\t\tthis validation." -msgstr "" +msgstr "ردیف #{0}: نرخ فروش برای کالای {1} کمتر از {2} آن است.\n" +"\t\t\t\t\tفروش {3} باید حداقل {4} باشد.

      همچنین،\n" +"\t\t\t\t\tمی‌توانید اعتبارسنجی قیمت فروش را در {5} غیرفعال کنید تا\n" +"\t\t\t\t\tاین اعتبارسنجی را دور بزنید." #: erpnext/manufacturing/doctype/work_order/work_order.py:262 msgid "Row #{0}: Sequence ID must be {1} or {2} for Operation {3}." @@ -45413,7 +45418,7 @@ msgstr "" #: erpnext/assets/doctype/asset/asset.js:300 #: erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json msgid "Shift" -msgstr "تغییر مکان" +msgstr "شیفت" #. Label of the shift_factor (Float) field in DocType 'Asset Shift Factor' #: erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.json @@ -47777,7 +47782,7 @@ msgstr "فاکتور اشتراک" #. Label of a Card Break in the Accounting Workspace #: erpnext/accounts/workspace/accounting/accounting.json msgid "Subscription Management" -msgstr "" +msgstr "مدیریت اشتراک" #. Label of the subscription_period (Section Break) field in DocType #. 'Subscription' @@ -50060,7 +50065,7 @@ msgstr "موجودی برای اقلام و انبارهای زیر رزرو ش #: erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js:37 msgid "The sync has started in the background, please check the {0} list for new records." -msgstr "همگام سازی در پس زمینه شروع شده است، لطفاً لیست {0} را برای رکوردهای جدید بررسی کنید." +msgstr "همگام سازی در پس‌زمینه شروع شده است، لطفاً لیست {0} را برای رکوردهای جدید بررسی کنید." #. Description of the 'Invoice Type Created via POS Screen' (Select) field in #. DocType 'POS Settings' @@ -50075,11 +50080,11 @@ msgstr "" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1007 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Draft stage" -msgstr "تسک به عنوان یک کار پس زمینه در نوبت قرار گرفته است. در صورت وجود هرگونه مشکل در پردازش در پس‌زمینه، سیستم نظری در مورد خطا در این تطبیق موجودی اضافه می‌کند و به مرحله پیش‌نویس باز می‌گردد." +msgstr "تسک به عنوان یک کار پس‌زمینه در نوبت قرار گرفته است. در صورت وجود هرگونه مشکل در پردازش در پس‌زمینه، سیستم نظری در مورد خطا در این تطبیق موجودی اضافه می‌کند و به مرحله پیش‌نویس باز می‌گردد." #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1018 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Submitted stage" -msgstr "تسک به عنوان یک کار پس زمینه در نوبت قرار گرفته است. در صورت وجود هرگونه مشکل در پردازش در پس‌زمینه، سیستم نظری در مورد خطا در این تطبیق موجودی اضافه می‌کند و به مرحله ارسال باز می‌گردد." +msgstr "تسک به عنوان یک کار پس‌زمینه در نوبت قرار گرفته است. در صورت وجود هرگونه مشکل در پردازش در پس‌زمینه، سیستم نظری در مورد خطا در این تطبیق موجودی اضافه می‌کند و به مرحله ارسال باز می‌گردد." #: erpnext/stock/doctype/material_request/material_request.py:334 msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}" @@ -50449,7 +50454,7 @@ msgstr "این برنامه زمانی ایجاد شد که تعدیل ارزش #: erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.py:207 msgid "This schedule was created when Asset {0}'s shifts were adjusted through Asset Shift Allocation {1}." -msgstr "این برنامه زمانی ایجاد شد که تغییرات دارایی {0} از طریق تخصیص تغییر دارایی {1} تنظیم شد." +msgstr "این زمان‌بندی زمانی ایجاد شد که شیفت‌های دارایی {0} از طریق تخصیص شیفت دارایی {1} تنظیم شدند." #. Description of the 'Dunning Letter' (Section Break) field in DocType #. 'Dunning Type' @@ -51357,7 +51362,7 @@ msgstr "کل زمان نگهداری" #. Label of the total_holidays (Int) field in DocType 'Holiday List' #: erpnext/setup/doctype/holiday_list/holiday_list.json msgid "Total Holidays" -msgstr "" +msgstr "کل تعطیلات" #: erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py:121 msgid "Total Income" @@ -51698,7 +51703,7 @@ msgstr "کل مالیات" #: erpnext/stock/doctype/delivery_note/delivery_note.json #: erpnext/stock/doctype/purchase_receipt/purchase_receipt.json msgid "Total Taxes and Charges" -msgstr "" +msgstr "کل مالیات‌ها و عوارض" #. Label of the base_total_taxes_and_charges (Currency) field in DocType #. 'Payment Entry' @@ -53532,7 +53537,7 @@ msgstr "اعتبار موجودی منفی" #. 'Pricing Rule' #: erpnext/accounts/doctype/pricing_rule/pricing_rule.json msgid "Validate Pricing Rule" -msgstr "" +msgstr "اعتبارسنجی قانون قیمت‌گذاری" #. Label of the validate_selling_price (Check) field in DocType 'Selling #. Settings' From 1d850bf3be3b16caa6801c76228497e838794390 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Wed, 19 Nov 2025 14:23:39 -0800 Subject: [PATCH 44/51] fix: Croatian translations --- erpnext/locale/hr.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/locale/hr.po b/erpnext/locale/hr.po index 7b5ccbaca55..7c3a7dbc725 100644 --- a/erpnext/locale/hr.po +++ b/erpnext/locale/hr.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2025-11-16 09:35+0000\n" -"PO-Revision-Date: 2025-11-18 22:14\n" +"PO-Revision-Date: 2025-11-19 22:23\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Croatian\n" "MIME-Version: 1.0\n" @@ -2105,7 +2105,7 @@ msgstr "Računi za Spajanje" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:158 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:264 msgid "Accrued Expenses" -msgstr "Obračunati Troškovi" +msgstr "Nagomilani Troškovi" #. Option for the 'Account Type' (Select) field in DocType 'Account' #: erpnext/accounts/doctype/account/account.json @@ -25420,7 +25420,7 @@ msgstr "Kontakt Podizvođača" #. Order' #: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json msgid "Job Worker Currency" -msgstr "" +msgstr "Valuta Podizvođača" #. Label of the supplier_delivery_note (Data) field in DocType 'Subcontracting #. Receipt' From cb737f31fc4bc9569b06e526152fbd7c4e97d536 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Wed, 19 Nov 2025 14:23:42 -0800 Subject: [PATCH 45/51] fix: Bosnian translations --- erpnext/locale/bs.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/locale/bs.po b/erpnext/locale/bs.po index 1d95e487f4b..ab0ffb11175 100644 --- a/erpnext/locale/bs.po +++ b/erpnext/locale/bs.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2025-11-16 09:35+0000\n" -"PO-Revision-Date: 2025-11-18 22:14\n" +"PO-Revision-Date: 2025-11-19 22:23\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Bosnian\n" "MIME-Version: 1.0\n" @@ -2105,7 +2105,7 @@ msgstr "Računi za Spajanje" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:158 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:264 msgid "Accrued Expenses" -msgstr "" +msgstr "Nagomilani Troškovi" #. Option for the 'Account Type' (Select) field in DocType 'Account' #: erpnext/accounts/doctype/account/account.json @@ -25420,7 +25420,7 @@ msgstr "Kontakt Podizvođača" #. Order' #: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json msgid "Job Worker Currency" -msgstr "" +msgstr "Valuta Podizvođača" #. Label of the supplier_delivery_note (Data) field in DocType 'Subcontracting #. Receipt' From c3ff5e3748977f4f8648d4fed096d493c7d33915 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 20 Nov 2025 12:18:03 +0530 Subject: [PATCH 46/51] fix: multiple minor fixes --- erpnext/accounts/doctype/budget/budget.py | 8 ++++++++ erpnext/accounts/doctype/budget/test_budget.py | 2 +- erpnext/controllers/budget_controller.py | 2 +- .../v16_0/migrate_budget_records_to_new_structure.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 15ed0eb7317..d798da5b589 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -69,6 +69,7 @@ class Budget(Document): def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) + self.validate_budget_amount() self.validate_fiscal_year() self.set_fiscal_year_dates() self.validate_duplicate() @@ -77,6 +78,10 @@ class Budget(Document): self.validate_applicable_for() self.validate_existing_expenses() + def validate_budget_amount(self): + if self.budget_amount <= 0: + frappe.throw(_("Budget Amount can not be {0}.").format(self.budget_amount)) + def validate_fiscal_year(self): if self.from_fiscal_year: self.validate_fiscal_year_company(self.from_fiscal_year, self.company) @@ -370,6 +375,9 @@ def validate_expense_against_budget(params, expense_amount=0): if not params.account: params.account = params.get("expense_account") + if not params.get("expense_account") and params.get("account"): + params.expense_account = params.account + if not (params.get("account") and params.get("cost_center")) and params.item_code: params.cost_center, params.account = get_item_details(params) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index dd31cdc636f..ba9b4c04e08 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -619,7 +619,7 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again { "account": "_Test Account Cost for Goods Sold - _TC", "cost_center": "_Test Cost Center - _TC", - "monthly_end_date": posting_date, + "month_end_date": posting_date, "company": "_Test Company", "from_fiscal_year": fiscal_year, "to_fiscal_year": fiscal_year, diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 33afac4df22..5c7692a4433 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -428,7 +428,7 @@ class BudgetValidation: frappe.bold(key[2]), frappe.bold(frappe.unscrub(key[0])), frappe.bold(key[1]), - frappe.bold(fmt_money(v_map.accumulated_montly_budget, currency=currency)), + frappe.bold(fmt_money(v_map.accumulated_monthly_budget, currency=currency)), self.budget_applicable_for(v_map, current_amt), frappe.bold(fmt_money(monthly_diff, currency=currency)), ) diff --git a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index bde5c2984c7..c9a18ebff31 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -44,7 +44,7 @@ def migrate_single_budget(budget_name): if not account_rows: return - frappe.db.delete("Budget Account", {"parent": budget_doc.name}) + frappe.db.delete("Budget Account", filters={"parent": budget_doc.name}) percentage_allocations = get_percentage_allocations(budget_doc) From dd4bef070651333f9ce33edb2b0b334a45a34b5e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 20 Nov 2025 13:00:24 +0530 Subject: [PATCH 47/51] fix: validation for SABB deletion --- .../serial_and_batch_bundle.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 69b3abdc5ee..c4e1b234926 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 @@ -1393,7 +1393,36 @@ class SerialandBatchBundle(Document): if self.voucher_type == "POS Invoice": return - if frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") == 1: + child_doctype = self.voucher_type + " Item" + mapper = { + "Asset Capitalization": "Asset Capitalization Stock Item", + "Asset Repair": "Asset Repair Consumed Item", + "Stock Entry": "Stock Entry Detail", + }.get(self.voucher_type) + + if mapper: + child_doctype = mapper + + if self.voucher_type == "Delivery Note" and not frappe.db.exists( + "Delivery Note Item", self.voucher_detail_no + ): + child_doctype = "Packed Item" + + elif self.voucher_type == "Sales Invoice" and not frappe.db.exists( + "Sales Invoice Item", self.voucher_detail_no + ): + child_doctype = "Packed Item" + + elif self.voucher_type == "Subcontracting Receipt" and not frappe.db.exists( + "Subcontracting Receipt Item", self.voucher_detail_no + ): + child_doctype = "Subcontracting Receipt Supplied Item" + + if ( + frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") == 1 + and self.voucher_detail_no + and frappe.db.exists(child_doctype, self.voucher_detail_no) + ): msg = f"""The {self.voucher_type} {bold(self.voucher_no)} is in submitted state, please cancel it first""" frappe.throw(_(msg)) From aa6f09e9a9f9466680235a1246b7852466b6369b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 20 Nov 2025 14:57:33 +0530 Subject: [PATCH 48/51] fix: serial batch selector shown only once --- erpnext/stock/doctype/stock_entry/stock_entry.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 604db8dd26a..110654e0066 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -976,12 +976,10 @@ frappe.ui.form.on("Stock Entry Detail", { no_batch_serial_number_value = true; } - if ( - no_batch_serial_number_value && - !frappe.flags.hide_serial_batch_dialog && - !frappe.flags.dialog_set - ) { - frappe.flags.dialog_set = true; + if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) { + if (!frappe.flags.dialog_set) { + frappe.flags.dialog_set = true; + } erpnext.stock.select_batch_and_serial_no(frm, d); } else { frappe.flags.dialog_set = false; From 7faee7edc2c713024d12fa659a5e1da9646a35c8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 20 Nov 2025 11:49:55 +0530 Subject: [PATCH 49/51] fix(product bundle): fields reset if doc is new --- erpnext/stock/doctype/packed_item/packed_item.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 2d6d1947534..25f58365a95 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -121,7 +121,12 @@ def get_indexed_packed_items_table(doc): """ indexed_table = {} for packed_item in doc.get("packed_items"): - key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname) + key = ( + packed_item.parent_item, + packed_item.item_code, + packed_item.idx if doc.is_new() else packed_item.parent_detail_docname, + ) + indexed_table[key] = packed_item return indexed_table @@ -182,7 +187,11 @@ def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, re exists, pi_row = False, {} # check if row already exists in packed items table - key = (main_item_row.item_code, packing_item.item_code, main_item_row.name) + key = ( + main_item_row.item_code, + packing_item.item_code, + main_item_row.idx if doc.is_new() else main_item_row.name, + ) if packed_items_table.get(key): pi_row, exists = packed_items_table.get(key), True From 20e0313a8c4d1d94a19cc9e82d9f04ad31a059f2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 20 Nov 2025 15:34:39 +0530 Subject: [PATCH 50/51] fix: unhide zero val checkbox --- .../doctype/stock_reconciliation/stock_reconciliation.py | 1 + .../stock_reconciliation_item.json | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 97269476097..b9355a2beb7 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -977,6 +977,7 @@ class StockReconciliation(StockController): is_customer_item = frappe.get_cached_value("Item", d.item_code, "is_customer_provided_item") if is_customer_item and d.valuation_rate: d.valuation_rate = 0.0 + d.allow_zero_valuation_rate = 1 changed_any_values = True if changed_any_values: diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 083a9340c09..4013049476b 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -196,12 +196,10 @@ }, { "default": "0", - "depends_on": "allow_zero_valuation_rate", "fieldname": "allow_zero_valuation_rate", "fieldtype": "Check", "label": "Allow Zero Valuation Rate", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "depends_on": "barcode", @@ -268,7 +266,7 @@ "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2025-04-28 22:40:30.086415", + "modified": "2025-11-20 15:27:13.868179", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", From ff2d9bf4cb49d384083a2c0dadf1d7571246703b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 20 Nov 2025 15:42:27 +0530 Subject: [PATCH 51/51] fix: remove disabled warehouse in get_warehouses_based_on_account --- erpnext/stock/doctype/warehouse/warehouse.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 8fabedfa49c..32f5e61d9a5 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -240,7 +240,9 @@ def get_child_warehouses(warehouse): def get_warehouses_based_on_account(account, company=None): warehouses = [] - for d in frappe.get_all("Warehouse", fields=["name", "is_group"], filters={"account": account}): + for d in frappe.get_all( + "Warehouse", fields=["name", "is_group"], filters={"account": account, "disabled": 0} + ): if d.is_group: warehouses.extend(get_child_warehouses(d.name)) else: