Merge branch 'develop' of https://github.com/frappe/erpnext into support-53364

This commit is contained in:
Pugazhendhi Velu
2025-11-20 13:31:25 +00:00
87 changed files with 2104 additions and 595 deletions

View File

@@ -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",
)

View File

@@ -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");
}
},
});

View File

@@ -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",

View File

@@ -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 = "<hr> {{ _('Total Expenses booked through') }} - <ul>"
def get_expense_breakup(params, currency, budget_against):
msg = "<hr> {} - <ul>".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))
+ "</li>"
)
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 += (
"<li>"
@@ -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))
+ "</li>"
)
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 += (
"<li>"
+ 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))
+ "</li></ul>"
)
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

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -669,7 +669,7 @@ class PaymentReconciliation(Document):
"party": self.party,
},
fields=[
"parent as `name`",
"parent as name",
"exchange_rate",
],
as_list=1,

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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 []

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)
)

View File

@@ -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,

View File

@@ -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)),
)

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(
{

View File

@@ -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",

View File

@@ -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 = (

View File

@@ -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")]

View File

@@ -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()

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'

View File

@@ -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 "ایجاد <b><a href='/app/{0}'>{1}(ها)</a></b> با موفقیت"
#: erpnext/utilities/bulk_transaction.py:206
msgid "Creation of {0} failed.\n"
"\t\t\t\tCheck <b><a href=\"/app/bulk-transaction-log\">Bulk Transaction Log</a></b>"
msgstr ""
msgstr "ایجاد {0} ناموفق بود.\n"
"\t\t\t\tبررسی <b><a href=\"/app/bulk-transaction-log\">لاگ تراکنش‌های انبوه</a></b>"
#: erpnext/utilities/bulk_transaction.py:197
msgid "Creation of {0} partially successful.\n"
"\t\t\t\tCheck <b><a href=\"/app/bulk-transaction-log\">Bulk Transaction Log</a></b>"
msgstr ""
msgstr "ایجاد {0} تا حدودی موفقیت‌آمیز بود.\n"
"\t\t\t\tبررسی <b><a href=\"/app/bulk-transaction-log\">لاگ تراکنش‌های انبوه</a></b>"
#. 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 <b>Template Type</b> 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}.<br><br>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} باشد.<br><br>همچنین،\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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"]),

View File

@@ -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",
)

View File

@@ -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",
):

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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") {

View File

@@ -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]

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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})

View File

@@ -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(

View File

@@ -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):

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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",

View File

@@ -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

View File

@@ -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 },
{

View File

@@ -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

View File

@@ -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":

View File

@@ -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],

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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))

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",

View File

@@ -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": []
}
}

View File

@@ -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:

View File

@@ -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}

View File

@@ -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},

View File

@@ -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()

View File

@@ -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",
)

View File

@@ -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]

View File

@@ -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,
)

View File

@@ -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))

View File

@@ -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"
}