diff --git a/erpnext/accounts/report/account_group_mapping/__init__.py b/erpnext/accounts/report/account_group_mapping/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/report/account_group_mapping/account_group_mapping.js b/erpnext/accounts/report/account_group_mapping/account_group_mapping.js new file mode 100644 index 000000000000..1872323d503c --- /dev/null +++ b/erpnext/accounts/report/account_group_mapping/account_group_mapping.js @@ -0,0 +1,75 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Account Group Mapping"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + fieldname: "report_type", + label: __("Report Type"), + fieldtype: "Select", + options: ["Profit and Loss", "Balance Sheet"], + default: "Profit and Loss", + description: __("Filter by report type for Account Groups and Accounts"), + reqd: 1 + }, + { + fieldname: "root_type", + label: __("Root Type"), + fieldtype: "Select", + options: ["", "Income", "Expense", "Asset", "Liability", "Equity"], + default: "", + description: __("Filter by root type for Accounts only") + } + ], + + onChange: function (new_value, column, data) { + const old_value = data[column.fieldname]; + + frappe.call({ + method: "erpnext.accounts.report.account_group_mapping.account_group_mapping.update_account_group_mapping", + args: { + account: data.account, + old_group: old_value, + new_group: new_value + }, + callback: function () { + frappe.query_report.refresh(); + } + }); + }, + onload: function () { + const style = document.createElement("style"); + style.innerHTML = ` + .awesomplete { + z-index: 10010 !important; + } + + .awesomplete > ul { + z-index: 10010 !important; + position: absolute !important; + background: white; + border: 1px solid #ddd; + max-height: 200px; + overflow-y: auto; + } + + .dt-scrollable { + overflow: visible !important; + } + + .dataTable-cell > .link-field { + overflow: visible !important; + } + `; + document.head.appendChild(style); + } + +}; diff --git a/erpnext/accounts/report/account_group_mapping/account_group_mapping.json b/erpnext/accounts/report/account_group_mapping/account_group_mapping.json new file mode 100644 index 000000000000..8941f74f5189 --- /dev/null +++ b/erpnext/accounts/report/account_group_mapping/account_group_mapping.json @@ -0,0 +1,33 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2025-05-27 10:01:53.573544", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2025-05-27 10:01:53.573544", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Account Group Mapping", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "reference_report": "", + "report_name": "Account Group Mapping", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/erpnext/accounts/report/account_group_mapping/account_group_mapping.py b/erpnext/accounts/report/account_group_mapping/account_group_mapping.py new file mode 100644 index 000000000000..186f0a5759a8 --- /dev/null +++ b/erpnext/accounts/report/account_group_mapping/account_group_mapping.py @@ -0,0 +1,168 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +def execute(filters=None): + return AccountGroupMappingReport(filters).run() + +class AccountGroupMappingReport: + def __init__(self, filters=None): + self.filters = frappe._dict(filters or {}) + self.company = self.filters.get("company") + self.report_type = self.filters.get("report_type") + self.root_type = self.filters.get("root_type") + + def run(self): + self.validate_filters() + return self.get_columns(), self.get_data() + + def validate_filters(self): + if not self.company: + frappe.throw(_("Company filter is required")) + + def get_account_groups(self): + filters = {"company": self.company, "report_type": self.report_type} + return frappe.get_all( + "Account Group", + filters=filters, + fields=["name", "group_name", "report_type", "root_type"], + order_by="group_name" + ) + + def get_leaf_accounts(self): + filters = {**self.filters, "is_group": 0} + + where_clauses = [] + args = [] + for key, value in filters.items(): + where_clauses.append(f"{key}=%s") + args.append(value) + + where = " AND ".join(where_clauses) + return frappe.db.sql( + f""" + SELECT name, account_number, account_name, root_type, lft, rgt + FROM `tabAccount` + WHERE {where} + ORDER BY lft + """, tuple(args), as_dict=True + ) + + def get_account_group_mappings(self, group_names): + mapping = {} + + for group in group_names: + rows = frappe.get_all( + "Account Group Row", + filters={"parent": group, "row_type": "Account"}, + fields=["account"] + ) + for r in rows: + mapping.setdefault(r["account"], []).append(group) + return mapping + + def get_columns(self): + columns = [ + { + "label": _("Account Number"), + "fieldname": "account_number", + "fieldtype": "Data", + "width": 100 + }, + { + "label": _("Account Name"), + "fieldname": "account_name", + "fieldtype": "Data", + "width": 250 + }, + { + "label": _("Unmapped & Recent"), + "fieldname": "recent_unmapped", + "fieldtype": "Data", + "width": 130 + }, + ] + + self.account_groups = self.get_account_groups() + self.group_names = [g.name for g in self.account_groups] + self.account_group_mappings = self.get_account_group_mappings(self.group_names) + max_groups = max((len(groups) for groups in self.account_group_mappings.values()), default=1) + + for i in range(1, max_groups + 2): + columns.append({ + "label": _(f"Account Group {i}"), + "fieldname": f"account_group_{i}", + "fieldtype": "Link", + "options": "Account Group", + "get_query": { + "filters": { + "company": self.company, + "report_type": self.report_type, + } + }, + "width": 160, + "editable": 1, + "align": "left" + }) + + return columns + + def get_data(self): + accounts = self.get_leaf_accounts() + data = [] + max_groups = len([c for c in self.get_columns() if c["fieldname"].startswith("account_group_")]) + + for acc in accounts: + row = { + "account_number": acc["account_number"], + "account_name": acc["account_name"], + "account": acc["name"] + } + + groups = self.account_group_mappings.get(acc["name"], []) + + for i in range(1, max_groups + 1): + row[f"account_group_{i}"] = groups[i-1] if i-1 < len(groups) else "" + + # Check for recent GL Entry if unmapped + row["recent_unmapped"] = "" + + if not groups: + recent_entry = frappe.db.exists( + "GL Entry", + { + "account": acc["name"], + "posting_date": [">=", frappe.utils.add_days(frappe.utils.nowdate(), -90)] + } + ) + row["recent_unmapped"] = _("Needs Mapping") if recent_entry else "" + data.append(row) + + return data + +@frappe.whitelist() +def update_account_group_mapping(account, old_group, new_group): + if not account: + frappe.throw(_("Account is required.")) + + if old_group == new_group: + return + + if old_group and old_group != new_group: + old_group_doc = frappe.get_doc("Account Group", old_group) + old_group_doc.rows = [r for r in old_group_doc.rows if not (r.row_type == "Account" and r.account == account)] + # Reset idx for all rows + for i, r in enumerate(old_group_doc.rows): + r.idx = i + 1 + old_group_doc.save() + + if new_group: + new_group_doc = frappe.get_doc("Account Group", new_group) + if not any(r.row_type == "Account" and r.account == account for r in new_group_doc.rows): + new_group_doc.append("rows", { + "row_type": "Account", + "account": account + }) + new_group_doc.save() diff --git a/erpnext/accounts/report/summarized_profit_and_loss/summarized_profit_and_loss.py b/erpnext/accounts/report/summarized_profit_and_loss/summarized_profit_and_loss.py index 8dcc1a94e3ee..0d5043ad11b3 100644 --- a/erpnext/accounts/report/summarized_profit_and_loss/summarized_profit_and_loss.py +++ b/erpnext/accounts/report/summarized_profit_and_loss/summarized_profit_and_loss.py @@ -92,6 +92,18 @@ def get_net_profit_loss(self): for key, (from_date, to_date) in periods.items(): result[key] = self.get_net_profit_loss_for_period(accounts, from_date, to_date) + # --- Budget Calculation --- + budget_data = self.get_budget_data(accounts, self.filters.year_start_date, self.filters.report_date) + budget_totals = self.calculate_budget_totals( + budget_data, + self.filters.month_start_date, self.filters.report_date, + self.filters.year_start_date, self.filters.report_date + ) + + # Sum budget for all accounts + result["mtd_budget"] = sum(flt(b.get("mtd_budget")) for b in budget_totals.values()) + result["ytd_budget"] = sum(flt(b.get("ytd_budget")) for b in budget_totals.values()) + return result def get_net_profit_loss_for_period(self, accounts, from_date, to_date): diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index 96b9ab024c51..824083e79f8a 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -1086,7 +1086,7 @@ "hidden": 0, "is_query_report": 0, "label": "More Reports", - "link_count": 6, + "link_count": 7, "link_type": "DocType", "onboard": 0, "type": "Card Break" @@ -1111,6 +1111,16 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 1, + "label": "Account Group Mapping", + "link_count": 0, + "link_to": "Account Group Mapping", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -1155,7 +1165,7 @@ "type": "Link" } ], - "modified": "2025-05-21 12:17:11.323572", + "modified": "2025-05-27 15:51:21.425993", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting",