Merge branch 'version-16-hotfix' into pr-52968

This commit is contained in:
Khushi Rawat
2026-03-06 14:46:32 +05:30
committed by GitHub
96 changed files with 2997 additions and 965 deletions

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.3.0"
__version__ = "16.7.3"
def get_default_company(user=None):

View File

@@ -205,7 +205,7 @@
"description": "Payment Terms from orders will be fetched into the invoices as is",
"fieldname": "automatically_fetch_payment_terms",
"fieldtype": "Check",
"label": "Automatically Fetch Payment Terms from Order"
"label": "Automatically Fetch Payment Terms from Order/Quotation"
},
{
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
@@ -697,7 +697,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-04 17:15:38.609327",
"modified": "2026-02-27 01:04:09.415288",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -1065,8 +1065,12 @@ class PaymentEntry(AccountsController):
total_allocated_amount += flt(d.allocated_amount)
base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d)
self.total_allocated_amount = abs(total_allocated_amount)
self.base_total_allocated_amount = abs(base_total_allocated_amount)
self.total_allocated_amount = flt(
abs(total_allocated_amount), self.precision("total_allocated_amount")
)
self.base_total_allocated_amount = flt(
abs(base_total_allocated_amount), self.precision("base_total_allocated_amount")
)
def set_unallocated_amount(self):
self.unallocated_amount = 0

View File

@@ -4,19 +4,6 @@
frappe.ui.form.on("POS Closing Entry", {
onload: async function (frm) {
frm.ignore_doctypes_on_cancel_all = ["POS Invoice Merge Log", "Sales Invoice"];
frm.set_query("pos_profile", function (doc) {
return {
filters: { user: doc.user },
};
});
frm.set_query("user", function (doc) {
return {
query: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_cashiers",
filters: { parent: doc.pos_profile },
};
});
frm.set_query("pos_opening_entry", function (doc) {
return { filters: { status: "Open", docstatus: 1 } };
});

View File

@@ -346,8 +346,7 @@ def apply_pricing_rule(args, doc=None):
args = frappe._dict(args)
if not args.transaction_type:
set_transaction_type(args)
set_transaction_type(args)
# list of dictionaries
out = []
@@ -683,23 +682,23 @@ def remove_pricing_rules(item_list):
return out
def set_transaction_type(args):
if args.transaction_type:
def set_transaction_type(pricing_ctx: frappe._dict) -> None:
if pricing_ctx.transaction_type in ["buying", "selling"]:
return
if args.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
args.transaction_type = "selling"
elif args.doctype in (
if pricing_ctx.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
pricing_ctx.transaction_type = "selling"
elif pricing_ctx.doctype in (
"Material Request",
"Supplier Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
):
args.transaction_type = "buying"
elif args.customer:
args.transaction_type = "selling"
pricing_ctx.transaction_type = "buying"
elif pricing_ctx.customer:
pricing_ctx.transaction_type = "selling"
else:
args.transaction_type = "buying"
pricing_ctx.transaction_type = "buying"
@frappe.whitelist()

View File

@@ -800,8 +800,7 @@
"hide_seconds": 1,
"label": "Time Sheets",
"options": "Sales Invoice Timesheet",
"print_hide": 1,
"read_only": 1
"print_hide": 1
},
{
"default": "0",
@@ -2331,7 +2330,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-02-25 12:41:57.043459",
"modified": "2026-02-28 17:58:56.453076",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -1451,6 +1451,9 @@ class SalesInvoice(SellingController):
return asset_qty_map
def process_asset_depreciation(self):
if self.is_internal_transfer():
return
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
self.depreciate_asset_on_sale()
else:

View File

@@ -8,6 +8,8 @@ import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_default_address
from frappe.model.document import Document
from frappe.query_builder import DocType
from frappe.query_builder.functions import IfNull
from frappe.utils import cstr
from frappe.utils.nestedset import get_root_of
@@ -83,6 +85,8 @@ class TaxRule(Document):
frappe.throw(_("Tax Template is mandatory."))
def validate_filters(self):
TaxRule = DocType("Tax Rule")
filters = {
"tax_type": self.tax_type,
"customer": self.customer,
@@ -105,33 +109,34 @@ class TaxRule(Document):
"company": self.company,
}
conds = ""
for d in filters:
if conds:
conds += " and "
conds += f"""ifnull({d}, '') = {frappe.db.escape(cstr(filters[d]))}"""
if self.from_date and self.to_date:
conds += f""" and ((from_date > '{self.from_date}' and from_date < '{self.to_date}') or
(to_date > '{self.from_date}' and to_date < '{self.to_date}') or
('{self.from_date}' > from_date and '{self.from_date}' < to_date) or
('{self.from_date}' = from_date and '{self.to_date}' = to_date))"""
elif self.from_date and not self.to_date:
conds += f""" and to_date > '{self.from_date}'"""
elif self.to_date and not self.from_date:
conds += f""" and from_date < '{self.to_date}'"""
tax_rule = frappe.db.sql(
f"select name, priority \
from `tabTax Rule` where {conds} and name != '{self.name}'",
as_dict=1,
query = (
frappe.qb.from_(TaxRule).select(TaxRule.name, TaxRule.priority).where(TaxRule.name != self.name)
)
if tax_rule:
if tax_rule[0].priority == self.priority:
frappe.throw(_("Tax Rule Conflicts with {0}").format(tax_rule[0].name), ConflictingTaxRule)
for field, value in filters.items():
query = query.where(IfNull(TaxRule[field], "") == cstr(value))
if self.from_date and self.to_date:
query = query.where(
((TaxRule.from_date > self.from_date) & (TaxRule.from_date < self.to_date))
| ((TaxRule.to_date > self.from_date) & (TaxRule.to_date < self.to_date))
| ((self.from_date > TaxRule.from_date) & (self.from_date < TaxRule.to_date))
| ((TaxRule.from_date == self.from_date) & (TaxRule.to_date == self.to_date))
)
elif self.from_date:
query = query.where(TaxRule.to_date > self.from_date)
elif self.to_date:
query = query.where(TaxRule.from_date < self.to_date)
tax_rule = query.run(as_dict=True)
if tax_rule and tax_rule[0].priority == self.priority:
frappe.throw(
_("Tax Rule Conflicts with {0}").format(tax_rule[0].name),
ConflictingTaxRule,
)
@frappe.whitelist()

View File

@@ -1,6 +1,6 @@
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import today
from frappe.utils import add_days, today
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.accounts_payable.accounts_payable import execute
@@ -57,3 +57,66 @@ class TestAccountsPayable(AccountsTestMixin, IntegrationTestCase):
if not do_not_submit:
pi = pi.submit()
return pi
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms
payment_term1 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
).insert()
payment_term2 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
).insert()
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term1.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 15,
},
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term2.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 30,
},
],
}
)
template.insert()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"based_on_payment_terms": 1,
"payment_terms_template": template.name,
"ageing_based_on": "Posting Date",
}
pi = self.create_purchase_invoice(do_not_submit=True)
pi.payment_terms_template = template.name
schedule = get_payment_terms(template.name)
pi.set("payment_schedule", [])
for row in schedule:
row["due_date"] = add_days(pi.posting_date, row.get("credit_days", 0))
pi.append("payment_schedule", row)
pi.save()
pi.submit()
report = execute(filters)
row = report[1][0]
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])

View File

@@ -1035,9 +1035,8 @@ class ReceivablePayableReport:
self,
):
self.customer = qb.DocType("Customer")
if self.filters.get("customer_group"):
groups = get_customer_group_with_children(self.filters.customer_group)
groups = get_party_group_with_children("Customer", self.filters.customer_group)
customers = (
qb.from_(self.customer)
.select(self.customer.name)
@@ -1049,14 +1048,18 @@ class ReceivablePayableReport:
self.get_hierarchical_filters("Territory", "territory")
if self.filters.get("payment_terms_template"):
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer.payment_terms == self.filters.get("payment_terms_template"))
)
customer_ptt = self.ple.party.isin(
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer.payment_terms == self.filters.get("payment_terms_template"))
)
si_ptt = self.add_payment_term_template_filters("Sales Invoice")
sales_ptt = self.ple.against_voucher_no.isin(si_ptt)
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
if self.filters.get("sales_partner"):
self.qb_selection_filter.append(
self.ple.party.isin(
@@ -1081,14 +1084,53 @@ class ReceivablePayableReport:
)
if self.filters.get("payment_terms_template"):
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(supplier)
.select(supplier.name)
.where(supplier.payment_terms == self.filters.get("supplier_group"))
)
supplier_ptt = self.ple.party.isin(
qb.from_(supplier)
.select(supplier.name)
.where(supplier.payment_terms == self.filters.get("payment_terms_template"))
)
pi_ptt = self.add_payment_term_template_filters("Purchase Invoice")
purchase_ptt = self.ple.against_voucher_no.isin(pi_ptt)
self.qb_selection_filter.append(Criterion.any([supplier_ptt, purchase_ptt]))
def add_payment_term_template_filters(self, dtype):
voucher_type = qb.DocType(dtype)
ptt = (
qb.from_(voucher_type)
.select(voucher_type.name)
.where(voucher_type.payment_terms_template == self.filters.get("payment_terms_template"))
.where(voucher_type.company == self.filters.company)
)
if dtype == "Purchase Invoice":
party = "Supplier"
party_group_type = "supplier_group"
acc_type = "credit_to"
else:
party = "Customer"
party_group_type = "customer_group"
acc_type = "debit_to"
if self.filters.get(party_group_type):
party_groups = get_party_group_with_children(party, self.filters.get(party_group_type))
ptt = ptt.where((voucher_type[party_group_type]).isin(party_groups))
if self.filters.party:
ptt = ptt.where((voucher_type[party.lower()]).isin(self.filters.party))
if self.filters.cost_center:
cost_centers = get_cost_centers_with_children(self.filters.cost_center)
ptt = ptt.where(voucher_type.cost_center.isin(cost_centers))
if self.filters.party_account:
ptt = ptt.where(voucher_type[acc_type] == self.filters.party_account)
return ptt
def get_hierarchical_filters(self, doctype, key):
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
@@ -1330,20 +1372,26 @@ class ReceivablePayableReport:
self.err_journals = [x[0] for x in results] if results else []
def get_customer_group_with_children(customer_groups):
if not isinstance(customer_groups, list):
customer_groups = [d.strip() for d in customer_groups.strip().split(",") if d]
def get_party_group_with_children(party, party_groups):
if party not in ("Customer", "Supplier"):
return []
all_customer_groups = []
for d in customer_groups:
if frappe.db.exists("Customer Group", d):
lft, rgt = frappe.db.get_value("Customer Group", d, ["lft", "rgt"])
children = frappe.get_all("Customer Group", filters={"lft": [">=", lft], "rgt": ["<=", rgt]})
all_customer_groups += [c.name for c in children]
group_dtype = f"{party} Group"
if not isinstance(party_groups, list):
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
all_party_groups = []
for d in party_groups:
if frappe.db.exists(group_dtype, d):
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
children = frappe.get_all(
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
)
all_party_groups += children
else:
frappe.throw(_("Customer Group: {0} does not exist").format(d))
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
return list(set(all_customer_groups))
return list(set(all_party_groups))
class InitSQLProceduresForAR:

View File

@@ -1139,3 +1139,66 @@ class TestAccountsReceivable(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms
payment_term1 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
).insert()
payment_term2 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
).insert()
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term1.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 15,
},
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term2.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 30,
},
],
}
)
template.insert()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"based_on_payment_terms": 1,
"payment_terms_template": template.name,
"ageing_based_on": "Posting Date",
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.payment_terms_template = template.name
schedule = get_payment_terms(template.name)
si.set("payment_schedule", [])
for row in schedule:
row["due_date"] = add_days(si.posting_date, row.get("credit_days", 0))
si.append("payment_schedule", row)
si.save()
si.submit()
report = execute(filters)
row = report[1][0]
self.assertEqual(len(report[1]), 2)
self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])

View File

@@ -5,6 +5,7 @@ import frappe
from frappe import _
from frappe.utils import add_months, flt, formatdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.trends import get_period_date_ranges
@@ -13,6 +14,8 @@ def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
if filters.get("budget_against_filter"):
dimensions = filters.get("budget_against_filter")
@@ -31,6 +34,10 @@ def execute(filters=None):
return columns, data, None, chart_data
def validate_filters(filters):
validate_budget_dimensions(filters)
def get_budget_records(filters, dimensions):
budget_against_field = frappe.scrub(filters["budget_against"])
@@ -51,7 +58,7 @@ def get_budget_records(filters, dimensions):
b.company = %s
AND b.docstatus = 1
AND b.budget_against = %s
AND b.{budget_against_field} IN ({', '.join(['%s'] * len(dimensions))})
AND b.{budget_against_field} IN ({", ".join(["%s"] * len(dimensions))})
AND (
b.from_fiscal_year <= %s
AND b.to_fiscal_year >= %s
@@ -404,6 +411,17 @@ def get_budget_dimensions(filters):
) # nosec
def validate_budget_dimensions(filters):
dimensions = [d.get("document_type") for d in get_dimensions(with_cost_center_and_project=True)[0]]
if filters.get("budget_against") and filters.get("budget_against") not in dimensions:
frappe.throw(
title=_("Invalid Accounting Dimension"),
msg=_("{0} is not a valid Accounting Dimension.").format(
frappe.bold(filters.get("budget_against"))
),
)
def build_comparison_chart_data(filters, columns, data):
if not data:
return None

View File

@@ -86,6 +86,12 @@ frappe.query_reports["Consolidated Trial Balance"] = {
fieldtype: "Check",
default: 1,
},
{
fieldname: "show_net_values",
label: __("Show net values in opening and closing columns"),
fieldtype: "Check",
default: 1,
},
{
fieldname: "show_group_accounts",
label: __("Show Group Accounts"),

View File

@@ -14,6 +14,7 @@ from erpnext.accounts.report.financial_statements import (
)
from erpnext.accounts.report.trial_balance.trial_balance import (
accumulate_values_into_parents,
calculate_total_row,
calculate_values,
get_opening_balances,
hide_group_accounts,
@@ -44,7 +45,6 @@ def execute(filters: dict | None = None):
def validate_filters(filters):
validate_companies(filters)
filters.show_net_values = True
tb_validate_filters(filters)
@@ -99,16 +99,20 @@ def get_data(filters) -> list[list]:
tb_data = get_company_wise_tb_data(company_filter, reporting_currency, ignore_reporting_currency)
consolidate_trial_balance_data(data, tb_data)
for d in data:
prepare_opening_closing(d)
total_row = calculate_total_row(data, reporting_currency)
data.extend([{}, total_row])
if filters.get("show_net_values"):
prepare_opening_closing_for_ctb(data)
if not filters.get("show_group_accounts"):
data = hide_group_accounts(data)
total_row = calculate_total_row(
data, reporting_currency, show_group_accounts=filters.get("show_group_accounts")
)
calculate_foreign_currency_translation_reserve(total_row, data, filters=filters)
data.extend([total_row])
if filters.get("presentation_currency"):
update_to_presentation_currency(
data,
@@ -207,10 +211,6 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
data = []
for d in accounts:
# Prepare opening closing for group account
if parent_children_map.get(d.account) and filters.get("show_net_values"):
prepare_opening_closing(d)
has_value = False
row = {
"account": d.name,
@@ -242,35 +242,9 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
return data
def calculate_total_row(data, reporting_currency):
total_row = {
"account": "'" + _("Total") + "'",
"account_name": "'" + _("Total") + "'",
"warn_if_negative": True,
"opening_debit": 0.0,
"opening_credit": 0.0,
"debit": 0.0,
"credit": 0.0,
"closing_debit": 0.0,
"closing_credit": 0.0,
"parent_account": None,
"indent": 0,
"has_value": True,
"currency": reporting_currency,
}
for d in data:
if not d.get("parent_account"):
for field in value_fields:
total_row[field] += d[field]
if data:
calculate_foreign_currency_translation_reserve(total_row, data)
return total_row
def calculate_foreign_currency_translation_reserve(total_row, data):
def calculate_foreign_currency_translation_reserve(total_row, data, filters):
if not data or not total_row:
return
opening_dr_cr_diff = total_row["opening_debit"] - total_row["opening_credit"]
dr_cr_diff = total_row["debit"] - total_row["credit"]
@@ -289,7 +263,7 @@ def calculate_foreign_currency_translation_reserve(total_row, data):
"root_type": data[idx].get("root_type"),
"account_type": "Equity",
"parent_account": data[idx].get("account"),
"indent": data[idx].get("indent") + 1,
"indent": data[idx].get("indent") + 1 if filters.get("show_group_accounts") else 0,
"has_value": True,
"currency": total_row.get("currency"),
}
@@ -297,7 +271,8 @@ def calculate_foreign_currency_translation_reserve(total_row, data):
fctr_row["closing_debit"] = fctr_row["opening_debit"] + fctr_row["debit"]
fctr_row["closing_credit"] = fctr_row["opening_credit"] + fctr_row["credit"]
prepare_opening_closing(fctr_row)
if filters.get("show_net_values"):
prepare_opening_closing(fctr_row)
data.insert(idx + 1, fctr_row)
@@ -396,6 +371,11 @@ def update_to_presentation_currency(data, from_currency, to_currency, date, igno
d.update(currency=to_currency)
def prepare_opening_closing_for_ctb(data):
for d in data:
prepare_opening_closing(d)
def get_columns():
return [
{

View File

@@ -390,7 +390,7 @@ def calculate_values(
prepare_opening_closing(d)
def calculate_total_row(accounts, company_currency):
def calculate_total_row(data, company_currency, show_group_accounts=True):
total_row = {
"account": "'" + _("Total") + "'",
"account_name": "'" + _("Total") + "'",
@@ -407,10 +407,16 @@ def calculate_total_row(accounts, company_currency):
"currency": company_currency,
}
for d in accounts:
if not d.parent_account:
for field in value_fields:
total_row[field] += d[field]
def sum_value_fields(row):
for field in value_fields:
total_row[field] += row[field]
for d in data:
if not show_group_accounts:
sum_value_fields(d)
elif show_group_accounts and not d.get("parent_account"):
sum_value_fields(d)
return total_row
@@ -456,11 +462,13 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
row["has_value"] = has_value
data.append(row)
total_row = calculate_total_row(accounts, company_currency)
if not filters.get("show_group_accounts"):
data = hide_group_accounts(data)
total_row = calculate_total_row(
data, company_currency, show_group_accounts=filters.get("show_group_accounts")
)
data.extend([{}, total_row])
return data

View File

@@ -16,6 +16,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
refresh() {
this.show_general_ledger();
erpnext.toggle_serial_batch_fields(this.frm);
if (this.frm.doc.stock_items && this.frm.doc.stock_items.length) {
this.show_stock_ledger();

View File

@@ -15,7 +15,7 @@
"doctype": "Module Onboarding",
"idx": 0,
"is_complete": 0,
"modified": "2026-02-26 10:45:47.970714",
"modified": "2026-02-26 10:50:47.970714",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Onboarding",

View File

@@ -8,7 +8,7 @@
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-26 10:44:59.557156",
"modified": "2026-02-26 10:50:59.557156",
"modified_by": "Administrator",
"name": "Learn Asset",
"owner": "Administrator",

View File

@@ -17,6 +17,7 @@
"order_confirmation_date",
"column_break_7",
"transaction_date",
"transaction_time",
"schedule_date",
"column_break1",
"company",
@@ -1311,6 +1312,14 @@
{
"fieldname": "section_break_tnkm",
"fieldtype": "Section Break"
},
{
"default": "Now",
"depends_on": "is_internal_supplier",
"fieldname": "transaction_time",
"fieldtype": "Time",
"label": "Time",
"mandatory_depends_on": "is_internal_supplier"
}
],
"grid_page_length": 50,
@@ -1318,7 +1327,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-02-23 14:22:33.323946",
"modified": "2026-03-02 00:40:47.119584",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -166,6 +166,7 @@ class PurchaseOrder(BuyingController):
total_qty: DF.Float
total_taxes_and_charges: DF.Currency
transaction_date: DF.Date
transaction_time: DF.Time | None
# end: auto-generated types
def __init__(self, *args, **kwargs):

View File

@@ -250,10 +250,17 @@ frappe.ui.form.on("Request for Quotation", {
"subject",
])
.then((r) => {
frm.set_value(
"message_for_supplier",
r.message.use_html ? r.message.response_html : r.message.response
);
if (r.message.use_html) {
frm.set_value({
mfs_html: r.message.response_html,
use_html: 1,
});
} else {
frm.set_value({
message_for_supplier: r.message.response,
use_html: 0,
});
}
frm.set_value("subject", r.message.subject);
});
}

View File

@@ -31,7 +31,9 @@
"send_document_print",
"sec_break_email_2",
"subject",
"use_html",
"message_for_supplier",
"mfs_html",
"terms_section_break",
"incoterm",
"named_place",
@@ -142,12 +144,13 @@
{
"allow_on_submit": 1,
"default": "Please supply the specified items at the best possible rates",
"depends_on": "eval:doc.use_html == 0",
"fieldname": "message_for_supplier",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Message for Supplier",
"print_hide": 1,
"reqd": 1
"mandatory_depends_on": "eval:doc.use_html == 0",
"print_hide": 1
},
{
"collapsible": 1,
@@ -324,6 +327,22 @@
"label": "Subject",
"not_nullable": 1,
"reqd": 1
},
{
"allow_on_submit": 1,
"depends_on": "eval:doc.use_html == 1",
"fieldname": "mfs_html",
"fieldtype": "Code",
"label": "Message for Supplier",
"mandatory_depends_on": "eval:doc.use_html == 1",
"print_hide": 1
},
{
"default": "0",
"fieldname": "use_html",
"fieldtype": "Check",
"hidden": 1,
"label": "Use HTML"
}
],
"grid_page_length": 50,
@@ -331,7 +350,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-01-06 10:31:08.747043",
"modified": "2026-03-01 23:38:48.079274",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",

View File

@@ -47,7 +47,8 @@ class RequestforQuotation(BuyingController):
incoterm: DF.Link | None
items: DF.Table[RequestforQuotationItem]
letter_head: DF.Link | None
message_for_supplier: DF.TextEditor
message_for_supplier: DF.TextEditor | None
mfs_html: DF.Code | None
named_place: DF.Data | None
naming_series: DF.Literal["PUR-RFQ-.YYYY.-"]
opportunity: DF.Link | None
@@ -61,6 +62,7 @@ class RequestforQuotation(BuyingController):
tc_name: DF.Link | None
terms: DF.TextEditor | None
transaction_date: DF.Date
use_html: DF.Check
vendor: DF.Link | None
# end: auto-generated types
@@ -100,8 +102,16 @@ class RequestforQuotation(BuyingController):
["use_html", "response", "response_html", "subject"],
as_dict=True,
)
if not self.message_for_supplier:
self.message_for_supplier = data.response_html if data.use_html else data.response
self.use_html = data.use_html
if data.use_html:
if not self.mfs_html:
self.mfs_html = data.response_html
else:
if not self.message_for_supplier:
self.message_for_supplier = data.response
if not self.subject:
self.subject = data.subject
@@ -304,7 +314,10 @@ class RequestforQuotation(BuyingController):
else:
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
rendered_message = frappe.render_template(self.message_for_supplier, doc_args)
message_template = self.mfs_html if self.use_html else self.message_for_supplier
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
rendered_message = frappe.render_template(message_template, doc_args)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")

View File

@@ -34,7 +34,7 @@ class TestPurchaseOrder(IntegrationTestCase):
self.assertEqual(sq.get("items")[1].rate, 300)
self.assertEqual(sq.get("items")[1].description, "test")
def test_update_supplier_quotation_child_rate_disallow(self):
def test_update_supplier_quotation_child_rate(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()
trans_item = json.dumps(
@@ -47,6 +47,22 @@ class TestPurchaseOrder(IntegrationTestCase):
},
]
)
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
sq.reload()
self.assertEqual(sq.get("items")[0].rate, 300)
po = make_purchase_order(sq.name)
po.schedule_date = add_days(today(), 1)
po.submit()
trans_item = json.dumps(
[
{
"item_code": sq.items[0].item_code,
"rate": 20,
"qty": sq.items[0].qty,
"docname": sq.items[0].name,
},
]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
)

View File

@@ -165,7 +165,7 @@ def get_data(filters):
"cost_center": po.cost_center,
"project": po.project,
"requesting_site": po.warehouse,
"requestor": po.owner,
"requestor": mr_record.get("owner", po.owner),
"material_request_no": po.material_request,
"item_code": po.item_code,
"quantity": flt(po.qty),

View File

@@ -2525,13 +2525,14 @@ class AccountsController(TransactionBase):
grand_total = flt(self.get("rounded_total") or self.grand_total)
automatically_fetch_payment_terms = 0
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
grand_total = grand_total - flt(self.write_off_amount)
if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"):
po_or_so, doctype, fieldname = self.get_order_details()
automatically_fetch_payment_terms = cint(
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
if self.doctype != "Sales Order":
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
grand_total = grand_total - flt(self.write_off_amount)
if self.get("total_advance"):
if party_account_currency == self.company_currency:
@@ -2547,7 +2548,7 @@ class AccountsController(TransactionBase):
if not self.get("payment_schedule"):
if (
self.doctype in ["Sales Invoice", "Purchase Invoice"]
self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"]
and automatically_fetch_payment_terms
and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
):
@@ -2605,16 +2606,18 @@ class AccountsController(TransactionBase):
if not self.get("items"):
return None, None, None
if self.doctype == "Sales Invoice":
po_or_so = self.get("items")[0].get("sales_order")
po_or_so_doctype = "Sales Order"
po_or_so_doctype_name = "sales_order"
prev_doc = self.get("items")[0].get("sales_order")
prev_doctype = "Sales Order"
prev_doctype_name = "sales_order"
elif self.doctype == "Purchase Invoice":
prev_doc = self.get("items")[0].get("purchase_order")
prev_doctype = "Purchase Order"
prev_doctype_name = "purchase_order"
else:
po_or_so = self.get("items")[0].get("purchase_order")
po_or_so_doctype = "Purchase Order"
po_or_so_doctype_name = "purchase_order"
return po_or_so, po_or_so_doctype, po_or_so_doctype_name
prev_doc = self.get("items")[0].get("prevdoc_docname")
prev_doctype = "Quotation"
prev_doctype_name = "prevdoc_docname"
return prev_doc, prev_doctype, prev_doctype_name
def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype):
if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname):
@@ -3872,20 +3875,28 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
return False
def validate_quantity(child_item, new_data):
def validate_quantity_and_rate(child_item, new_data):
if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
frappe.throw(
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
_("Row #{0}:Quantity for Item {1} cannot be zero.").format(
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
),
title=_("Invalid Qty"),
)
if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty):
frappe.throw(_("Cannot set quantity less than delivered quantity"))
qty_limits = {
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity")),
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity")),
}
if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty):
frappe.throw(_("Cannot set quantity less than received quantity"))
if parent_doctype in qty_limits:
qty_field, error_message = qty_limits[parent_doctype]
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
frappe.throw(
_("Row #{0}:").format(new_data.get("idx"))
+ error_message.format(frappe.bold(new_data.get("item_code"))),
title=_("Invalid Qty"),
)
if parent_doctype in ["Quotation", "Supplier Quotation"]:
if (parent_doctype == "Quotation" and not ordered_items) or (
@@ -3898,7 +3909,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
if parent_doctype == "Quotation"
else purchased_items.get(child_item.name)
)
if qty_to_check:
if not rate_unchanged:
frappe.throw(
_(
"Cannot update rate as item {0} is already ordered or purchased against this quotation"
).format(frappe.bold(new_data.get("item_code")))
)
if flt(new_data.get("qty")) < qty_to_check:
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
@@ -4017,10 +4036,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
):
continue
validate_quantity(child_item, d)
if parent_doctype in ["Quotation", "Supplier Quotation"]:
if not rate_unchanged:
frappe.throw(_("Rates cannot be modified for quoted items"))
validate_quantity_and_rate(child_item, d)
if flt(child_item.get("qty")) != flt(d.get("qty")):
any_qty_changed = True

View File

@@ -1012,7 +1012,14 @@ def get_serial_batches_based_on_bundle(doctype, field, _bundle_ids):
if doctype == "Packed Item":
if key is None:
key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field)
key = frappe.get_cached_value(
"Packed Item",
{"parent_detail_docname": row.voucher_detail_no, "item_code": row.item_code},
field,
)
if key is None:
key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field)
if row.voucher_type == "Delivery Note":
key = frappe.get_cached_value("Delivery Note Item", key, "dn_detail")
elif row.voucher_type == "Sales Invoice":

View File

@@ -333,9 +333,10 @@ class SellingController(StockController):
if is_internal_customer or not is_stock_item:
continue
if item.get("incoming_rate") and item.base_net_rate < (
rate_field = "valuation_rate" if self.doctype in ["Sales Order", "Quotation"] else "incoming_rate"
if item.get(rate_field) and item.base_net_rate < (
valuation_rate := flt(
item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
item.get(rate_field) * (item.conversion_factor or 1), item.precision("base_net_rate")
)
):
throw_message(

View File

@@ -63,6 +63,8 @@ class StockController(AccountsController):
if not self.get("is_return"):
self.validate_inspection()
self.validate_warehouse_of_sabb()
self.validate_serialized_batch()
self.clean_serial_nos()
self.validate_customer_provided_item()
@@ -75,6 +77,45 @@ class StockController(AccountsController):
super().on_update()
self.check_zero_rate()
def validate_warehouse_of_sabb(self):
if self.is_internal_transfer():
return
doc_before_save = self.get_doc_before_save()
for row in self.items:
if not row.get("serial_and_batch_bundle"):
continue
sabb_details = frappe.db.get_value(
"Serial and Batch Bundle",
row.serial_and_batch_bundle,
["type_of_transaction", "warehouse", "has_serial_no"],
as_dict=True,
)
if not sabb_details:
continue
if sabb_details.type_of_transaction != "Outward":
continue
warehouse = row.get("warehouse") or row.get("s_warehouse")
if sabb_details.warehouse != warehouse:
frappe.throw(
_(
"Row #{0}: Warehouse {1} does not match with the warehouse {2} in Serial and Batch Bundle {3}."
).format(row.idx, warehouse, sabb_details.warehouse, row.serial_and_batch_bundle)
)
if self.doctype == "Stock Reconciliation":
continue
if sabb_details.has_serial_no and doc_before_save and doc_before_save.get("items"):
prev_row = doc_before_save.get("items", {"idx": row.idx})
if prev_row and prev_row[0].serial_and_batch_bundle != row.serial_and_batch_bundle:
sabb_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
sabb_doc.validate_serial_no_status()
def reset_conversion_factor(self):
for row in self.get("items"):
if row.uom != row.stock_uom:
@@ -2087,7 +2128,9 @@ def check_item_quality_inspection(doctype, items):
@frappe.whitelist()
def make_quality_inspections(doctype, docname, items, inspection_type):
def make_quality_inspections(
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
):
if isinstance(items, str):
items = json.loads(items)
@@ -2106,6 +2149,7 @@ def make_quality_inspections(doctype, docname, items, inspection_type):
quality_inspection = frappe.get_doc(
{
"company": company,
"doctype": "Quality Inspection",
"inspection_type": inspection_type,
"inspected_by": frappe.session.user,

View File

@@ -307,6 +307,21 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
};
});
this.frm.set_query("uom", "items", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (!row.item_code) {
return;
}
return {
query: "erpnext.controllers.queries.get_item_uom_query",
filters: {
item_code: row.item_code,
},
};
});
me.frm.set_query("contact_person", erpnext.queries["contact_query"]);
if (me.frm.doc.opportunity_from == "Lead") {

View File

@@ -59,7 +59,9 @@ def create_prospect_against_crm_deal():
)
pass
create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
if doc.contacts and len(doc.contacts):
create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
create_address("Prospect", prospect_name, doc.address)
frappe.response["message"] = prospect_name

View File

@@ -0,0 +1,21 @@
{
"app": "erpnext",
"bg_color": "blue",
"creation": "2026-02-24 17:43:08.379896",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon_type": "Link",
"idx": 0,
"label": "Organization",
"link_to": "Organization",
"link_type": "Workspace Sidebar",
"modified": "2026-02-24 17:59:39.885360",
"modified_by": "Administrator",
"name": "Organization",
"owner": "Administrator",
"parent_icon": "",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -219,6 +219,16 @@ website_route_rules = [
{"from_route": "/tasks", "to_route": "Task"},
]
standard_navbar_items = [
{
"item_label": "Clear Demo Data",
"item_type": "Action",
"action": "erpnext.demo.clear_demo();",
"is_standard": 1,
"condition": "eval: frappe.boot.sysdefaults.demo_company",
},
]
standard_portal_menu_items = [
{"title": "Projects", "route": "/project", "reference_doctype": "Project", "role": "Customer"},
{

File diff suppressed because it is too large Load Diff

View File

@@ -1321,9 +1321,9 @@ class JobCard(Document):
def is_work_order_closed(self):
if self.work_order:
status = frappe.get_value("Work Order", self.work_order)
status = frappe.get_value("Work Order", self.work_order, "status")
if status == "Closed":
if status in ["Closed", "Stopped"]:
return True
return False

View File

@@ -225,7 +225,12 @@ class WorkOrder(Document):
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
def validate_fg_warehouse_for_reservation(self):
if self.reserve_stock and self.sales_order and not self.subcontracting_inward_order:
if (
self.reserve_stock
and self.sales_order
and not self.subcontracting_inward_order
and not self.production_plan_sub_assembly_item
):
warehouses = frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "item_code": self.production_item},
@@ -413,39 +418,52 @@ class WorkOrder(Document):
)
def validate_sales_order(self):
if self.production_plan_sub_assembly_item:
return
if self.sales_order:
self.check_sales_order_on_hold_or_close()
so = frappe.db.sql(
"""
select so.name, so_item.delivery_date, so.project
from `tabSales Order` so
inner join `tabSales Order Item` so_item on so_item.parent = so.name
left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent
where so.name=%s and so.docstatus = 1
and so.skip_delivery_note = 0 and (
so_item.item_code=%s or
pk_item.item_code=%s )
""",
(self.sales_order, self.production_item, self.production_item),
as_dict=1,
SalesOrder = frappe.qb.DocType("Sales Order")
SalesOrderItem = frappe.qb.DocType("Sales Order Item")
PackedItem = frappe.qb.DocType("Packed Item")
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
so = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
.left_join(ProductBundleItem)
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
.where(
(SalesOrder.skip_delivery_note == 0)
& (SalesOrder.docstatus == 1)
& (SalesOrder.name == self.sales_order)
& (
(SalesOrderItem.item_code == self.production_item)
| (ProductBundleItem.item_code == self.production_item)
)
)
.run(as_dict=1)
)
if not so:
so = frappe.db.sql(
"""
select
so.name, so_item.delivery_date, so.project
from
`tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item
where so.name=%s
and so.name=so_item.parent
and so.name=packed_item.parent
and so.skip_delivery_note = 0
and so_item.item_code = packed_item.parent_item
and so.docstatus = 1 and packed_item.item_code=%s
""",
(self.sales_order, self.production_item),
as_dict=1,
so = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
.inner_join(PackedItem)
.on(PackedItem.parent == SalesOrder.name)
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
.where(
(SalesOrder.name == self.sales_order)
& (SalesOrder.skip_delivery_note == 0)
& (SalesOrderItem.item_code == PackedItem.parent_item)
& (SalesOrder.docstatus == 1)
& (PackedItem.item_code == self.production_item)
)
.run(as_dict=1)
)
if len(so):
@@ -651,7 +669,7 @@ class WorkOrder(Document):
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
if self.sales_order and self.sales_order_item:
if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item:
update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
if self.production_plan:
@@ -695,19 +713,25 @@ class WorkOrder(Document):
self.db_set("disassembled_qty", self.disassembled_qty)
def get_transferred_or_manufactured_qty(self, purpose, fieldname):
table = frappe.qb.DocType("Stock Entry")
query = frappe.qb.from_(table).where(
(table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose)
parent = frappe.qb.DocType("Stock Entry")
query = frappe.qb.from_(parent).where(
(parent.work_order == self.name)
& (parent.docstatus == 1)
& (parent.purpose == purpose)
& (parent.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty"))
)
if purpose == "Manufacture":
query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty))
child = frappe.qb.DocType("Stock Entry Detail")
query = (
query.join(child)
.on(parent.name == child.parent)
.select(Sum(child.transfer_qty))
.where(child.is_finished_item == 1)
)
else:
query = query.select(Sum(table.fg_completed_qty))
query = query.where(
table.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty")
)
query = query.select(Sum(parent.fg_completed_qty))
return flt(query.run()[0][0])
@@ -1159,7 +1183,7 @@ class WorkOrder(Document):
doc.db_set("status", doc.status)
def update_work_order_qty_in_so(self):
if not self.sales_order and not self.sales_order_item:
if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
return
total_bundle_qty = 1

View File

@@ -469,3 +469,5 @@ erpnext.patches.v15_0.delete_quotation_lost_record_detail
erpnext.patches.v16_0.add_portal_redirects
erpnext.patches.v16_0.complete_onboarding_steps_for_older_sites #2
erpnext.patches.v16_0.migrate_asset_type_checkboxes_to_select
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.enable_serial_batch_setting

View File

@@ -0,0 +1,9 @@
import frappe
def execute():
if not frappe.get_all("Serial No", limit=1) and not frappe.get_all("Batch", limit=1):
return
frappe.db.set_single_value("Stock Settings", "enable_serial_and_batch_no_for_item", 1)
frappe.db.set_default("enable_serial_and_batch_no_for_item", 1)

View File

@@ -0,0 +1,33 @@
import frappe
from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
def execute():
PurchaseOrderItem = DocType("Purchase Order Item")
MaterialRequestItem = DocType("Material Request Item")
poi_query = (
frappe.qb.from_(PurchaseOrderItem)
.select(PurchaseOrderItem.sales_order_item, Sum(PurchaseOrderItem.stock_qty))
.where(PurchaseOrderItem.sales_order_item.isnotnull() & PurchaseOrderItem.docstatus == 1)
.groupby(PurchaseOrderItem.sales_order_item)
)
mri_query = (
frappe.qb.from_(MaterialRequestItem)
.select(MaterialRequestItem.sales_order_item, Sum(MaterialRequestItem.stock_qty))
.where(MaterialRequestItem.sales_order_item.isnotnull() & MaterialRequestItem.docstatus == 1)
.groupby(MaterialRequestItem.sales_order_item)
)
poi_data = poi_query.run()
mri_data = mri_query.run()
updates_against_poi = {data[0]: {"ordered_qty": data[1]} for data in poi_data}
updates_against_mri = {data[0]: {"requested_qty": data[1], "ordered_qty": 0} for data in mri_data}
frappe.db.auto_commit_on_many_writes = 1
frappe.db.bulk_update("Sales Order Item", updates_against_mri)
frappe.db.bulk_update("Sales Order Item", updates_against_poi)
frappe.db.auto_commit_on_many_writes = 0

View File

@@ -205,7 +205,7 @@ frappe.ui.form.on("Project", {
collect_progress: function (frm) {
if (frm.doc.collect_progress && !frm.doc.subject) {
frm.set_value("subject", __("For project {0}, update your status", [frm.doc.name]));
frm.set_value("subject", __("For project - {0}, update your status", [frm.doc.project_name]));
}
},
});

View File

@@ -12,29 +12,21 @@
"project_name",
"status",
"project_type",
"is_active",
"percent_complete_method",
"percent_complete",
"column_break_5",
"project_template",
"expected_start_date",
"expected_end_date",
"priority",
"department",
"customer_details",
"customer",
"column_break_14",
"sales_order",
"users_section",
"users",
"copied_from",
"section_break0",
"notes",
"is_active",
"percent_complete",
"section_break_18",
"expected_start_date",
"actual_start_date",
"actual_time",
"column_break_20",
"expected_end_date",
"actual_end_date",
"costing_tab",
"project_details",
"estimated_costing",
"total_costing_amount",
@@ -50,7 +42,7 @@
"gross_margin",
"column_break_37",
"per_gross_margin",
"monitor_progress",
"monitor_progress_tab",
"collect_progress",
"holiday_list",
"frequency",
@@ -63,7 +55,18 @@
"weekly_time_to_send",
"column_break_45",
"subject",
"message"
"message",
"more_info_tab",
"customer_details",
"customer",
"column_break_14",
"sales_order",
"users_section",
"users",
"copied_from",
"section_break0",
"notes",
"connections_tab"
],
"fields": [
{
@@ -231,7 +234,7 @@
"collapsible": 1,
"fieldname": "section_break_18",
"fieldtype": "Section Break",
"label": "Start and End Dates"
"label": "Timeline"
},
{
"fieldname": "actual_start_date",
@@ -258,7 +261,6 @@
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "project_details",
"fieldtype": "Section Break",
"label": "Costing and Billing",
@@ -329,7 +331,6 @@
"options": "Cost Center"
},
{
"collapsible": 1,
"fieldname": "margin",
"fieldtype": "Section Break",
"label": "Margin",
@@ -357,12 +358,6 @@
"oldfieldtype": "Currency",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "monitor_progress",
"fieldtype": "Section Break",
"label": "Monitor Progress"
},
{
"default": "0",
"fieldname": "collect_progress",
@@ -455,6 +450,27 @@
"fieldtype": "Data",
"label": "Subject",
"mandatory_depends_on": "collect_progress"
},
{
"fieldname": "costing_tab",
"fieldtype": "Tab Break",
"label": "Costing"
},
{
"fieldname": "monitor_progress_tab",
"fieldtype": "Tab Break",
"label": "Progress"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
},
{
"fieldname": "connections_tab",
"fieldtype": "Tab Break",
"label": "Connections",
"show_dashboard": 1
}
],
"icon": "fa fa-puzzle-piece",
@@ -462,7 +478,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2025-08-21 17:57:58.314809",
"modified": "2026-03-04 11:09:55.253367",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",

View File

@@ -19,6 +19,13 @@ frappe.ui.form.on("Project Template", {
frappe.ui.form.on("Project Template Task", {
task: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];
if (!row.task) {
row.subject = null;
refresh_field("tasks");
return;
}
frappe.db.get_value("Task", row.task, "subject", (value) => {
row.subject = value.subject;
refresh_field("tasks");

View File

@@ -13,7 +13,6 @@
"type",
"color",
"is_group",
"is_template",
"column_break0",
"status",
"priority",
@@ -21,17 +20,21 @@
"parent_task",
"completed_by",
"completed_on",
"section_break_dafi",
"is_template",
"column_break_vvfp",
"start",
"duration",
"sb_timeline",
"exp_start_date",
"expected_time",
"start",
"column_break_11",
"exp_end_date",
"progress",
"duration",
"is_milestone",
"sb_details",
"description",
"dependencies_tab",
"sb_depends_on",
"depends_on",
"depends_on_tasks",
@@ -44,12 +47,13 @@
"total_costing_amount",
"column_break_20",
"total_billing_amount",
"more_info_tab",
"sb_more_info",
"company",
"review_date",
"closing_date",
"column_break_22",
"department",
"company",
"lft",
"rgt",
"old_parent",
@@ -78,7 +82,6 @@
"oldfieldname": "project",
"oldfieldtype": "Link",
"options": "Project",
"remember_last_selected_value": 1,
"search_index": 1
},
{
@@ -218,7 +221,6 @@
{
"fieldname": "sb_depends_on",
"fieldtype": "Section Break",
"label": "Dependencies",
"oldfieldtype": "Section Break"
},
{
@@ -298,10 +300,9 @@
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "sb_more_info",
"fieldtype": "Section Break",
"label": "More Info"
"label": "Additional Info"
},
{
"depends_on": "eval:doc.status == \"Closed\" || doc.status == \"Pending Review\"",
@@ -334,8 +335,7 @@
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"remember_last_selected_value": 1
"options": "Company"
},
{
"fieldname": "lft",
@@ -368,6 +368,7 @@
"options": "User"
},
{
"allow_in_quick_entry": 1,
"default": "0",
"fieldname": "is_template",
"fieldtype": "Check",
@@ -397,6 +398,24 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Template Task"
},
{
"fieldname": "dependencies_tab",
"fieldtype": "Tab Break",
"label": "Dependencies"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
},
{
"fieldname": "section_break_dafi",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_vvfp",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-check",
@@ -404,11 +423,11 @@
"is_tree": 1,
"links": [],
"max_attachments": 5,
"modified": "2025-10-16 08:39:12.214577",
"modified": "2026-03-04 11:47:10.454548",
"modified_by": "Administrator",
"module": "Projects",
"name": "Task",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"nsm_parent_field": "parent_task",
"owner": "Administrator",
"permissions": [
@@ -425,6 +444,7 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "subject",
"show_name_in_global_search": 1,
"show_preview_popup": 1,
@@ -434,4 +454,4 @@
"timeline_field": "project",
"title_field": "subject",
"track_seen": 1
}
}

View File

@@ -138,6 +138,8 @@ class Task(NestedSet):
def validate_status(self):
if self.is_template and self.status != "Template":
self.status = "Template"
if self.status == "Template" and not self.is_template:
self.status = "Open"
if self.status != self.get_db_value("status") and self.status == "Completed":
for d in self.depends_on:
if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"):

View File

@@ -260,6 +260,33 @@ frappe.ui.form.on("Timesheet", {
parent_project: function (frm) {
set_project_in_timelog(frm);
},
employee: function (frm) {
if (frm.doc.employee && frm.doc.time_logs) {
const selected_employee = frm.doc.employee;
frm.doc.time_logs.forEach((row) => {
if (row.activity_type) {
frappe.call({
method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost",
args: {
employee: frm.doc.employee,
activity_type: row.activity_type,
currency: frm.doc.currency,
},
callback: function (r) {
if (r.message) {
if (selected_employee !== frm.doc.employee) return;
row.billing_rate = r.message["billing_rate"];
row.costing_rate = r.message["costing_rate"];
frm.refresh_fields("time_logs");
calculate_billing_costing_amount(frm, row.doctype, row.name);
}
},
});
}
});
}
},
});
frappe.ui.form.on("Timesheet Detail", {

View File

@@ -18,28 +18,29 @@
"column_break_3",
"status",
"parent_project",
"employee_detail",
"employee",
"employee_name",
"department",
"column_break_9",
"user",
"start_date",
"end_date",
"employee_detail",
"employee",
"department",
"column_break_9",
"employee_name",
"section_break_5",
"time_logs",
"working_hours",
"total_hours",
"billing_tab",
"billing_details",
"total_billable_hours",
"total_billable_amount",
"total_costing_amount",
"base_total_billable_amount",
"base_total_billed_amount",
"base_total_costing_amount",
"column_break_10",
"total_billed_hours",
"total_billable_amount",
"total_billed_amount",
"total_costing_amount",
"base_total_billed_amount",
"per_billed",
"section_break_18",
"note",
@@ -176,7 +177,6 @@
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "billing_details",
"fieldtype": "Section Break",
"label": "Billing Details",
@@ -304,13 +304,18 @@
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate"
},
{
"fieldname": "billing_tab",
"fieldtype": "Tab Break",
"label": "Billing"
}
],
"icon": "fa fa-clock-o",
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-12-19 13:48:23.453636",
"modified": "2026-03-04 11:56:51.438298",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M19.2857 15.4286H22.1786C23.7763 15.4286 25.0714 16.7237 25.0714 18.3214V24.1071C25.0714 25.7048 23.7763 27 22.1786 27H19.2857V38.5714H15.4286V27H11.5714V23.1428H21.2143V19.2857H11.5714V15.4286H15.4286V11.5714H19.2857V15.4286ZM38.5714 38.5714H34.7143V34.7143H31.8214C30.2238 34.7143 28.9286 33.4191 28.9286 31.8214V26.0357C28.9286 24.438 30.2238 23.1428 31.8214 23.1428H34.7143V11.5714H38.5714V23.1428H42.4286V27H32.7857V30.8571H42.4286V34.7143H38.5714V38.5714Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 787 B

View File

@@ -0,0 +1,5 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7"/>
<path d="M20.5 13.25C20.5 13.0926 20.5 13 20.5 13L18.5 11.499V19.5H20C20.2761 19.5 20 19.5 20.5 19.5V13.25ZM14.5 14V16H10.5V14H14.5ZM22.5 19C22.5 20.3807 21.3807 21.5 20 21.5H16.5V19.5V7.5C16.5 7.5 16.2761 7.5 16 7.5H9C8.72386 7.5 9 7.5 8.5 7.5V19.5C9 19.5 8.72386 19.5 9 19.5H12.5V21.5H9C7.61929 21.5 6.5 20.3807 6.5 19V8C6.5 6.61929 7.61929 5.5 9 5.5H16C17.3807 5.5 18.5 6.61929 18.5 8V9L21.5 11.25C22.1295 11.7221 22.5 12.4631 22.5 13.25V19Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M19.2857 15.4286H22.1786C23.7762 15.4286 25.0714 16.7238 25.0714 18.3215V24.1072C25.0714 25.7049 23.7762 27 22.1786 27H19.2857V38.5715H15.4286V27H11.5714V23.1429H21.2143V19.2858H11.5714V15.4286H15.4286V11.5715H19.2857V15.4286ZM38.5714 38.5715H34.7143V34.7143H31.8214C30.2237 34.7143 28.9286 33.4192 28.9286 31.8215V26.0358C28.9286 24.4381 30.2237 23.1429 31.8214 23.1429H34.7143V11.5715H38.5714V23.1429H42.4286V27H32.7857V30.8572H42.4286V34.7143H38.5714V38.5715Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -0,0 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M20.5 13.25C20.5 13.0926 20.5 13 20.5 13L18.5 11.499V19.5H20C20.2761 19.5 20 19.5 20.5 19.5V13.25ZM14.5 14V16H10.5V14H14.5ZM22.5 19C22.5 20.3807 21.3807 21.5 20 21.5H16.5V19.5V7.5C16.5 7.5 16.2761 7.5 16 7.5H9C8.72386 7.5 9 7.5 8.5 7.5V19.5C9 19.5 8.72386 19.5 9 19.5H12.5V21.5H9C7.61929 21.5 6.5 20.3807 6.5 19V8C6.5 6.61929 7.61929 5.5 9 5.5H16C17.3807 5.5 18.5 6.61929 18.5 8V9L21.5 11.25C22.1295 11.7221 22.5 12.4631 22.5 13.25V19Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@@ -580,6 +580,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.validate_has_items();
erpnext.utils.view_serial_batch_nos(this.frm);
this.set_route_options_for_new_doc();
erpnext.toggle_serial_batch_fields(this.frm);
}
set_route_options_for_new_doc() {
@@ -1307,6 +1308,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if (this.frm.doc.transaction_date) {
this.frm.transaction_date = this.frm.doc.transaction_date;
frappe.ui.form.trigger(this.frm.doc.doctype, "currency");
this.recalculate_terms();
}
}
@@ -2966,6 +2968,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.call({
method: "erpnext.controllers.stock_controller.make_quality_inspections",
args: {
company: me.frm.doc.company,
doctype: me.frm.doc.doctype,
docname: me.frm.doc.name,
items: selected_data,

View File

@@ -19,6 +19,71 @@ $.extend(erpnext, {
return currency_list;
},
toggle_serial_batch_fields(frm) {
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
let fields = ["serial_and_batch_bundle", "use_serial_batch_fields", "serial_no", "batch_no"];
if (
[
"Stock Entry",
"Purchase Receipt",
"Purchase Invoice",
"Stock Reconciliation",
"Subcontracting Receipt",
].includes(frm.doc.doctype)
) {
fields.push("add_serial_batch_bundle");
}
if (["Stock Reconciliation"].includes(frm.doc.doctype)) {
fields.push("reconcile_all_serial_batch");
}
if (["Sales Invoice", "Delivery Note", "Pick List"].includes(frm.doc.doctype)) {
fields.push("pick_serial_and_batch");
}
if (["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(frm.doc.doctype)) {
fields.push("add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle");
}
let child_name = "items";
if (frm.doc.doctype === "Pick List") {
child_name = "locations";
}
if (frm.doc.doctype === "Asset Capitalization") {
child_name = "stock_items";
}
fields.forEach((field) => {
frm.fields_dict[child_name].grid.update_docfield_property(field, "hidden", hide_fields);
frm.fields_dict[child_name].grid.update_docfield_property(
field,
"in_list_view",
hide_fields ? 0 : 1
);
if (
frm.doc.doctype === "Subcontracting Receipt" &&
!["add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle"].includes(field)
) {
frm.fields_dict["supplied_items"].grid.update_docfield_property(field, "hidden", hide_fields);
frm.fields_dict["supplied_items"].grid.update_docfield_property(
field,
"in_list_view",
hide_fields ? 0 : 1
);
frm.fields_dict["supplied_items"].grid.reset_grid();
}
});
frm.fields_dict[child_name].grid.reset_grid();
},
toggle_naming_series: function () {
if (
cur_frm &&

View File

@@ -7,7 +7,7 @@ import json
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, getdate, nowdate
from frappe.utils import cint, flt, getdate, nowdate
from erpnext.controllers.selling_controller import SellingController
@@ -446,6 +446,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
child_filter = d.name in filtered_items if filtered_items else True
return child_filter
automatically_fetch_payment_terms = cint(
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
doclist = get_mapped_doc(
"Quotation",
source_name,
@@ -453,6 +457,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
"Quotation": {
"doctype": "Sales Order",
"validation": {"docstatus": ["=", 1]},
"field_no_map": ["payment_terms_template"],
},
"Quotation Item": {
"doctype": "Sales Order Item",
@@ -462,13 +467,15 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
"Payment Schedule": {"doctype": "Payment Schedule", "add_if_empty": True},
},
target_doc,
set_missing_values,
ignore_permissions=ignore_permissions,
)
if automatically_fetch_payment_terms:
doclist.set_payment_schedule()
return doclist

View File

@@ -59,8 +59,22 @@ class TestQuotation(IntegrationTestCase):
qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2)
self.assertRaises(frappe.ValidationError, qo.save)
def test_update_child_disallow_rate_change(self):
qo = make_quotation(qty=4)
def test_update_child_rate_change(self):
from erpnext.stock.doctype.item.test_item import make_item
item_1 = make_item("_Test Item")
item_2 = make_item("_Test Item 1")
item_list = [
{"item_code": item_1.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 300},
{"item_code": item_2.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 5, "rate": 400},
]
qo = make_quotation(item_list=item_list)
so = make_sales_order(qo.name, args={"filtered_children": [qo.items[0].name]})
so.delivery_date = nowdate()
so.submit()
qo.reload()
trans_item = json.dumps(
[
{
@@ -68,10 +82,35 @@ class TestQuotation(IntegrationTestCase):
"rate": 5000,
"qty": qo.items[0].qty,
"docname": qo.items[0].name,
}
},
{
"item_code": qo.items[1].item_code,
"rate": qo.items[1].rate,
"qty": qo.items[1].qty,
"docname": qo.items[1].name,
},
]
)
self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name)
trans_item = json.dumps(
[
{
"item_code": qo.items[0].item_code,
"rate": qo.items[0].rate,
"qty": qo.items[0].qty,
"docname": qo.items[0].name,
},
{
"item_code": qo.items[1].item_code,
"rate": 50,
"qty": qo.items[1].qty,
"docname": qo.items[1].name,
},
]
)
update_child_qty_rate("Quotation", trans_item, qo.name)
qo.reload()
self.assertEqual(qo.items[1].rate, 50)
def test_update_child_removing_item(self):
qo = make_quotation(qty=10)
@@ -143,6 +182,10 @@ class TestQuotation(IntegrationTestCase):
self.assertTrue(quotation.payment_schedule)
@IntegrationTestCase.change_settings(
"Accounts Settings",
{"automatically_fetch_payment_terms": 1},
)
def test_make_sales_order_terms_copied(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
@@ -285,7 +328,11 @@ class TestQuotation(IntegrationTestCase):
@IntegrationTestCase.change_settings(
"Accounts Settings",
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 0},
{
"add_taxes_from_item_tax_template": 0,
"add_taxes_from_taxes_and_charges_template": 0,
"automatically_fetch_payment_terms": 1,
},
)
def test_make_sales_order_with_terms(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
@@ -323,10 +370,13 @@ class TestQuotation(IntegrationTestCase):
sales_order.save()
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00)
self.assertEqual(sales_order.payment_schedule[0].due_date, getdate(quotation.transaction_date))
self.assertEqual(
getdate(sales_order.payment_schedule[0].due_date), getdate(quotation.transaction_date)
)
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 8906.00)
self.assertEqual(
sales_order.payment_schedule[1].due_date, getdate(add_days(quotation.transaction_date, 30))
getdate(sales_order.payment_schedule[1].due_date),
getdate(add_days(quotation.transaction_date, 30)),
)
def test_valid_till_before_transaction_date(self):
@@ -1026,6 +1076,56 @@ class TestQuotation(IntegrationTestCase):
quotation.reload()
self.assertEqual(quotation.status, "Open")
@IntegrationTestCase.change_settings(
"Accounts Settings",
{"automatically_fetch_payment_terms": 1},
)
def test_make_sales_order_with_payment_terms(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test Payment Terms Template for Quotation",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"invoice_portion": 50.00,
"credit_days_based_on": "Day(s) after invoice date",
"credit_days": 0,
},
{
"doctype": "Payment Terms Template Detail",
"invoice_portion": 50.00,
"credit_days_based_on": "Day(s) after invoice date",
"credit_days": 10,
},
],
}
).save()
quotation = make_quotation(qty=10, rate=1000, do_not_submit=1)
quotation.transaction_date = add_days(nowdate(), -2)
quotation.valid_till = add_days(nowdate(), 10)
quotation.update({"payment_terms_template": template.name, "payment_schedule": []})
quotation.save()
quotation.submit()
self.assertEqual(quotation.payment_schedule[0].payment_amount, 5000)
self.assertEqual(quotation.payment_schedule[1].payment_amount, 5000)
self.assertEqual(quotation.payment_schedule[0].due_date, quotation.transaction_date)
self.assertEqual(quotation.payment_schedule[1].due_date, add_days(quotation.transaction_date, 10))
sales_order = make_sales_order(quotation.name)
sales_order.transaction_date = nowdate()
sales_order.delivery_date = nowdate()
sales_order.save()
self.assertEqual(sales_order.payment_schedule[0].due_date, sales_order.transaction_date)
self.assertEqual(sales_order.payment_schedule[1].due_date, add_days(sales_order.transaction_date, 10))
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 5000)
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 5000)
def enable_calculate_bundle_price(enable=1):
selling_settings = frappe.get_doc("Selling Settings")

View File

@@ -18,6 +18,7 @@
"column_break_7",
"order_type",
"transaction_date",
"transaction_time",
"delivery_date",
"column_break1",
"tax_id",
@@ -122,6 +123,7 @@
"company_contact_person",
"payment_schedule_section",
"payment_terms_section",
"ignore_default_payment_terms_template",
"payment_terms_template",
"payment_schedule",
"terms_section_break",
@@ -1724,6 +1726,22 @@
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
},
{
"default": "Now",
"depends_on": "is_internal_customer",
"fieldname": "transaction_time",
"fieldtype": "Time",
"label": "Time",
"mandatory_depends_on": "is_internal_customer"
},
{
"default": "0",
"fieldname": "ignore_default_payment_terms_template",
"fieldtype": "Check",
"hidden": 1,
"label": "Ignore Default Payment Terms Template",
"read_only": 1
}
],
"grid_page_length": 50,
@@ -1731,7 +1749,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-02-10 11:55:52.796522",
"modified": "2026-03-04 18:04:05.873483",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -116,6 +116,7 @@ class SalesOrder(SellingController):
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_default_payment_terms_template: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -186,6 +187,7 @@ class SalesOrder(SellingController):
total_qty: DF.Float
total_taxes_and_charges: DF.Currency
transaction_date: DF.Date
transaction_time: DF.Time | None
utm_campaign: DF.Link | None
utm_content: DF.Data | None
utm_medium: DF.Link | None

View File

@@ -2647,6 +2647,49 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
si2 = make_sales_invoice(so.name)
self.assertEqual(si2.items[0].qty, 20)
@change_settings("Selling Settings", {"validate_selling_price": 1})
def test_selling_price_validation_for_manufactured_item(self):
"""
Unit test to check the selling price validation for manufactured item, without last purchae rate in Item master.
"""
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# create a FG Item and RM Item
rm_item = make_item(
"_Test RM Item for SO selling validation",
{"is_stock_item": 1, "valuation_rate": 100, "stock_uom": "Nos"},
).name
rm_warehouse = create_warehouse("_Test RM SPV Warehouse")
fg_item = make_item("_Test FG Item for SO selling validation", {"is_stock_item": 1}).name
fg_warehouse = create_warehouse("_Test FG SPV Warehouse")
# create BOM and inward entry for RM Item
bom_no = make_bom(item=fg_item, raw_materials=[rm_item]).name
make_stock_entry(item_code=rm_item, target=rm_warehouse, qty=10, rate=100)
# create a manufacture entry, so system won't update the last purchase rate in Item master.
se = make_stock_entry(item_code=fg_item, qty=10, purpose="Manufacture", do_not_save=True)
se.from_bom = 1
se.use_multi_level_bom = 1
se.bom_no = bom_no
se.fg_completed_qty = 1
se.from_warehouse = rm_warehouse
se.to_warehouse = fg_warehouse
se.get_items()
se.save()
se.submit()
# check valuation of FG Item
self.assertEqual(se.items[1].valuation_rate, 100)
# create a SO for FG Item with selling rate than valuation rate.
so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1)
self.assertRaises(frappe.ValidationError, so.save)
def compare_payment_schedules(doc, doc1, doc2):
for index, schedule in enumerate(doc1.get("payment_schedule")):

View File

@@ -95,6 +95,7 @@
"ordered_qty",
"planned_qty",
"production_plan_qty",
"requested_qty",
"column_break_69",
"work_order_qty",
"delivered_qty",
@@ -1010,13 +1011,21 @@
"fieldtype": "Float",
"label": "Finished Good Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted"
},
{
"fieldname": "requested_qty",
"fieldtype": "Float",
"label": "Requested Qty",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-20 16:39:00.200328",
"modified": "2026-02-21 16:39:00.200328",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -80,6 +80,7 @@ class SalesOrderItem(Document):
quotation_item: DF.Data | None
rate: DF.Currency
rate_with_margin: DF.Currency
requested_qty: DF.Float
reserve_stock: DF.Check
returned_qty: DF.Float
stock_qty: DF.Float

View File

@@ -329,7 +329,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-12 10:38:34.605126",
"modified": "2026-02-27 00:47:46.003305",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@@ -0,0 +1,47 @@
{
"allow_roles": [
{
"role": "System Manager"
},
{
"role": "Sales Manager"
},
{
"role": "Accounts Manager"
},
{
"role": "Manufacturing Manager"
},
{
"role": "Stock Manager"
}
],
"creation": "2026-02-24 18:03:53.158438",
"docstatus": 0,
"doctype": "Module Onboarding",
"idx": 0,
"is_complete": 0,
"modified": "2026-02-24 18:07:36.808560",
"modified_by": "Administrator",
"module": "Setup",
"name": "Organization Onboarding",
"owner": "Administrator",
"steps": [
{
"step": "Setup Company"
},
{
"step": "Invite Users"
},
{
"step": "Setup Email Account"
},
{
"step": "Setup Role Permissions"
},
{
"step": "Review System Settings"
}
],
"title": "Setup Organization"
}

View File

@@ -0,0 +1,20 @@
{
"action": "Create Entry",
"action_label": "Invite Users",
"creation": "2026-02-24 18:04:21.585575",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-24 18:04:21.585575",
"modified_by": "Administrator",
"name": "Invite Users",
"owner": "Administrator",
"reference_document": "User",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Invite Users",
"validate_action": 1
}

View File

@@ -0,0 +1,21 @@
{
"action": "Update Settings",
"action_label": "Review System Settings",
"creation": "2026-02-24 18:06:56.781335",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 1,
"is_skipped": 0,
"modified": "2026-02-24 18:06:56.781335",
"modified_by": "Administrator",
"name": "Review System Settings",
"owner": "Administrator",
"reference_document": "System Settings",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Review System Settings",
"validate_action": 0,
"value_to_validate": ""
}

View File

@@ -0,0 +1,21 @@
{
"action": "Go to Page",
"action_label": "Setup Company",
"creation": "2026-02-20 11:12:50.373049",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 1,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-23 21:10:17.680053",
"modified_by": "Administrator",
"name": "Setup Company",
"owner": "Administrator",
"path": "company",
"reference_document": "Company",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Company",
"validate_action": 1
}

View File

@@ -0,0 +1,20 @@
{
"action": "Create Entry",
"action_label": "Setup Email Account",
"creation": "2026-02-24 18:04:39.983155",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-24 18:04:39.983155",
"modified_by": "Administrator",
"name": "Setup Email Account",
"owner": "Administrator",
"reference_document": "Email Account",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Email Account",
"validate_action": 1
}

View File

@@ -0,0 +1,20 @@
{
"action": "Go to Page",
"action_label": "Setup Role Permissions",
"creation": "2026-02-24 18:05:10.485778",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-24 18:05:10.485778",
"modified_by": "Administrator",
"name": "Setup Role Permissions",
"owner": "Administrator",
"path": "permission-manager",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Role Permissions",
"validate_action": 1
}

View File

@@ -221,6 +221,8 @@ def set_defaults_for_tests():
frappe.db.set_default(key, value)
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
frappe.db.set_single_value("Stock Settings", "enable_serial_and_batch_no_for_item", 1)
def insert_record(records):
from frappe.desk.page.setup_wizard.setup_wizard import make_records

View File

@@ -84,7 +84,25 @@ frappe.ui.form.on("Item", {
}
},
toggle_has_serial_batch_fields(frm) {
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
frm.toggle_display(["serial_no_series", "batch_number_series", "create_new_batch"], !hide_fields);
frm.toggle_enable(["has_serial_no", "has_batch_no"], !hide_fields);
if (hide_fields) {
let description = __(
"To enable the Serial No and Batch No feature, please check the 'Enable Serial / Batch No for Item' checkbox in Stock Settings."
);
frm.set_df_property("has_serial_no", "description", description);
frm.set_df_property("has_batch_no", "description", description);
}
},
refresh: function (frm) {
frm.trigger("toggle_has_serial_batch_fields");
if (frm.doc.is_stock_item) {
frm.add_custom_button(
__("Stock Balance"),

View File

@@ -452,6 +452,7 @@
"fieldname": "batch_number_series",
"fieldtype": "Data",
"label": "Batch Number Series",
"show_description_on_click": 1,
"translatable": 1
},
{
@@ -493,7 +494,8 @@
"description": "Example: ABCD.#####\nIf series is set and Serial No is not mentioned in transactions, then automatic serial number will be created based on this series. If you always want to explicitly mention Serial Nos for this item. leave this blank.",
"fieldname": "serial_no_series",
"fieldtype": "Data",
"label": "Serial Number Series"
"label": "Serial Number Series",
"show_description_on_click": 1
},
{
"collapsible": 1,
@@ -985,7 +987,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-02-05 17:20:35.605734",
"modified": "2026-03-05 16:29:31.653447",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -218,6 +218,7 @@ class Item(Document):
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_item_tax_net_rate_range()
self.validate_allow_to_set_serial_batch()
if not self.is_new():
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
@@ -226,6 +227,18 @@ class Item(Document):
self.update_variants()
self.update_item_price()
def validate_allow_to_set_serial_batch(self):
if not self.has_serial_no and not self.has_batch_no:
return
if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
frappe.throw(
_(
"Please check the 'Enable Serial and Batch No for Item' checkbox in the {0} to set Serial No or Batch No for the item."
).format(get_link_to_form("Stock Settings", "Stock Settings")),
title=_("Serial and Batch No for Item Disabled"),
)
def validate_description(self):
"""Clean HTML description if set"""
if (

View File

@@ -14,6 +14,10 @@ from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
class IncorrectCompanyValidationError(frappe.ValidationError):
pass
class LandedCostVoucher(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -75,6 +79,7 @@ class LandedCostVoucher(Document):
self.check_mandatory()
self.validate_receipt_documents()
self.validate_line_items()
self.validate_expense_accounts()
init_landed_taxes_and_totals(self)
self.set_total_taxes_and_charges()
if not self.get("items"):
@@ -116,11 +121,28 @@ class LandedCostVoucher(Document):
receipt_documents = []
for d in self.get("purchase_receipts"):
docstatus = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus")
docstatus, company = frappe.get_cached_value(
d.receipt_document_type, d.receipt_document, ["docstatus", "company"]
)
if docstatus != 1:
msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted"
frappe.throw(_(msg), title=_("Invalid Document"))
if company != self.company:
frappe.throw(
_(
"Row {0}: {1} {2} is linked to company {3}. Please select a document belonging to company {4}."
).format(
d.idx,
d.receipt_document_type,
frappe.bold(d.receipt_document),
frappe.bold(company),
frappe.bold(self.company),
),
title=_("Incorrect Company"),
exc=IncorrectCompanyValidationError,
)
if d.receipt_document_type == "Purchase Invoice":
update_stock = frappe.db.get_value(
d.receipt_document_type, d.receipt_document, "update_stock"
@@ -152,6 +174,24 @@ class LandedCostVoucher(Document):
_("Row {0}: Cost center is required for an item {1}").format(item.idx, item.item_code)
)
def validate_expense_accounts(self):
for t in self.taxes:
company = frappe.get_cached_value("Account", t.expense_account, "company")
if company != self.company:
frappe.throw(
_(
"Row {0}: Expense Account {1} is linked to company {2}. Please select an account belonging to company {3}."
).format(
t.idx,
frappe.bold(t.expense_account),
frappe.bold(company),
frappe.bold(self.company),
),
title=_("Incorrect Account"),
exc=IncorrectCompanyValidationError,
)
def set_total_taxes_and_charges(self):
self.total_taxes_and_charges = sum(flt(d.base_amount) for d in self.get("taxes"))

View File

@@ -178,6 +178,39 @@ class TestLandedCostVoucher(IntegrationTestCase):
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0)
def test_lcv_validates_company(self):
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
IncorrectCompanyValidationError,
)
company_a = "_Test Company"
company_b = "_Test Company with perpetual inventory"
pr = make_purchase_receipt(
company=company_a,
warehouse="Stores - _TC",
qty=1,
rate=100,
)
lcv = make_landed_cost_voucher(
company=company_b,
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=50,
do_not_save=True,
)
self.assertRaises(IncorrectCompanyValidationError, lcv.validate_receipt_documents)
lcv.company = company_a
self.assertRaises(IncorrectCompanyValidationError, lcv.validate_expense_accounts)
lcv.taxes[0].expense_account = get_expense_account(company_a)
lcv.save()
distribute_landed_cost_on_items(lcv)
lcv.submit()
def test_landed_cost_voucher_for_zero_purchase_rate(self):
"Test impact of LCV on future stock balances."
from erpnext.stock.doctype.item.test_item import make_item
@@ -1260,6 +1293,7 @@ def make_landed_cost_voucher(**args):
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = args.company or "_Test Company"
lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount"
expense_account = get_expense_account(args.company or "_Test Company")
lcv.set(
"purchase_receipts",
@@ -1280,7 +1314,7 @@ def make_landed_cost_voucher(**args):
[
{
"description": "Shipping Charges",
"expense_account": args.expense_account or "Expenses Included In Valuation - TCP1",
"expense_account": args.expense_account or expense_account,
"amount": args.charges,
}
],
@@ -1300,6 +1334,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = company
lcv.distribute_charges_based_on = "Amount"
expense_account = get_expense_account(company)
lcv.set(
"purchase_receipts",
@@ -1319,7 +1354,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
[
{
"description": "Insurance Charges",
"expense_account": "Expenses Included In Valuation - TCP1",
"expense_account": expense_account,
"amount": charges,
}
],
@@ -1334,6 +1369,11 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
return lcv
def get_expense_account(company):
company_abbr = frappe.get_cached_value("Company", company, "abbr")
return f"Expenses Included In Valuation - {company_abbr}"
def distribute_landed_cost_on_items(lcv):
based_on = lcv.distribute_charges_based_on.lower()
total = sum(flt(d.get(based_on)) for d in lcv.get("items"))

View File

@@ -90,7 +90,7 @@ class MaterialRequest(BuyingController):
{
"source_dt": "Material Request Item",
"target_dt": "Sales Order Item",
"target_field": "ordered_qty",
"target_field": "requested_qty",
"target_parent_dt": "Sales Order",
"target_parent_field": "",
"join_field": "sales_order_item",
@@ -280,6 +280,8 @@ class MaterialRequest(BuyingController):
def on_cancel(self):
self.update_requested_qty_in_production_plan(cancel=True)
self.update_requested_qty()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
def get_mr_items_ordered_qty(self, mr_items):
mr_items_ordered_qty = {}
@@ -330,7 +332,8 @@ class MaterialRequest(BuyingController):
if mr_qty_allowance:
allowed_qty = flt(
(d.qty + (d.qty * (mr_qty_allowance / 100))), d.precision("ordered_qty")
(d.stock_qty + (d.stock_qty * (mr_qty_allowance / 100))),
d.precision("ordered_qty"),
)
if d.ordered_qty and flt(d.ordered_qty, precision) > flt(allowed_qty, precision):

View File

@@ -119,6 +119,8 @@ frappe.ui.form.on("Pick List", {
refresh: (frm) => {
frm.trigger("add_get_items_button");
frm.trigger("update_warehouse_property");
erpnext.toggle_serial_batch_fields(frm);
if (frm.doc.docstatus === 1) {
const status_completed = frm.doc.status === "Completed";

View File

@@ -1587,7 +1587,7 @@ def update_common_item_properties(item, location):
item.item_code = location.item_code
item.s_warehouse = location.warehouse
item.transfer_qty = location.picked_qty
item.qty = location.qty
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
item.uom = location.uom
item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom

View File

@@ -1246,7 +1246,9 @@ def get_billed_amount_against_po(po_items):
def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False):
# Update Billing % based on pending accepted qty
buying_settings = frappe.get_single("Buying Settings")
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
over_billing_allowance, role_allowed_to_over_bill = frappe.get_single_value(
"Accounts Settings", ["over_billing_allowance", "role_allowed_to_over_bill"]
)
total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
@@ -1304,7 +1306,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif amount and item.billed_amt > amount:
per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100
if per_over_billed > over_billing_allowance:
if (
per_over_billed > over_billing_allowance
and role_allowed_to_over_bill not in frappe.get_roles()
):
frappe.throw(
_("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format(
item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance

View File

@@ -142,7 +142,9 @@ class TestQualityInspection(IntegrationTestCase):
inspection_type = "Outgoing"
for item in dn.items:
item.sample_size = item.qty
quality_inspections = make_quality_inspections(dn.doctype, dn.name, dn.items, inspection_type)
quality_inspections = make_quality_inspections(
dn.company, dn.doctype, dn.name, dn.items, inspection_type
)
self.assertEqual(len(dn.items), len(quality_inspections))
# cleanup

View File

@@ -570,7 +570,20 @@ def run_parallel_reposting():
riv_entries = get_repost_item_valuation_entries()
rq_jobs = frappe.get_all(
"RQ Job",
fields=["arguments"],
filters={
"status": ("like", "%started%"),
"job_name": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.execute_reposting_entry",
},
)
for row in riv_entries:
if rq_jobs:
if job_running_for_entry(row.name, rq_jobs):
continue
if row.based_on != "Item and Warehouse" or row.repost_only_accounting_ledgers:
execute_reposting_entry(row.name)
continue
@@ -719,3 +732,19 @@ def get_existing_reposting_only_gl_entries(reposting_reference):
reposting_map[key] = d.reposting_reference
return reposting_map
def job_running_for_entry(reposting_entry, rq_jobs):
for job in rq_jobs:
if not job.arguments:
continue
try:
job_args = json.loads(job.arguments)
except (TypeError, json.JSONDecodeError):
continue
if isinstance(job_args, dict) and job_args.get("kwargs", {}).get("name") == reposting_entry:
return True
return False

View File

@@ -107,6 +107,7 @@ class SerialandBatchBundle(Document):
self.autoname()
def validate(self):
self.validate_allow_to_set_serial_batch()
if self.docstatus == 1 and self.voucher_detail_no:
self.validate_voucher_detail_no()
@@ -143,6 +144,15 @@ class SerialandBatchBundle(Document):
self.calculate_qty_and_amount()
self.set_child_details()
def validate_allow_to_set_serial_batch(self):
if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
frappe.throw(
_(
"Please check the 'Enable Serial and Batch No for Item' checkbox in the {0} to make Serial and Batch Bundle for the item."
).format(get_link_to_form("Stock Settings", "Stock Settings")),
title=_("Serial and Batch No for Item Disabled"),
)
def validate_serial_no_status(self):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
invalid_serial_nos = frappe.get_all(
@@ -717,10 +727,13 @@ class SerialandBatchBundle(Document):
if rate is None and child_table in ["Delivery Note Item", "Sales Invoice Item"]:
rate = frappe.db.get_value(
"Packed Item",
self.voucher_detail_no,
{"parent_detail_docname": self.voucher_detail_no, "item_code": self.item_code},
"incoming_rate",
)
if rate is None:
rate = frappe.db.get_value("Packed Item", self.voucher_detail_no, "incoming_rate")
if rate is not None:
is_packed_item = True
@@ -787,6 +800,9 @@ class SerialandBatchBundle(Document):
if not self.voucher_detail_no or self.voucher_detail_no != row.name:
values_to_set["voucher_detail_no"] = row.name
if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"):
values_to_set["voucher_detail_no"] = row.get("parent_detail_docname")
if parent.get("posting_date") and parent.get("posting_time"):
posting_datetime = combine_datetime(parent.posting_date, parent.posting_time)
if not self.posting_datetime or self.posting_datetime != posting_datetime:
@@ -1325,7 +1341,21 @@ class SerialandBatchBundle(Document):
)
if not vouchers and self.voucher_type == "Delivery Note":
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
if frappe.db.exists("Packed Item", self.voucher_detail_no):
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
else:
packed_items = frappe.get_all(
"Packed Item",
filters={
"parent_detail_docname": self.voucher_detail_no,
"serial_and_batch_bundle": self.name,
},
pluck="name",
)
for packed_item in packed_items:
frappe.db.set_value("Packed Item", packed_item, "serial_and_batch_bundle", None)
return
for voucher in vouchers:

View File

@@ -245,6 +245,7 @@ frappe.ui.form.on("Stock Entry", {
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields");
erpnext.toggle_serial_batch_fields(frm);
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
frm.trigger("validate_purpose_consumption");
@@ -930,10 +931,6 @@ frappe.ui.form.on("Stock Entry Detail", {
);
},
qty(frm, cdt, cdn) {
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
},
conversion_factor(frm, cdt, cdn) {
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
},

View File

@@ -236,6 +236,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_uom_is_integer("uom", "qty")
self.validate_uom_is_integer("stock_uom", "transfer_qty")
self.validate_warehouse()
self.validate_warehouse_of_sabb()
self.validate_work_order()
self.validate_bom()
self.set_process_loss_qty()

View File

@@ -2415,6 +2415,54 @@ class TestStockEntry(IntegrationTestCase):
frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit()
frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit()
def test_qi_creation_with_naming_rule_company_condition(self):
"""
Unit test case to check the document naming rule with company condition
For Quality Inspection, when created from Stock Entry.
"""
from erpnext.accounts.report.trial_balance.test_trial_balance import create_company
from erpnext.controllers.stock_controller import make_quality_inspections
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# create a separate company to handle document naming rule with company condition
qc_company = create_company(company_name="Test Quality Company")
# create document naming rule based on that for Quality Inspection Doctype
qc_naming_rule = frappe.new_doc(
"Document Naming Rule", document_type="Quality Inspection", prefix="NQC.-ST-", prefix_digits=5
)
qc_naming_rule.append("conditions", {"field": "company", "condition": "=", "value": qc_company})
qc_naming_rule.save()
warehouse = create_warehouse(warehouse_name="Test QI Warehouse", company=qc_company)
item = create_item(
item_code="Test QI DNR Item",
is_stock_item=1,
)
# create inward stock entry
stock_entry = make_stock_entry(
item_code=item.item_code,
target=warehouse,
qty=10,
basic_rate=100,
inspection_required=True,
do_not_submit=True,
)
# create QI from Stock Entry and check the naming series generated.
qi = make_quality_inspections(
stock_entry.company,
stock_entry.doctype,
stock_entry.name,
stock_entry.as_dict().get("items"),
"Incoming",
)
self.assertEqual(qi[0], "NQC-ST-00001")
# delete naming rule
frappe.delete_doc("Document Naming Rule", qc_naming_rule.name)
def make_serialized_item(self, **args):
args = frappe._dict(args)

View File

@@ -498,7 +498,8 @@
"fieldtype": "Link",
"label": "Reference Purchase Receipt",
"options": "Purchase Receipt",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "project",
@@ -660,7 +661,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-16 11:50:50.573443",
"modified": "2026-03-02 14:05:23.116017",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -76,6 +76,8 @@ frappe.ui.form.on("Stock Reconciliation", {
},
refresh: function (frm) {
erpnext.toggle_serial_batch_fields(frm);
if (frm.doc.docstatus < 1) {
frm.add_custom_button(__("Fetch Items from Warehouse"), function () {
frm.events.get_items(frm);

View File

@@ -523,9 +523,9 @@ class StockReconciliation(StockController):
if abs(difference_amount) > 0:
return True
float_precision = frappe.db.get_default("float_precision") or 3
item_dict["rate"] = flt(item_dict.get("rate"), float_precision)
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None
rate_precision = item.precision("valuation_rate")
item_dict["rate"] = flt(item_dict.get("rate"), rate_precision)
item.valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None
if (
(item.qty is None or item.qty == item_dict.get("qty"))
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))

View File

@@ -38,6 +38,7 @@
"allow_internal_transfer_at_arms_length_price",
"validate_material_transfer_warehouses",
"serial_and_batch_item_settings_tab",
"enable_serial_and_batch_no_for_item",
"section_break_7",
"allow_existing_serial_no",
"do_not_use_batchwise_valuation",
@@ -48,9 +49,8 @@
"use_serial_batch_fields",
"do_not_update_serial_batch_on_creation_of_auto_bundle",
"allow_negative_stock_for_batch",
"serial_and_batch_bundle_section",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
"section_break_gnhq",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
"use_naming_series",
"column_break_wslv",
"naming_series_prefix",
@@ -158,6 +158,7 @@
"label": "Convert Item Description to Clean HTML in Transactions"
},
{
"depends_on": "enable_serial_and_batch_no_for_item",
"fieldname": "section_break_7",
"fieldtype": "Section Break",
"label": "Serial & Batch Item Settings"
@@ -487,11 +488,6 @@
"fieldtype": "Check",
"label": "Auto Reserve Stock"
},
{
"fieldname": "serial_and_batch_bundle_section",
"fieldtype": "Section Break",
"label": "Serial and Batch Bundle"
},
{
"default": "0",
"fieldname": "set_serial_and_batch_bundle_naming_based_on_naming_series",
@@ -499,6 +495,7 @@
"label": "Set Serial and Batch Bundle Naming Based on Naming Series"
},
{
"depends_on": "enable_serial_and_batch_no_for_item",
"fieldname": "section_break_gnhq",
"fieldtype": "Section Break"
},
@@ -554,6 +551,11 @@
"fieldname": "allow_negative_stock_for_batch",
"fieldtype": "Check",
"label": "Allow Negative Stock for Batch"
},
{
"fieldname": "enable_serial_and_batch_no_for_item",
"fieldtype": "Check",
"label": "Enable Serial / Batch No for Item"
}
],
"hide_toolbar": 1,
@@ -562,7 +564,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-25 09:56:34.105949",
"modified": "2026-02-25 10:56:34.105949",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -47,6 +47,7 @@ class StockSettings(Document):
disable_serial_no_and_batch_selector: DF.Check
do_not_update_serial_batch_on_creation_of_auto_bundle: DF.Check
do_not_use_batchwise_valuation: DF.Check
enable_serial_and_batch_no_for_item: DF.Check
enable_stock_reservation: DF.Check
item_group: DF.Link | None
item_naming_by: DF.Literal["Item Code", "Naming Series"]
@@ -82,6 +83,7 @@ class StockSettings(Document):
"default_warehouse",
"set_qty_in_transactions_based_on_serial_no_input",
"use_serial_batch_fields",
"enable_serial_and_batch_no_for_item",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
]:
frappe.db.set_default(key, self.get(key, ""))
@@ -104,6 +106,7 @@ class StockSettings(Document):
)
self.validate_warehouses()
self.validate_serial_and_batch_no_settings()
self.cant_change_valuation_method()
self.validate_clean_description_html()
self.validate_pending_reposts()
@@ -112,6 +115,25 @@ class StockSettings(Document):
self.change_precision_for_for_sales()
self.change_precision_for_purchase()
def validate_serial_and_batch_no_settings(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save:
return
if doc_before_save.enable_serial_and_batch_no_for_item == self.enable_serial_and_batch_no_for_item:
return
if (
doc_before_save.enable_serial_and_batch_no_for_item
and not self.enable_serial_and_batch_no_for_item
):
if frappe.get_all("Serial and Batch Bundle", filters={"docstatus": 1}, limit=1, pluck="name"):
frappe.throw(
_(
"Cannot disable Serial and Batch No for Item, as there are existing records for serial / batch."
)
)
def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
for field in warehouse_fields:

View File

@@ -8,7 +8,7 @@ from typing import Any, TypedDict
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce
from frappe.query_builder.functions import Coalesce, Count
from frappe.utils import add_days, cint, date_diff, flt, getdate
from frappe.utils.nestedset import get_descendants_of
@@ -165,6 +165,7 @@ class StockBalanceReport:
sle.serial_no,
sle.serial_and_batch_bundle,
sle.has_serial_no,
sle.voucher_detail_no,
item_table.item_group,
item_table.stock_uom,
item_table.item_name,
@@ -190,6 +191,8 @@ class StockBalanceReport:
if self.filters.get("show_stock_ageing_data"):
self.sle_entries = self.sle_query.run(as_dict=True)
self.prepare_stock_reco_voucher_wise_count()
# HACK: This is required to avoid causing db query in flt
_system_settings = frappe.get_cached_doc("System Settings")
with frappe.db.unbuffered_cursor():
@@ -207,6 +210,71 @@ class StockBalanceReport:
self.item_warehouse_map, self.float_precision, self.inventory_dimensions
)
def prepare_stock_reco_voucher_wise_count(self):
self.stock_reco_voucher_wise_count = frappe._dict()
doctype = frappe.qb.DocType("Stock Ledger Entry")
item = frappe.qb.DocType("Item")
query = (
frappe.qb.from_(doctype)
.inner_join(item)
.on(doctype.item_code == item.name)
.select(doctype.voucher_detail_no, Count(doctype.name).as_("count"))
.where(
(doctype.voucher_type == "Stock Reconciliation")
& (doctype.docstatus < 2)
& (doctype.is_cancelled == 0)
& (item.has_serial_no == 1)
)
.groupby(doctype.voucher_detail_no)
)
if items := self.filters.item_code:
if isinstance(items, str):
items = [items]
query = query.where(item.name.isin(items))
if self.filters.item_group:
childrens = []
childrens.append(self.filters.item_group)
if item_group_childrens := get_descendants_of(
"Item Group", self.filters.item_group, ignore_permissions=True
):
childrens.extend(item_group_childrens)
if childrens:
query = query.where(item.item_group.isin(childrens))
if warehouses := self.filters.get("warehouse"):
if isinstance(warehouses, str):
warehouses = [warehouses]
childrens = []
for warehouse in warehouses:
childrens.append(warehouse)
if warehouse_childrens := get_descendants_of("Warehouse", warehouse, ignore_permissions=True):
childrens.extend(warehouse_childrens)
if childrens:
query = query.where(doctype.warehouse.isin(childrens))
data = query.run(as_dict=True)
if not data:
return
for row in data:
if row.count != 1:
continue
sr_item = frappe.db.get_value(
"Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True
)
if sr_item.qty and sr_item.current_qty:
self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty
def prepare_new_data(self):
if self.filters.get("show_stock_ageing_data"):
self.filters["show_warehouse_wise_stock"] = True
@@ -283,9 +351,14 @@ class StockBalanceReport:
qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and (
not entry.batch_no and not entry.serial_no and not entry.serial_and_batch_bundle
not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle
):
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)
qty_dict.bal_qty = 0.0
qty_diff = flt(entry.actual_qty)
else:
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
else:
qty_diff = flt(entry.actual_qty)

View File

@@ -27,10 +27,23 @@ def execute(filters=None):
items = get_items(filters)
sl_entries = get_stock_ledger_entries(filters, items)
item_details = get_item_details(items, sl_entries, include_uom)
inv_dimension_key = []
inv_dimension_wise_value = get_inv_dimension_wise_value(filters)
if inv_dimension_wise_value:
for key in inv_dimension_wise_value:
value = inv_dimension_wise_value[key]
if isinstance(value, list):
inv_dimension_key.extend(value)
else:
inv_dimension_key.append(value)
if filters.get("batch_no"):
opening_row = get_opening_balance_from_batch(filters, columns, sl_entries)
elif inv_dimension_wise_value:
opening_row = get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value)
else:
opening_row = get_opening_balance(filters, columns, sl_entries)
opening_row = get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
bundle_details = {}
@@ -50,12 +63,16 @@ def execute(filters=None):
stock_value = opening_row.get("stock_value")
available_serial_nos = {}
inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters)
batch_balance_dict = frappe._dict({})
if actual_qty and filters.get("batch_no"):
batch_balance_dict[filters.batch_no] = [actual_qty, stock_value]
inv_dimension_wise_dict = frappe._dict({})
set_opening_row_for_inv_dimension(
inv_dimension_wise_dict, filters, inv_dimension_key=inv_dimension_key, opening_row=opening_row
)
for sle in sl_entries:
item_detail = item_details[sle.item_code]
@@ -64,7 +81,10 @@ def execute(filters=None):
data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict, filters))
continue
if filters.get("batch_no") or inventory_dimension_filters_applied:
if inv_dimension_key:
set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle)
if filters.get("batch_no"):
actual_qty += flt(sle.actual_qty, precision)
stock_value += sle.stock_value_difference
if sle.batch_no:
@@ -103,6 +123,50 @@ def execute(filters=None):
return columns, data
def set_opening_row_for_inv_dimension(
inv_dimension_wise_dict, filters, inv_dimension_key=None, opening_row=None
):
if (
not inv_dimension_key
or not opening_row
or not filters.get("item_code")
or not filters.get("warehouse")
):
return
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
return
if inv_dimension_key and opening_row and filters.get("item_code") and filters.get("warehouse"):
new_key = copy.deepcopy(inv_dimension_key)
new_key.extend([filters.item_code[0], filters.warehouse[0]])
opening_key = tuple(new_key)
inv_dimension_wise_dict[opening_key] = {
"qty_after_transaction": flt(opening_row.get("qty_after_transaction")),
"dimension_stock_value": flt(opening_row.get("stock_value")),
}
def set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle):
new_key = copy.deepcopy(inv_dimension_key)
new_key.extend([sle.item_code, sle.warehouse])
new_key = tuple(new_key)
if new_key not in inv_dimension_wise_dict:
inv_dimension_wise_dict[new_key] = {"qty_after_transaction": 0, "dimension_stock_value": 0}
inv_dimesion_value = inv_dimension_wise_dict[new_key]
inv_dimesion_value["qty_after_transaction"] += sle.actual_qty
inv_dimesion_value["dimension_stock_value"] += sle.stock_value_difference
sle.update(
{
"qty_after_transaction": inv_dimesion_value["qty_after_transaction"],
"stock_value": inv_dimesion_value["dimension_stock_value"],
}
)
def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filters):
segregated_entries = []
qty_before_transaction = sle.qty_after_transaction - sle.actual_qty
@@ -605,19 +669,26 @@ def get_opening_balance_from_batch(filters, columns, sl_entries):
}
def get_opening_balance(filters, columns, sl_entries):
def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=None):
if not (filters.item_code and filters.warehouse and filters.from_date):
return
from erpnext.stock.stock_ledger import get_previous_sle
project = None
if filters.get("project") and not frappe.get_all(
"Inventory Dimension", filters={"reference_document": "Project"}
):
project = filters.get("project")
last_entry = get_previous_sle(
{
"item_code": filters.item_code,
"warehouse_condition": get_warehouse_condition(filters.warehouse),
"posting_date": filters.from_date,
"posting_time": "00:00:00",
}
"project": project,
},
)
# check if any SLEs are actually Opening Stock Reconciliation
@@ -689,9 +760,75 @@ def get_item_group_condition(item_group, item_table=None):
where ig.lft >= {item_group_details.lft} and ig.rgt <= {item_group_details.rgt} and item.item_group = ig.name)"
def check_inventory_dimension_filters_applied(filters) -> bool:
def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
if not filters.item_code or not filters.warehouse or not filters.from_date:
return
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
return
sl_doctype = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sl_doctype)
.select(
sl_doctype.item_code,
sl_doctype.warehouse,
Sum(sl_doctype.actual_qty).as_("qty_after_transaction"),
Sum(sl_doctype.stock_value_difference).as_("stock_value"),
)
.where(
(sl_doctype.posting_date < filters.from_date)
& (sl_doctype.docstatus < 2)
& (sl_doctype.is_cancelled == 0)
)
)
if filters.get("item_code"):
if isinstance(filters.item_code, list | tuple):
query = query.where(sl_doctype.item_code.isin(filters.item_code))
else:
query = query.where(sl_doctype.item_code == filters.item_code)
if filters.get("warehouse"):
if isinstance(filters.warehouse, list | tuple):
query = query.where(sl_doctype.warehouse.isin(filters.warehouse))
else:
query = query.where(sl_doctype.warehouse == filters.warehouse)
for key, value in inv_dimension_wise_value.items():
if isinstance(value, list | tuple):
query = query.where(sl_doctype[key].isin(value))
else:
query = query.where(sl_doctype[key] == value)
opening_data = query.run(as_dict=True)
if opening_data:
return frappe._dict(
{
"item_code": _("'Opening'"),
"qty_after_transaction": opening_data[0].qty_after_transaction,
"stock_value": opening_data[0].stock_value,
"valuation_rate": flt(opening_data[0].stock_value)
/ flt(opening_data[0].qty_after_transaction)
if opening_data[0].qty_after_transaction
else 0,
}
)
return frappe._dict({})
def get_inv_dimension_wise_value(filters) -> list:
inv_dimension_key = frappe._dict({})
for dimension in get_inventory_dimensions():
if dimension.fieldname in filters and filters.get(dimension.fieldname):
return True
inv_dimension_key[dimension.fieldname] = filters.get(dimension.fieldname)
return False
if filters.get("project") and not frappe.get_all(
"Inventory Dimension", filters={"reference_document": "Project"}
):
inv_dimension_key["project"] = filters.get("project")
return inv_dimension_key

View File

@@ -21,7 +21,7 @@ frappe.query_reports["Stock Ledger Invariant Check"] = {
options: "Item",
get_query: function () {
return {
filters: { is_stock_item: 1, has_serial_no: 0 },
filters: { is_stock_item: 1 },
};
},
},

View File

@@ -401,6 +401,9 @@ class SerialBatchBundle:
def submit_serial_and_batch_bundle(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle)
if self.sle.voucher_detail_no and doc.voucher_detail_no != self.sle.voucher_detail_no:
doc.voucher_detail_no = self.sle.voucher_detail_no
self.validate_actual_qty(doc)
doc.flags.ignore_voucher_validation = True
@@ -460,6 +463,11 @@ class SerialBatchBundle:
if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0:
customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer")
if sle.voucher_type in ["Stock Entry"] and sle.actual_qty < 0:
purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose")
if purpose in ["Disassemble", "Material Receipt"]:
status = "Inactive"
sn_table = frappe.qb.DocType("Serial No")
query = (

View File

@@ -1305,7 +1305,7 @@ class update_entries_after:
else:
if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
ref_doctype = "Packed Item"
elif sle == "Subcontracting Receipt":
elif sle.voucher_type == "Subcontracting Receipt":
ref_doctype = "Subcontracting Receipt Supplied Item"
else:
ref_doctype = "Purchase Receipt Item Supplied"
@@ -1862,6 +1862,9 @@ def get_stock_ledger_entries(
if extra_cond:
conditions += f"{extra_cond}"
if previous_sle.get("project"):
conditions += " and project = %(project)s"
# nosemgrep
return frappe.db.sql(
"""

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
refresh: (frm) => {
frappe.dynamic_link = { doc: frm.doc, fieldname: "supplier", doctype: "Supplier" };
erpnext.toggle_serial_batch_fields(frm);
if (frm.doc.docstatus === 1) {
frm.add_custom_button(
__("Stock Ledger"),

View File

@@ -8,7 +8,7 @@
<form action="/search_help" style="display: flex;">
<input name='q' class='form-control' type='text'
style='max-width: 400px; display: inline-block; margin-right: 10px;'
value='{{ frappe.form_dict.q or ''}}'
value='{{ (frappe.form_dict.q or '') | e }}'
{% if not frappe.form_dict.q%}placeholder="{{ _("What do you need help with?") }}"{% endif %}>
<input type='submit'
class='btn btn-sm btn-light btn-search' value="{{ _("Search") }}">

View File

@@ -341,6 +341,7 @@ class TransactionBase(StatusUpdater):
args.update(
{
"posting_date": self.transaction_date,
"posting_time": self.transaction_time,
}
)
else:

View File

@@ -0,0 +1,102 @@
{
"app": "erpnext",
"creation": "2026-02-24 17:39:43.793115",
"docstatus": 0,
"doctype": "Workspace Sidebar",
"header_icon": "organization",
"idx": 1,
"items": [
{
"child": 0,
"collapsible": 1,
"icon": "organization",
"indent": 0,
"keep_closed": 0,
"label": "Company",
"link_to": "Company",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-text",
"indent": 0,
"keep_closed": 0,
"label": "Letter Head",
"link_to": "Letter Head",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "file-user",
"indent": 0,
"keep_closed": 0,
"label": "Department",
"link_to": "Department",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-user",
"indent": 0,
"keep_closed": 0,
"label": "Branch",
"link_to": "Branch",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "users",
"indent": 0,
"keep_closed": 0,
"label": "User",
"link_to": "User",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "user-round-check",
"indent": 0,
"keep_closed": 0,
"label": "Role Permissions",
"link_to": "permission-manager",
"link_type": "Page",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "mail",
"indent": 0,
"keep_closed": 0,
"label": "Email Account",
"link_to": "Email Account",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-02-24 18:08:00.796746",
"modified_by": "Administrator",
"module": "Setup",
"module_onboarding": "Organization Onboarding",
"name": "Organization",
"owner": "Administrator",
"standard": 1,
"title": "Organization"
}