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/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js
index d3931dec3db..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,8 +20,28 @@ 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) {
+ 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")
+ );
+ }
+ }
},
budget_against: function (frm) {
@@ -39,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);
@@ -51,4 +70,44 @@ 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"));
+ }
+ );
+ },
+});
+
+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 fcd78691a03..8476a2831f0 100644
--- a/erpnext/accounts/doctype/budget/budget.json
+++ b/erpnext/accounts/doctype/budget/budget.json
@@ -12,10 +12,19 @@
"company",
"cost_center",
"project",
- "fiscal_year",
+ "account",
"column_break_3",
- "monthly_distribution",
"amended_from",
+ "from_fiscal_year",
+ "to_fiscal_year",
+ "budget_start_date",
+ "budget_end_date",
+ "distribution_frequency",
+ "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",
@@ -32,8 +41,8 @@
"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_kkan",
+ "revision_of"
],
"fields": [
{
@@ -44,6 +53,7 @@
"in_standard_filter": 1,
"label": "Budget Against",
"options": "\nCost Center\nProject",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -53,6 +63,7 @@
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -62,7 +73,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'",
@@ -70,28 +82,13 @@
"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",
"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",
@@ -187,22 +184,12 @@
"options": "\nStop\nWarn\nIgnore"
},
{
- "fieldname": "section_break_21",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "accounts",
- "fieldtype": "Table",
- "label": "Budget Accounts",
- "options": "Budget Account",
- "reqd": 1
- },
- {
+ "default": "BUDGET-.########",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"no_copy": 1,
- "options": "BUDGET-.YYYY.-",
+ "options": "BUDGET-.########",
"print_hide": 1,
"reqd": 1,
"set_only_once": 1
@@ -232,13 +219,97 @@
"fieldtype": "Select",
"label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense",
"options": "\nStop\nWarn\nIgnore"
+ },
+ {
+ "fieldname": "section_break_fpdt",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "budget_distribution",
+ "fieldtype": "Table",
+ "label": "Budget Distribution",
+ "options": "Budget Distribution"
+ },
+ {
+ "fieldname": "account",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Account",
+ "options": "Account",
+ "read_only_depends_on": "eval: doc.revision_of",
+ "reqd": 1
+ },
+ {
+ "fieldname": "budget_amount",
+ "fieldtype": "Currency",
+ "label": "Budget Amount",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_kkan",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "revision_of",
+ "fieldtype": "Data",
+ "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
+ },
+ {
+ "fieldname": "budget_start_date",
+ "fieldtype": "Date",
+ "hidden": 1,
+ "label": "Budget Start Date"
+ },
+ {
+ "fieldname": "budget_end_date",
+ "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-06-16 15:57:13.114981",
+ "modified": "2025-11-19 17:00:00.648224",
"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..d798da5b589 100644
--- a/erpnext/accounts/doctype/budget/budget.py
+++ b/erpnext/accounts/doctype/budget/budget.py
@@ -2,10 +2,14 @@
# 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.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, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -30,9 +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
- accounts: DF.Table[BudgetAccount]
+ account: DF.Link
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"]
@@ -47,73 +51,117 @@ 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 | None
+ budget_start_date: DF.Date | None
company: DF.Link
cost_center: DF.Link | None
- fiscal_year: DF.Link
- monthly_distribution: DF.Link | None
- naming_series: DF.Literal["BUDGET-.YYYY.-"]
+ distribute_equally: DF.Check
+ distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
+ from_fiscal_year: DF.Link
+ naming_series: DF.Literal["BUDGET-.########"]
project: DF.Link | None
+ revision_of: DF.Data | None
+ to_fiscal_year: DF.Link
# 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_budget_amount()
+ self.validate_fiscal_year()
+ self.set_fiscal_year_dates()
self.validate_duplicate()
- self.validate_accounts()
+ self.validate_account()
self.set_null_value()
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)
+ 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(
+ "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)
+ account = self.account
+
+ if not account:
+ return
- 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,
+ 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, self.budget_end_date, self.budget_start_date),
+ as_dict=True,
)
- 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}' with overlapping fiscal years."
+ ).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":
@@ -139,30 +187,201 @@ class Budget(Document):
):
self.applicable_on_booking_actual_expenses = 1
+ def validate_existing_expenses(self):
+ if self.is_new() and self.revision_of:
+ return
-def validate_expense_against_budget(args, expense_amount=0):
- args = frappe._dict(args)
+ params = 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),
+ }
+ )
+
+ params[params.budget_against_field] = self.get(params.budget_against_field)
+
+ if frappe.get_cached_value("DocType", params.budget_against_doctype, "is_tree"):
+ params.is_tree = True
+ else:
+ params.is_tree = False
+
+ actual_spent = get_actual_expense(params)
+
+ 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()
+
+ def on_update(self):
+ self.validate_distribution_totals()
+
+ def allocate_budget(self):
+ if self.revision_of:
+ return
+
+ if not self.should_regenerate_budget_distribution():
+ return
+
+ self.set("budget_distribution", [])
+
+ periods = self.get_budget_periods()
+ total_periods = len(periods)
+ row_percent = 100 / total_periods if total_periods else 0
+
+ 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)
+
+ 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 old_doc or not self.budget_distribution:
+ return True
+
+ if old_doc:
+ changed_fields = [
+ "from_fiscal_year",
+ "to_fiscal_year",
+ "budget_amount",
+ "distribution_frequency",
+ "distribute_equally",
+ ]
+ for field in changed_fields:
+ if old_doc.get(field) != self.get(field):
+ return True
+
+ return bool(self.distribute_equally)
+
+ def get_budget_periods(self):
+ """Return list of (start_date, end_date) tuples based on frequency."""
+ frequency = self.distribution_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, 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 be equal to Budget Amount {1}").format(
+ flt(total_amount, 2), self.budget_amount
+ )
+ )
+
+ if flt(abs(total_percent - 100), 2) > 0.10:
+ frappe.throw(
+ _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2))
+ )
+
+
+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]
- if args.get("company"):
- frappe.flags.exception_approver_role = frappe.get_cached_value(
- "Company", args.get("company"), "exception_budget_approver_role"
- )
+ 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)
- if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}):
+ 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
+ """,
+ (params.company, year_end_date, year_start_date),
+ )
+
+ if not budget_exists:
return
- if not args.account:
- args.account = args.get("expense_account")
+ if params.get("company"):
+ frappe.flags.exception_approver_role = frappe.get_cached_value(
+ "Company", params.get("company"), "exception_budget_approver_role"
+ )
- 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.account:
+ params.account = params.get("expense_account")
- if not args.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)
+
+ if not params.account:
return
default_dimensions = [
@@ -180,59 +399,78 @@ 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"""
- select
- b.{budget_against} as budget_against, ba.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,
- 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, `tabBudget Account` ba
- where
- b.name=ba.parent and b.fiscal_year=%s
- and ba.account=%s and b.docstatus=1
+ SELECT
+ b.name,
+ b.{budget_against} AS budget_against,
+ 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,
+ 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.company = %s
+ AND b.docstatus = 1
+ AND %s BETWEEN b.budget_start_date AND b.budget_end_date
+ AND b.account = %s
{condition}
- """,
- (args.fiscal_year, 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
+ 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,
@@ -241,14 +479,12 @@ 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, 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,
@@ -257,38 +493,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
@@ -301,14 +540,25 @@ 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):
- msg = "
{{ _('Total Expenses booked through') }} - "
+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", 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(
+ {
+ "from_date": from_date,
+ "to_date": to_date,
+ "is_cancelled": 0,
}
)
@@ -317,18 +567,23 @@ def get_expense_breakup(args, currency, budget_against):
+ 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))
+ + frappe.bold(fmt_money(params.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": params.item_code,
+ "per_ordered": [["<", 100]],
+ }
+ )
msg += (
"- "
@@ -337,22 +592,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))
+ + frappe.bold(fmt_money(params.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": params.item_code,
+ "per_billed": [["<", 100]],
+ }
+ )
+
msg += (
"- "
+ frappe.utils.get_link_to_report(
@@ -360,42 +617,34 @@ 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))
+ + frappe.bold(fmt_money(params.ordered_amount, currency=currency))
+ "
"
)
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
@@ -409,9 +658,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
@@ -425,111 +674,102 @@ def get_ordered_amount(args):
return data[0][0] if data else 0
-def get_other_condition(args, for_doc):
- condition = "expense_account = '%s'" % (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)}'"
- 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", 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 ""
- if args.is_tree:
+ date_condition = (
+ f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
+ )
+
+ 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
)
+ params.update(lft_rgt)
- 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{params.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}
+ """,
+ params,
)[0][0]
) # nosec
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.start_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):
+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:
@@ -537,7 +777,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]
@@ -551,14 +791,39 @@ 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"]
)
+
+
+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)
+
+ if old_budget.docstatus == 1:
+ old_budget.cancel()
+
+ new_budget = frappe.copy_doc(old_budget)
+ new_budget.docstatus = 0
+ new_budget.revision_of = old_budget.name
+ 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 ccc92fb518b..ba9b4c04e08 100644
--- a/erpnext/accounts/doctype/budget/test_budget.py
+++ b/erpnext/accounts/doctype/budget/test_budget.py
@@ -3,12 +3,14 @@
import unittest
import frappe
-from frappe.utils import 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,
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
@@ -25,11 +27,15 @@ 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")
- 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",
@@ -50,12 +56,13 @@ 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")
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",
@@ -73,13 +80,11 @@ 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")
- 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",
@@ -107,16 +112,16 @@ 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,
+ 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.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
-
mr = frappe.get_doc(
{
"doctype": "Material Request",
@@ -151,14 +156,15 @@ 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.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
@@ -175,13 +181,14 @@ 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")
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",
@@ -200,7 +207,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",
@@ -217,7 +224,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"})
@@ -237,7 +244,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
@@ -266,7 +273,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
@@ -298,11 +305,17 @@ 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(
- 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",
@@ -331,11 +344,14 @@ 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(
- 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",
@@ -372,7 +388,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",
@@ -387,12 +408,15 @@ 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)
-
- accumulated_limit = get_accumulated_monthly_budget(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ 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())
+
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
"_Test Bank - _TC",
@@ -422,6 +446,165 @@ class TestBudget(ERPNextTestSuite):
po.cancel()
jv.cancel()
+ def test_fiscal_year_validation(self):
+ frappe.get_doc(
+ {
+ "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):
+ budget.save()
+
+ def test_total_distribution_equals_budget(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ applicable_on_cumulative_expense=True,
+ distribute_equally=0,
+ budget_amount=12000,
+ do_not_save=False,
+ submit_budget=False,
+ )
+
+ for row in budget.budget_distribution:
+ row.amount = 2000
+
+ with self.assertRaises(frappe.ValidationError):
+ budget.save()
+
+ def test_evenly_distribute_budget(self):
+ 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(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, do_not_save=False, submit_budget=True
+ )
+
+ 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.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):
+ 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
+ )
+
+ 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.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=False,
+ submit_budget=False,
+ )
+
+ budget.budget_distribution = []
+
+ for row in [
+ {"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)
+
+ budget.save()
+
+ total_child_amount = sum(row.amount for row in budget.budget_distribution)
+
+ 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",
+ "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()
+
+ 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):
+ budget = make_budget(
+ budget_against="Cost Center",
+ distribute_equally=1,
+ budget_amount=15000,
+ do_not_save=False,
+ submit_budget=True,
+ )
+
+ 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):
+ new_budget.insert()
+
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":
@@ -430,21 +613,32 @@ 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(
{
"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",
- "fiscal_year": fiscal_year,
+ "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:
@@ -474,18 +668,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")
@@ -494,18 +703,18 @@ 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.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
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.distribution_frequency = "Monthly"
+ budget.distribute_equally = args.get("distribute_equally", 1)
if args.applicable_on_material_request:
budget.applicable_on_material_request = 1
@@ -530,7 +739,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/__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..85d14599cec
--- /dev/null
+++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
@@ -0,0 +1,58 @@
+{
+ "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",
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "end_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "End Date",
+ "read_only": 1
+ },
+ {
+ "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-11-03 13:18:28.398198",
+ "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
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 be545ac980b..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
@@ -71,8 +71,8 @@ class OpeningInvoiceCreationTool(Document):
max_count = {}
fields = [
"company",
- "count(name) as total_invoices",
- "sum(outstanding_amount) as outstanding_amount",
+ {"COUNT": "*", "as": "total_invoices"},
+ {"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/budget_controller.py b/erpnext/controllers/budget_controller.py
index 6a9e6ae316d..5c7692a4433 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,
)
}
)
@@ -164,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,
@@ -186,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")])
@@ -314,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)
@@ -425,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/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 12b24c420d4..8d4aeffd9d5 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))
@@ -1235,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/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..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
@@ -74,7 +75,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")]
@@ -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/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/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'
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'
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'
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'
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",
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index f707aa6dde1..d65d42a086b 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
@@ -1193,7 +1195,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")
@@ -1484,7 +1485,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,
)
@@ -1506,7 +1510,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
@@ -1530,11 +1534,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(
@@ -1545,6 +1549,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,
},
)
@@ -1561,13 +1566,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:
@@ -1577,6 +1589,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,
},
)
@@ -1594,8 +1607,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)
@@ -1610,8 +1621,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)
@@ -1721,7 +1730,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:
@@ -1730,8 +1742,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]
@@ -1741,11 +1754,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.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) {
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/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"
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 b651b211e11..2a987313345 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",
)
)
@@ -717,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,
@@ -1346,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/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/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.txt b/erpnext/patches.txt
index 51d4f66b9bb..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_budget_records_to_new_structure
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/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/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py
new file mode 100644
index 00000000000..c9a18ebff31
--- /dev/null
+++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py
@@ -0,0 +1,128 @@
+import frappe
+from frappe.utils import add_months, flt, get_first_day, get_last_day
+
+
+def execute():
+ remove_old_property_setter()
+
+ budget_names = frappe.db.get_list(
+ "Budget",
+ filters={"docstatus": ["in", [0, 1]]},
+ pluck="name",
+ )
+
+ 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", filters={"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)
+
+ new_budget.flags.ignore_validate = True
+ new_budget.flags.ignore_links = True
+ new_budget.insert(ignore_permissions=True, ignore_mandatory=True)
+
+ if budget_doc.docstatus == 1:
+ new_budget.submit()
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/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") {
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 ba837c6d9dc..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/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/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 },
{
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
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/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"),
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/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))
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;
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index e67192a4299..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,
@@ -3417,6 +3417,26 @@ def get_work_order_details(work_order, company):
}
+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
+
+
def get_operating_cost_per_unit(work_order=None, bom_no=None):
operating_cost_per_unit = 0
if work_order:
@@ -3434,7 +3454,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)
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/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",
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
+}
diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py
index 63a2f209d02..32f5e61d9a5 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])
@@ -236,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:
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))
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"
+}