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.model.document import Document
from frappe.utils.user import is_website_user from frappe.utils.user import is_website_user
__version__ = "16.3.0" __version__ = "16.7.3"
def get_default_company(user=None): 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", "description": "Payment Terms from orders will be fetched into the invoices as is",
"fieldname": "automatically_fetch_payment_terms", "fieldname": "automatically_fetch_payment_terms",
"fieldtype": "Check", "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 ", "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, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-04 17:15:38.609327", "modified": "2026-02-27 01:04:09.415288",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@@ -1065,8 +1065,12 @@ class PaymentEntry(AccountsController):
total_allocated_amount += flt(d.allocated_amount) total_allocated_amount += flt(d.allocated_amount)
base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d) base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d)
self.total_allocated_amount = abs(total_allocated_amount) self.total_allocated_amount = flt(
self.base_total_allocated_amount = abs(base_total_allocated_amount) 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): def set_unallocated_amount(self):
self.unallocated_amount = 0 self.unallocated_amount = 0

View File

@@ -4,19 +4,6 @@
frappe.ui.form.on("POS Closing Entry", { frappe.ui.form.on("POS Closing Entry", {
onload: async function (frm) { onload: async function (frm) {
frm.ignore_doctypes_on_cancel_all = ["POS Invoice Merge Log", "Sales Invoice"]; 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) { frm.set_query("pos_opening_entry", function (doc) {
return { filters: { status: "Open", docstatus: 1 } }; return { filters: { status: "Open", docstatus: 1 } };
}); });

View File

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

View File

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

View File

@@ -1451,6 +1451,9 @@ class SalesInvoice(SellingController):
return asset_qty_map return asset_qty_map
def process_asset_depreciation(self): 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): if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
self.depreciate_asset_on_sale() self.depreciate_asset_on_sale()
else: else:

View File

@@ -8,6 +8,8 @@ import frappe
from frappe import _ from frappe import _
from frappe.contacts.doctype.address.address import get_default_address from frappe.contacts.doctype.address.address import get_default_address
from frappe.model.document import Document 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 import cstr
from frappe.utils.nestedset import get_root_of from frappe.utils.nestedset import get_root_of
@@ -83,6 +85,8 @@ class TaxRule(Document):
frappe.throw(_("Tax Template is mandatory.")) frappe.throw(_("Tax Template is mandatory."))
def validate_filters(self): def validate_filters(self):
TaxRule = DocType("Tax Rule")
filters = { filters = {
"tax_type": self.tax_type, "tax_type": self.tax_type,
"customer": self.customer, "customer": self.customer,
@@ -105,33 +109,34 @@ class TaxRule(Document):
"company": self.company, "company": self.company,
} }
conds = "" query = (
for d in filters: frappe.qb.from_(TaxRule).select(TaxRule.name, TaxRule.priority).where(TaxRule.name != self.name)
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,
) )
if tax_rule: for field, value in filters.items():
if tax_rule[0].priority == self.priority: query = query.where(IfNull(TaxRule[field], "") == cstr(value))
frappe.throw(_("Tax Rule Conflicts with {0}").format(tax_rule[0].name), ConflictingTaxRule)
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() @frappe.whitelist()

View File

@@ -1,6 +1,6 @@
import frappe import frappe
from frappe.tests import IntegrationTestCase 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.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.accounts_payable.accounts_payable import execute from erpnext.accounts.report.accounts_payable.accounts_payable import execute
@@ -57,3 +57,66 @@ class TestAccountsPayable(AccountsTestMixin, IntegrationTestCase):
if not do_not_submit: if not do_not_submit:
pi = pi.submit() pi = pi.submit()
return pi 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,
): ):
self.customer = qb.DocType("Customer") self.customer = qb.DocType("Customer")
if self.filters.get("customer_group"): 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 = ( customers = (
qb.from_(self.customer) qb.from_(self.customer)
.select(self.customer.name) .select(self.customer.name)
@@ -1049,13 +1048,17 @@ class ReceivablePayableReport:
self.get_hierarchical_filters("Territory", "territory") self.get_hierarchical_filters("Territory", "territory")
if self.filters.get("payment_terms_template"): if self.filters.get("payment_terms_template"):
self.qb_selection_filter.append( customer_ptt = self.ple.party.isin(
self.ple.party.isin(
qb.from_(self.customer) qb.from_(self.customer)
.select(self.customer.name) .select(self.customer.name)
.where(self.customer.payment_terms == self.filters.get("payment_terms_template")) .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"): if self.filters.get("sales_partner"):
self.qb_selection_filter.append( self.qb_selection_filter.append(
@@ -1081,14 +1084,53 @@ class ReceivablePayableReport:
) )
if self.filters.get("payment_terms_template"): if self.filters.get("payment_terms_template"):
self.qb_selection_filter.append( supplier_ptt = self.ple.party.isin(
self.ple.party.isin(
qb.from_(supplier) qb.from_(supplier)
.select(supplier.name) .select(supplier.name)
.where(supplier.payment_terms == self.filters.get("supplier_group")) .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): def get_hierarchical_filters(self, doctype, key):
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) 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 [] self.err_journals = [x[0] for x in results] if results else []
def get_customer_group_with_children(customer_groups): def get_party_group_with_children(party, party_groups):
if not isinstance(customer_groups, list): if party not in ("Customer", "Supplier"):
customer_groups = [d.strip() for d in customer_groups.strip().split(",") if d] return []
all_customer_groups = [] group_dtype = f"{party} Group"
for d in customer_groups: if not isinstance(party_groups, list):
if frappe.db.exists("Customer Group", d): party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
lft, rgt = frappe.db.get_value("Customer Group", d, ["lft", "rgt"])
children = frappe.get_all("Customer Group", filters={"lft": [">=", lft], "rgt": ["<=", rgt]}) all_party_groups = []
all_customer_groups += [c.name for c in children] 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: 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: class InitSQLProceduresForAR:

View File

@@ -1139,3 +1139,66 @@ class TestAccountsReceivable(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(len(report[1]), 1) self.assertEqual(len(report[1]), 1)
row = report[1][0] row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding]) 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 import _
from frappe.utils import add_months, flt, formatdate 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.accounts.utils import get_fiscal_year
from erpnext.controllers.trends import get_period_date_ranges from erpnext.controllers.trends import get_period_date_ranges
@@ -13,6 +14,8 @@ def execute(filters=None):
if not filters: if not filters:
filters = {} filters = {}
validate_filters(filters)
columns = get_columns(filters) columns = get_columns(filters)
if filters.get("budget_against_filter"): if filters.get("budget_against_filter"):
dimensions = 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 return columns, data, None, chart_data
def validate_filters(filters):
validate_budget_dimensions(filters)
def get_budget_records(filters, dimensions): def get_budget_records(filters, dimensions):
budget_against_field = frappe.scrub(filters["budget_against"]) budget_against_field = frappe.scrub(filters["budget_against"])
@@ -51,7 +58,7 @@ def get_budget_records(filters, dimensions):
b.company = %s b.company = %s
AND b.docstatus = 1 AND b.docstatus = 1
AND b.budget_against = %s 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 ( AND (
b.from_fiscal_year <= %s b.from_fiscal_year <= %s
AND b.to_fiscal_year >= %s AND b.to_fiscal_year >= %s
@@ -404,6 +411,17 @@ def get_budget_dimensions(filters):
) # nosec ) # 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): def build_comparison_chart_data(filters, columns, data):
if not data: if not data:
return None return None

View File

@@ -86,6 +86,12 @@ frappe.query_reports["Consolidated Trial Balance"] = {
fieldtype: "Check", fieldtype: "Check",
default: 1, default: 1,
}, },
{
fieldname: "show_net_values",
label: __("Show net values in opening and closing columns"),
fieldtype: "Check",
default: 1,
},
{ {
fieldname: "show_group_accounts", fieldname: "show_group_accounts",
label: __("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 ( from erpnext.accounts.report.trial_balance.trial_balance import (
accumulate_values_into_parents, accumulate_values_into_parents,
calculate_total_row,
calculate_values, calculate_values,
get_opening_balances, get_opening_balances,
hide_group_accounts, hide_group_accounts,
@@ -44,7 +45,6 @@ def execute(filters: dict | None = None):
def validate_filters(filters): def validate_filters(filters):
validate_companies(filters) validate_companies(filters)
filters.show_net_values = True
tb_validate_filters(filters) 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) tb_data = get_company_wise_tb_data(company_filter, reporting_currency, ignore_reporting_currency)
consolidate_trial_balance_data(data, tb_data) consolidate_trial_balance_data(data, tb_data)
for d in data: if filters.get("show_net_values"):
prepare_opening_closing(d) prepare_opening_closing_for_ctb(data)
total_row = calculate_total_row(data, reporting_currency)
data.extend([{}, total_row])
if not filters.get("show_group_accounts"): if not filters.get("show_group_accounts"):
data = hide_group_accounts(data) 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"): if filters.get("presentation_currency"):
update_to_presentation_currency( update_to_presentation_currency(
data, data,
@@ -207,10 +211,6 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
data = [] data = []
for d in accounts: 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 has_value = False
row = { row = {
"account": d.name, "account": d.name,
@@ -242,35 +242,9 @@ def prepare_companywise_tb_data(accounts, filters, parent_children_map, reportin
return data return data
def calculate_total_row(data, reporting_currency): def calculate_foreign_currency_translation_reserve(total_row, data, filters):
total_row = { if not data or not total_row:
"account": "'" + _("Total") + "'", return
"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):
opening_dr_cr_diff = total_row["opening_debit"] - total_row["opening_credit"] opening_dr_cr_diff = total_row["opening_debit"] - total_row["opening_credit"]
dr_cr_diff = total_row["debit"] - total_row["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"), "root_type": data[idx].get("root_type"),
"account_type": "Equity", "account_type": "Equity",
"parent_account": data[idx].get("account"), "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, "has_value": True,
"currency": total_row.get("currency"), "currency": total_row.get("currency"),
} }
@@ -297,6 +271,7 @@ def calculate_foreign_currency_translation_reserve(total_row, data):
fctr_row["closing_debit"] = fctr_row["opening_debit"] + fctr_row["debit"] fctr_row["closing_debit"] = fctr_row["opening_debit"] + fctr_row["debit"]
fctr_row["closing_credit"] = fctr_row["opening_credit"] + fctr_row["credit"] fctr_row["closing_credit"] = fctr_row["opening_credit"] + fctr_row["credit"]
if filters.get("show_net_values"):
prepare_opening_closing(fctr_row) prepare_opening_closing(fctr_row)
data.insert(idx + 1, 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) d.update(currency=to_currency)
def prepare_opening_closing_for_ctb(data):
for d in data:
prepare_opening_closing(d)
def get_columns(): def get_columns():
return [ return [
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
"order_confirmation_date", "order_confirmation_date",
"column_break_7", "column_break_7",
"transaction_date", "transaction_date",
"transaction_time",
"schedule_date", "schedule_date",
"column_break1", "column_break1",
"company", "company",
@@ -1311,6 +1312,14 @@
{ {
"fieldname": "section_break_tnkm", "fieldname": "section_break_tnkm",
"fieldtype": "Section Break" "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, "grid_page_length": 50,
@@ -1318,7 +1327,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-23 14:22:33.323946", "modified": "2026-03-02 00:40:47.119584",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order", "name": "Purchase Order",

View File

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

View File

@@ -250,10 +250,17 @@ frappe.ui.form.on("Request for Quotation", {
"subject", "subject",
]) ])
.then((r) => { .then((r) => {
frm.set_value( if (r.message.use_html) {
"message_for_supplier", frm.set_value({
r.message.use_html ? r.message.response_html : r.message.response 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); frm.set_value("subject", r.message.subject);
}); });
} }

View File

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

View File

@@ -47,7 +47,8 @@ class RequestforQuotation(BuyingController):
incoterm: DF.Link | None incoterm: DF.Link | None
items: DF.Table[RequestforQuotationItem] items: DF.Table[RequestforQuotationItem]
letter_head: DF.Link | None 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 named_place: DF.Data | None
naming_series: DF.Literal["PUR-RFQ-.YYYY.-"] naming_series: DF.Literal["PUR-RFQ-.YYYY.-"]
opportunity: DF.Link | None opportunity: DF.Link | None
@@ -61,6 +62,7 @@ class RequestforQuotation(BuyingController):
tc_name: DF.Link | None tc_name: DF.Link | None
terms: DF.TextEditor | None terms: DF.TextEditor | None
transaction_date: DF.Date transaction_date: DF.Date
use_html: DF.Check
vendor: DF.Link | None vendor: DF.Link | None
# end: auto-generated types # end: auto-generated types
@@ -100,8 +102,16 @@ class RequestforQuotation(BuyingController):
["use_html", "response", "response_html", "subject"], ["use_html", "response", "response_html", "subject"],
as_dict=True, as_dict=True,
) )
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: if not self.message_for_supplier:
self.message_for_supplier = data.response_html if data.use_html else data.response self.message_for_supplier = data.response
if not self.subject: if not self.subject:
self.subject = data.subject self.subject = data.subject
@@ -304,7 +314,10 @@ class RequestforQuotation(BuyingController):
else: else:
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None 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 = ( subject_source = (
self.subject self.subject
or frappe.get_value("Email Template", self.email_template, "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].rate, 300)
self.assertEqual(sq.get("items")[1].description, "test") 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 = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit() sq.submit()
trans_item = json.dumps( 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( self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name 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, "cost_center": po.cost_center,
"project": po.project, "project": po.project,
"requesting_site": po.warehouse, "requesting_site": po.warehouse,
"requestor": po.owner, "requestor": mr_record.get("owner", po.owner),
"material_request_no": po.material_request, "material_request_no": po.material_request,
"item_code": po.item_code, "item_code": po.item_code,
"quantity": flt(po.qty), "quantity": flt(po.qty),

View File

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

View File

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

View File

@@ -333,9 +333,10 @@ class SellingController(StockController):
if is_internal_customer or not is_stock_item: if is_internal_customer or not is_stock_item:
continue 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( 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( throw_message(

View File

@@ -63,6 +63,8 @@ class StockController(AccountsController):
if not self.get("is_return"): if not self.get("is_return"):
self.validate_inspection() self.validate_inspection()
self.validate_warehouse_of_sabb()
self.validate_serialized_batch() self.validate_serialized_batch()
self.clean_serial_nos() self.clean_serial_nos()
self.validate_customer_provided_item() self.validate_customer_provided_item()
@@ -75,6 +77,45 @@ class StockController(AccountsController):
super().on_update() super().on_update()
self.check_zero_rate() 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): def reset_conversion_factor(self):
for row in self.get("items"): for row in self.get("items"):
if row.uom != row.stock_uom: if row.uom != row.stock_uom:
@@ -2087,7 +2128,9 @@ def check_item_quality_inspection(doctype, items):
@frappe.whitelist() @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): if isinstance(items, str):
items = json.loads(items) items = json.loads(items)
@@ -2106,6 +2149,7 @@ def make_quality_inspections(doctype, docname, items, inspection_type):
quality_inspection = frappe.get_doc( quality_inspection = frappe.get_doc(
{ {
"company": company,
"doctype": "Quality Inspection", "doctype": "Quality Inspection",
"inspection_type": inspection_type, "inspection_type": inspection_type,
"inspected_by": frappe.session.user, "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"]); me.frm.set_query("contact_person", erpnext.queries["contact_query"]);
if (me.frm.doc.opportunity_from == "Lead") { if (me.frm.doc.opportunity_from == "Lead") {

View File

@@ -59,7 +59,9 @@ def create_prospect_against_crm_deal():
) )
pass pass
if doc.contacts and len(doc.contacts):
create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name) create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
create_address("Prospect", prospect_name, doc.address) create_address("Prospect", prospect_name, doc.address)
frappe.response["message"] = prospect_name 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"}, {"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 = [ standard_portal_menu_items = [
{"title": "Projects", "route": "/project", "reference_doctype": "Project", "role": "Customer"}, {"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): def is_work_order_closed(self):
if self.work_order: 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 True
return False return False

View File

@@ -225,7 +225,12 @@ class WorkOrder(Document):
frappe.throw(_("Actual End Date cannot be before Actual Start Date")) frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
def validate_fg_warehouse_for_reservation(self): 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( warehouses = frappe.get_all(
"Sales Order Item", "Sales Order Item",
filters={"parent": self.sales_order, "item_code": self.production_item}, filters={"parent": self.sales_order, "item_code": self.production_item},
@@ -413,39 +418,52 @@ class WorkOrder(Document):
) )
def validate_sales_order(self): def validate_sales_order(self):
if self.production_plan_sub_assembly_item:
return
if self.sales_order: if self.sales_order:
self.check_sales_order_on_hold_or_close() self.check_sales_order_on_hold_or_close()
so = frappe.db.sql(
""" SalesOrder = frappe.qb.DocType("Sales Order")
select so.name, so_item.delivery_date, so.project SalesOrderItem = frappe.qb.DocType("Sales Order Item")
from `tabSales Order` so PackedItem = frappe.qb.DocType("Packed Item")
inner join `tabSales Order Item` so_item on so_item.parent = so.name ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent
where so.name=%s and so.docstatus = 1 so = (
and so.skip_delivery_note = 0 and ( frappe.qb.from_(SalesOrder)
so_item.item_code=%s or .inner_join(SalesOrderItem)
pk_item.item_code=%s ) .on(SalesOrderItem.parent == SalesOrder.name)
""", .left_join(ProductBundleItem)
(self.sales_order, self.production_item, self.production_item), .on(ProductBundleItem.parent == SalesOrderItem.item_code)
as_dict=1, .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: if not so:
so = frappe.db.sql( so = (
""" frappe.qb.from_(SalesOrder)
select .inner_join(SalesOrderItem)
so.name, so_item.delivery_date, so.project .on(SalesOrderItem.parent == SalesOrder.name)
from .inner_join(PackedItem)
`tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item .on(PackedItem.parent == SalesOrder.name)
where so.name=%s .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
and so.name=so_item.parent .where(
and so.name=packed_item.parent (SalesOrder.name == self.sales_order)
and so.skip_delivery_note = 0 & (SalesOrder.skip_delivery_note == 0)
and so_item.item_code = packed_item.parent_item & (SalesOrderItem.item_code == PackedItem.parent_item)
and so.docstatus = 1 and packed_item.item_code=%s & (SalesOrder.docstatus == 1)
""", & (PackedItem.item_code == self.production_item)
(self.sales_order, self.production_item), )
as_dict=1, .run(as_dict=1)
) )
if len(so): 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 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) update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
if self.production_plan: if self.production_plan:
@@ -695,19 +713,25 @@ class WorkOrder(Document):
self.db_set("disassembled_qty", self.disassembled_qty) self.db_set("disassembled_qty", self.disassembled_qty)
def get_transferred_or_manufactured_qty(self, purpose, fieldname): def get_transferred_or_manufactured_qty(self, purpose, fieldname):
table = frappe.qb.DocType("Stock Entry") parent = frappe.qb.DocType("Stock Entry")
query = frappe.qb.from_(table).where(
(table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose) 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": if purpose == "Manufacture":
query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty)) child = frappe.qb.DocType("Stock Entry Detail")
else: query = (
query = query.select(Sum(table.fg_completed_qty)) query.join(child)
.on(parent.name == child.parent)
query = query.where( .select(Sum(child.transfer_qty))
table.is_additional_transfer_entry == cint(fieldname == "additional_transferred_qty") .where(child.is_finished_item == 1)
) )
else:
query = query.select(Sum(parent.fg_completed_qty))
return flt(query.run()[0][0]) return flt(query.run()[0][0])
@@ -1159,7 +1183,7 @@ class WorkOrder(Document):
doc.db_set("status", doc.status) doc.db_set("status", doc.status)
def update_work_order_qty_in_so(self): 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 return
total_bundle_qty = 1 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.add_portal_redirects
erpnext.patches.v16_0.complete_onboarding_steps_for_older_sites #2 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.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) { collect_progress: function (frm) {
if (frm.doc.collect_progress && !frm.doc.subject) { 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", "project_name",
"status", "status",
"project_type", "project_type",
"is_active",
"percent_complete_method", "percent_complete_method",
"percent_complete",
"column_break_5", "column_break_5",
"project_template", "project_template",
"expected_start_date",
"expected_end_date",
"priority", "priority",
"department", "department",
"customer_details", "is_active",
"customer", "percent_complete",
"column_break_14",
"sales_order",
"users_section",
"users",
"copied_from",
"section_break0",
"notes",
"section_break_18", "section_break_18",
"expected_start_date",
"actual_start_date", "actual_start_date",
"actual_time", "actual_time",
"column_break_20", "column_break_20",
"expected_end_date",
"actual_end_date", "actual_end_date",
"costing_tab",
"project_details", "project_details",
"estimated_costing", "estimated_costing",
"total_costing_amount", "total_costing_amount",
@@ -50,7 +42,7 @@
"gross_margin", "gross_margin",
"column_break_37", "column_break_37",
"per_gross_margin", "per_gross_margin",
"monitor_progress", "monitor_progress_tab",
"collect_progress", "collect_progress",
"holiday_list", "holiday_list",
"frequency", "frequency",
@@ -63,7 +55,18 @@
"weekly_time_to_send", "weekly_time_to_send",
"column_break_45", "column_break_45",
"subject", "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": [ "fields": [
{ {
@@ -231,7 +234,7 @@
"collapsible": 1, "collapsible": 1,
"fieldname": "section_break_18", "fieldname": "section_break_18",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Start and End Dates" "label": "Timeline"
}, },
{ {
"fieldname": "actual_start_date", "fieldname": "actual_start_date",
@@ -258,7 +261,6 @@
"read_only": 1 "read_only": 1
}, },
{ {
"collapsible": 1,
"fieldname": "project_details", "fieldname": "project_details",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Costing and Billing", "label": "Costing and Billing",
@@ -329,7 +331,6 @@
"options": "Cost Center" "options": "Cost Center"
}, },
{ {
"collapsible": 1,
"fieldname": "margin", "fieldname": "margin",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Margin", "label": "Margin",
@@ -357,12 +358,6 @@
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
"read_only": 1 "read_only": 1
}, },
{
"collapsible": 1,
"fieldname": "monitor_progress",
"fieldtype": "Section Break",
"label": "Monitor Progress"
},
{ {
"default": "0", "default": "0",
"fieldname": "collect_progress", "fieldname": "collect_progress",
@@ -455,6 +450,27 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Subject", "label": "Subject",
"mandatory_depends_on": "collect_progress" "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", "icon": "fa fa-puzzle-piece",
@@ -462,7 +478,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"max_attachments": 4, "max_attachments": 4,
"modified": "2025-08-21 17:57:58.314809", "modified": "2026-03-04 11:09:55.253367",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Projects", "module": "Projects",
"name": "Project", "name": "Project",

View File

@@ -19,6 +19,13 @@ frappe.ui.form.on("Project Template", {
frappe.ui.form.on("Project Template Task", { frappe.ui.form.on("Project Template Task", {
task: function (frm, cdt, cdn) { task: function (frm, cdt, cdn) {
var row = locals[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) => { frappe.db.get_value("Task", row.task, "subject", (value) => {
row.subject = value.subject; row.subject = value.subject;
refresh_field("tasks"); refresh_field("tasks");

View File

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

View File

@@ -138,6 +138,8 @@ class Task(NestedSet):
def validate_status(self): def validate_status(self):
if self.is_template and self.status != "Template": if self.is_template and self.status != "Template":
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": if self.status != self.get_db_value("status") and self.status == "Completed":
for d in self.depends_on: for d in self.depends_on:
if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"): 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) { parent_project: function (frm) {
set_project_in_timelog(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", { frappe.ui.form.on("Timesheet Detail", {

View File

@@ -18,28 +18,29 @@
"column_break_3", "column_break_3",
"status", "status",
"parent_project", "parent_project",
"employee_detail",
"employee",
"employee_name",
"department",
"column_break_9",
"user", "user",
"start_date", "start_date",
"end_date", "end_date",
"employee_detail",
"employee",
"department",
"column_break_9",
"employee_name",
"section_break_5", "section_break_5",
"time_logs", "time_logs",
"working_hours", "working_hours",
"total_hours", "total_hours",
"billing_tab",
"billing_details", "billing_details",
"total_billable_hours", "total_billable_hours",
"total_billable_amount",
"total_costing_amount",
"base_total_billable_amount", "base_total_billable_amount",
"base_total_billed_amount",
"base_total_costing_amount", "base_total_costing_amount",
"column_break_10", "column_break_10",
"total_billed_hours", "total_billed_hours",
"total_billable_amount",
"total_billed_amount", "total_billed_amount",
"total_costing_amount", "base_total_billed_amount",
"per_billed", "per_billed",
"section_break_18", "section_break_18",
"note", "note",
@@ -176,7 +177,6 @@
"read_only": 1 "read_only": 1
}, },
{ {
"collapsible": 1,
"fieldname": "billing_details", "fieldname": "billing_details",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Billing Details", "label": "Billing Details",
@@ -304,13 +304,18 @@
"fieldname": "exchange_rate", "fieldname": "exchange_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Exchange Rate" "label": "Exchange Rate"
},
{
"fieldname": "billing_tab",
"fieldtype": "Tab Break",
"label": "Billing"
} }
], ],
"icon": "fa fa-clock-o", "icon": "fa fa-clock-o",
"idx": 1, "idx": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-12-19 13:48:23.453636", "modified": "2026-03-04 11:56:51.438298",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Projects", "module": "Projects",
"name": "Timesheet", "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(); this.validate_has_items();
erpnext.utils.view_serial_batch_nos(this.frm); erpnext.utils.view_serial_batch_nos(this.frm);
this.set_route_options_for_new_doc(); this.set_route_options_for_new_doc();
erpnext.toggle_serial_batch_fields(this.frm);
} }
set_route_options_for_new_doc() { set_route_options_for_new_doc() {
@@ -1307,6 +1308,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if (this.frm.doc.transaction_date) { if (this.frm.doc.transaction_date) {
this.frm.transaction_date = this.frm.doc.transaction_date; this.frm.transaction_date = this.frm.doc.transaction_date;
frappe.ui.form.trigger(this.frm.doc.doctype, "currency"); 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({ frappe.call({
method: "erpnext.controllers.stock_controller.make_quality_inspections", method: "erpnext.controllers.stock_controller.make_quality_inspections",
args: { args: {
company: me.frm.doc.company,
doctype: me.frm.doc.doctype, doctype: me.frm.doc.doctype,
docname: me.frm.doc.name, docname: me.frm.doc.name,
items: selected_data, items: selected_data,

View File

@@ -19,6 +19,71 @@ $.extend(erpnext, {
return currency_list; 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 () { toggle_naming_series: function () {
if ( if (
cur_frm && cur_frm &&

View File

@@ -7,7 +7,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.mapper import get_mapped_doc 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 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 child_filter = d.name in filtered_items if filtered_items else True
return child_filter return child_filter
automatically_fetch_payment_terms = cint(
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
doclist = get_mapped_doc( doclist = get_mapped_doc(
"Quotation", "Quotation",
source_name, source_name,
@@ -453,6 +457,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
"Quotation": { "Quotation": {
"doctype": "Sales Order", "doctype": "Sales Order",
"validation": {"docstatus": ["=", 1]}, "validation": {"docstatus": ["=", 1]},
"field_no_map": ["payment_terms_template"],
}, },
"Quotation Item": { "Quotation Item": {
"doctype": "Sales Order 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 Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
"Payment Schedule": {"doctype": "Payment Schedule", "add_if_empty": True},
}, },
target_doc, target_doc,
set_missing_values, set_missing_values,
ignore_permissions=ignore_permissions, ignore_permissions=ignore_permissions,
) )
if automatically_fetch_payment_terms:
doclist.set_payment_schedule()
return doclist return doclist

View File

@@ -59,8 +59,22 @@ class TestQuotation(IntegrationTestCase):
qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2) qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2)
self.assertRaises(frappe.ValidationError, qo.save) self.assertRaises(frappe.ValidationError, qo.save)
def test_update_child_disallow_rate_change(self): def test_update_child_rate_change(self):
qo = make_quotation(qty=4) 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( trans_item = json.dumps(
[ [
{ {
@@ -68,10 +82,35 @@ class TestQuotation(IntegrationTestCase):
"rate": 5000, "rate": 5000,
"qty": qo.items[0].qty, "qty": qo.items[0].qty,
"docname": qo.items[0].name, "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) 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): def test_update_child_removing_item(self):
qo = make_quotation(qty=10) qo = make_quotation(qty=10)
@@ -143,6 +182,10 @@ class TestQuotation(IntegrationTestCase):
self.assertTrue(quotation.payment_schedule) self.assertTrue(quotation.payment_schedule)
@IntegrationTestCase.change_settings(
"Accounts Settings",
{"automatically_fetch_payment_terms": 1},
)
def test_make_sales_order_terms_copied(self): def test_make_sales_order_terms_copied(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.selling.doctype.quotation.quotation import make_sales_order
@@ -285,7 +328,11 @@ class TestQuotation(IntegrationTestCase):
@IntegrationTestCase.change_settings( @IntegrationTestCase.change_settings(
"Accounts 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): def test_make_sales_order_with_terms(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.selling.doctype.quotation.quotation import make_sales_order
@@ -323,10 +370,13 @@ class TestQuotation(IntegrationTestCase):
sales_order.save() sales_order.save()
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00) 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].payment_amount, 8906.00)
self.assertEqual( 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): def test_valid_till_before_transaction_date(self):
@@ -1026,6 +1076,56 @@ class TestQuotation(IntegrationTestCase):
quotation.reload() quotation.reload()
self.assertEqual(quotation.status, "Open") 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): def enable_calculate_bundle_price(enable=1):
selling_settings = frappe.get_doc("Selling Settings") selling_settings = frappe.get_doc("Selling Settings")

View File

@@ -18,6 +18,7 @@
"column_break_7", "column_break_7",
"order_type", "order_type",
"transaction_date", "transaction_date",
"transaction_time",
"delivery_date", "delivery_date",
"column_break1", "column_break1",
"tax_id", "tax_id",
@@ -122,6 +123,7 @@
"company_contact_person", "company_contact_person",
"payment_schedule_section", "payment_schedule_section",
"payment_terms_section", "payment_terms_section",
"ignore_default_payment_terms_template",
"payment_terms_template", "payment_terms_template",
"payment_schedule", "payment_schedule",
"terms_section_break", "terms_section_break",
@@ -1724,6 +1726,22 @@
"fieldname": "utm_analytics_section", "fieldname": "utm_analytics_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "UTM Analytics" "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, "grid_page_length": 50,
@@ -1731,7 +1749,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-10 11:55:52.796522", "modified": "2026-03-04 18:04:05.873483",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order", "name": "Sales Order",

View File

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

View File

@@ -2647,6 +2647,49 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
si2 = make_sales_invoice(so.name) si2 = make_sales_invoice(so.name)
self.assertEqual(si2.items[0].qty, 20) 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): def compare_payment_schedules(doc, doc1, doc2):
for index, schedule in enumerate(doc1.get("payment_schedule")): for index, schedule in enumerate(doc1.get("payment_schedule")):

View File

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

View File

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

View File

@@ -329,7 +329,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-12 10:38:34.605126", "modified": "2026-02-27 00:47:46.003305",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Selling Settings", "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_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", "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): def insert_record(records):
from frappe.desk.page.setup_wizard.setup_wizard import make_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) { refresh: function (frm) {
frm.trigger("toggle_has_serial_batch_fields");
if (frm.doc.is_stock_item) { if (frm.doc.is_stock_item) {
frm.add_custom_button( frm.add_custom_button(
__("Stock Balance"), __("Stock Balance"),

View File

@@ -452,6 +452,7 @@
"fieldname": "batch_number_series", "fieldname": "batch_number_series",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Batch Number Series", "label": "Batch Number Series",
"show_description_on_click": 1,
"translatable": 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.", "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", "fieldname": "serial_no_series",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Serial Number Series" "label": "Serial Number Series",
"show_description_on_click": 1
}, },
{ {
"collapsible": 1, "collapsible": 1,
@@ -985,7 +987,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2026-02-05 17:20:35.605734", "modified": "2026-03-05 16:29:31.653447",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -218,6 +218,7 @@ class Item(Document):
self.validate_auto_reorder_enabled_in_stock_settings() self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change() self.cant_change()
self.validate_item_tax_net_rate_range() self.validate_item_tax_net_rate_range()
self.validate_allow_to_set_serial_batch()
if not self.is_new(): if not self.is_new():
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") 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_variants()
self.update_item_price() 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): def validate_description(self):
"""Clean HTML description if set""" """Clean HTML description if set"""
if ( 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 from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
class IncorrectCompanyValidationError(frappe.ValidationError):
pass
class LandedCostVoucher(Document): class LandedCostVoucher(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -75,6 +79,7 @@ class LandedCostVoucher(Document):
self.check_mandatory() self.check_mandatory()
self.validate_receipt_documents() self.validate_receipt_documents()
self.validate_line_items() self.validate_line_items()
self.validate_expense_accounts()
init_landed_taxes_and_totals(self) init_landed_taxes_and_totals(self)
self.set_total_taxes_and_charges() self.set_total_taxes_and_charges()
if not self.get("items"): if not self.get("items"):
@@ -116,11 +121,28 @@ class LandedCostVoucher(Document):
receipt_documents = [] receipt_documents = []
for d in self.get("purchase_receipts"): 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: if docstatus != 1:
msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted" msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted"
frappe.throw(_(msg), title=_("Invalid Document")) 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": if d.receipt_document_type == "Purchase Invoice":
update_stock = frappe.db.get_value( update_stock = frappe.db.get_value(
d.receipt_document_type, d.receipt_document, "update_stock" 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) _("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): def set_total_taxes_and_charges(self):
self.total_taxes_and_charges = sum(flt(d.base_amount) for d in self.get("taxes")) 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.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) 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): def test_landed_cost_voucher_for_zero_purchase_rate(self):
"Test impact of LCV on future stock balances." "Test impact of LCV on future stock balances."
from erpnext.stock.doctype.item.test_item import make_item 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 = frappe.new_doc("Landed Cost Voucher")
lcv.company = args.company or "_Test Company" lcv.company = args.company or "_Test Company"
lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount" lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount"
expense_account = get_expense_account(args.company or "_Test Company")
lcv.set( lcv.set(
"purchase_receipts", "purchase_receipts",
@@ -1280,7 +1314,7 @@ def make_landed_cost_voucher(**args):
[ [
{ {
"description": "Shipping Charges", "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, "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 = frappe.new_doc("Landed Cost Voucher")
lcv.company = company lcv.company = company
lcv.distribute_charges_based_on = "Amount" lcv.distribute_charges_based_on = "Amount"
expense_account = get_expense_account(company)
lcv.set( lcv.set(
"purchase_receipts", "purchase_receipts",
@@ -1319,7 +1354,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
[ [
{ {
"description": "Insurance Charges", "description": "Insurance Charges",
"expense_account": "Expenses Included In Valuation - TCP1", "expense_account": expense_account,
"amount": charges, "amount": charges,
} }
], ],
@@ -1334,6 +1369,11 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company,
return lcv 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): def distribute_landed_cost_on_items(lcv):
based_on = lcv.distribute_charges_based_on.lower() based_on = lcv.distribute_charges_based_on.lower()
total = sum(flt(d.get(based_on)) for d in lcv.get("items")) 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", "source_dt": "Material Request Item",
"target_dt": "Sales Order Item", "target_dt": "Sales Order Item",
"target_field": "ordered_qty", "target_field": "requested_qty",
"target_parent_dt": "Sales Order", "target_parent_dt": "Sales Order",
"target_parent_field": "", "target_parent_field": "",
"join_field": "sales_order_item", "join_field": "sales_order_item",
@@ -280,6 +280,8 @@ class MaterialRequest(BuyingController):
def on_cancel(self): def on_cancel(self):
self.update_requested_qty_in_production_plan(cancel=True) self.update_requested_qty_in_production_plan(cancel=True)
self.update_requested_qty() self.update_requested_qty()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
def get_mr_items_ordered_qty(self, mr_items): def get_mr_items_ordered_qty(self, mr_items):
mr_items_ordered_qty = {} mr_items_ordered_qty = {}
@@ -330,7 +332,8 @@ class MaterialRequest(BuyingController):
if mr_qty_allowance: if mr_qty_allowance:
allowed_qty = flt( 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): 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) => { refresh: (frm) => {
frm.trigger("add_get_items_button"); frm.trigger("add_get_items_button");
frm.trigger("update_warehouse_property"); frm.trigger("update_warehouse_property");
erpnext.toggle_serial_batch_fields(frm);
if (frm.doc.docstatus === 1) { if (frm.doc.docstatus === 1) {
const status_completed = frm.doc.status === "Completed"; 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.item_code = location.item_code
item.s_warehouse = location.warehouse item.s_warehouse = location.warehouse
item.transfer_qty = location.picked_qty 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.uom = location.uom
item.conversion_factor = location.conversion_factor item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom 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): def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False):
# Update Billing % based on pending accepted qty # Update Billing % based on pending accepted qty
buying_settings = frappe.get_single("Buying Settings") 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 total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) 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) item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif amount and item.billed_amt > amount: elif amount and item.billed_amt > amount:
per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100 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( frappe.throw(
_("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( _("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 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" inspection_type = "Outgoing"
for item in dn.items: for item in dn.items:
item.sample_size = item.qty 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)) self.assertEqual(len(dn.items), len(quality_inspections))
# cleanup # cleanup

View File

@@ -570,7 +570,20 @@ def run_parallel_reposting():
riv_entries = get_repost_item_valuation_entries() 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: 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: if row.based_on != "Item and Warehouse" or row.repost_only_accounting_ledgers:
execute_reposting_entry(row.name) execute_reposting_entry(row.name)
continue continue
@@ -719,3 +732,19 @@ def get_existing_reposting_only_gl_entries(reposting_reference):
reposting_map[key] = d.reposting_reference reposting_map[key] = d.reposting_reference
return reposting_map 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() self.autoname()
def validate(self): def validate(self):
self.validate_allow_to_set_serial_batch()
if self.docstatus == 1 and self.voucher_detail_no: if self.docstatus == 1 and self.voucher_detail_no:
self.validate_voucher_detail_no() self.validate_voucher_detail_no()
@@ -143,6 +144,15 @@ class SerialandBatchBundle(Document):
self.calculate_qty_and_amount() self.calculate_qty_and_amount()
self.set_child_details() 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): def validate_serial_no_status(self):
serial_nos = [d.serial_no for d in self.entries if d.serial_no] serial_nos = [d.serial_no for d in self.entries if d.serial_no]
invalid_serial_nos = frappe.get_all( 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"]: if rate is None and child_table in ["Delivery Note Item", "Sales Invoice Item"]:
rate = frappe.db.get_value( rate = frappe.db.get_value(
"Packed Item", "Packed Item",
self.voucher_detail_no, {"parent_detail_docname": self.voucher_detail_no, "item_code": self.item_code},
"incoming_rate", "incoming_rate",
) )
if rate is None:
rate = frappe.db.get_value("Packed Item", self.voucher_detail_no, "incoming_rate")
if rate is not None: if rate is not None:
is_packed_item = True is_packed_item = True
@@ -787,6 +800,9 @@ class SerialandBatchBundle(Document):
if not self.voucher_detail_no or self.voucher_detail_no != row.name: if not self.voucher_detail_no or self.voucher_detail_no != row.name:
values_to_set["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"): if parent.get("posting_date") and parent.get("posting_time"):
posting_datetime = combine_datetime(parent.posting_date, parent.posting_time) posting_datetime = combine_datetime(parent.posting_date, parent.posting_time)
if not self.posting_datetime or self.posting_datetime != posting_datetime: 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": if not vouchers and self.voucher_type == "Delivery Note":
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) 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 return
for voucher in vouchers: for voucher in vouchers:

View File

@@ -245,6 +245,7 @@ frappe.ui.form.on("Stock Entry", {
refresh: function (frm) { refresh: function (frm) {
frm.trigger("get_items_from_transit_entry"); frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields"); frm.trigger("toggle_warehouse_fields");
erpnext.toggle_serial_batch_fields(frm);
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) { if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
frm.trigger("validate_purpose_consumption"); 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) { conversion_factor(frm, cdt, cdn) {
frm.events.set_rate_and_fg_qty(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("uom", "qty")
self.validate_uom_is_integer("stock_uom", "transfer_qty") self.validate_uom_is_integer("stock_uom", "transfer_qty")
self.validate_warehouse() self.validate_warehouse()
self.validate_warehouse_of_sabb()
self.validate_work_order() self.validate_work_order()
self.validate_bom() self.validate_bom()
self.set_process_loss_qty() 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, "Material Consumption for Manufacture", 5)).submit()
frappe.get_doc(_make_stock_entry(work_order.name, "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): def make_serialized_item(self, **args):
args = frappe._dict(args) args = frappe._dict(args)

View File

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

View File

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

View File

@@ -523,9 +523,9 @@ class StockReconciliation(StockController):
if abs(difference_amount) > 0: if abs(difference_amount) > 0:
return True return True
float_precision = frappe.db.get_default("float_precision") or 3 rate_precision = item.precision("valuation_rate")
item_dict["rate"] = flt(item_dict.get("rate"), float_precision) item_dict["rate"] = flt(item_dict.get("rate"), rate_precision)
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None item.valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None
if ( if (
(item.qty is None or item.qty == item_dict.get("qty")) (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")) 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", "allow_internal_transfer_at_arms_length_price",
"validate_material_transfer_warehouses", "validate_material_transfer_warehouses",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"enable_serial_and_batch_no_for_item",
"section_break_7", "section_break_7",
"allow_existing_serial_no", "allow_existing_serial_no",
"do_not_use_batchwise_valuation", "do_not_use_batchwise_valuation",
@@ -48,9 +49,8 @@
"use_serial_batch_fields", "use_serial_batch_fields",
"do_not_update_serial_batch_on_creation_of_auto_bundle", "do_not_update_serial_batch_on_creation_of_auto_bundle",
"allow_negative_stock_for_batch", "allow_negative_stock_for_batch",
"serial_and_batch_bundle_section",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
"section_break_gnhq", "section_break_gnhq",
"set_serial_and_batch_bundle_naming_based_on_naming_series",
"use_naming_series", "use_naming_series",
"column_break_wslv", "column_break_wslv",
"naming_series_prefix", "naming_series_prefix",
@@ -158,6 +158,7 @@
"label": "Convert Item Description to Clean HTML in Transactions" "label": "Convert Item Description to Clean HTML in Transactions"
}, },
{ {
"depends_on": "enable_serial_and_batch_no_for_item",
"fieldname": "section_break_7", "fieldname": "section_break_7",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Serial & Batch Item Settings" "label": "Serial & Batch Item Settings"
@@ -487,11 +488,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Auto Reserve Stock" "label": "Auto Reserve Stock"
}, },
{
"fieldname": "serial_and_batch_bundle_section",
"fieldtype": "Section Break",
"label": "Serial and Batch Bundle"
},
{ {
"default": "0", "default": "0",
"fieldname": "set_serial_and_batch_bundle_naming_based_on_naming_series", "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" "label": "Set Serial and Batch Bundle Naming Based on Naming Series"
}, },
{ {
"depends_on": "enable_serial_and_batch_no_for_item",
"fieldname": "section_break_gnhq", "fieldname": "section_break_gnhq",
"fieldtype": "Section Break" "fieldtype": "Section Break"
}, },
@@ -554,6 +551,11 @@
"fieldname": "allow_negative_stock_for_batch", "fieldname": "allow_negative_stock_for_batch",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Negative Stock for Batch" "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, "hide_toolbar": 1,
@@ -562,7 +564,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-25 09:56:34.105949", "modified": "2026-02-25 10:56:34.105949",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@@ -47,6 +47,7 @@ class StockSettings(Document):
disable_serial_no_and_batch_selector: DF.Check disable_serial_no_and_batch_selector: DF.Check
do_not_update_serial_batch_on_creation_of_auto_bundle: DF.Check do_not_update_serial_batch_on_creation_of_auto_bundle: DF.Check
do_not_use_batchwise_valuation: DF.Check do_not_use_batchwise_valuation: DF.Check
enable_serial_and_batch_no_for_item: DF.Check
enable_stock_reservation: DF.Check enable_stock_reservation: DF.Check
item_group: DF.Link | None item_group: DF.Link | None
item_naming_by: DF.Literal["Item Code", "Naming Series"] item_naming_by: DF.Literal["Item Code", "Naming Series"]
@@ -82,6 +83,7 @@ class StockSettings(Document):
"default_warehouse", "default_warehouse",
"set_qty_in_transactions_based_on_serial_no_input", "set_qty_in_transactions_based_on_serial_no_input",
"use_serial_batch_fields", "use_serial_batch_fields",
"enable_serial_and_batch_no_for_item",
"set_serial_and_batch_bundle_naming_based_on_naming_series", "set_serial_and_batch_bundle_naming_based_on_naming_series",
]: ]:
frappe.db.set_default(key, self.get(key, "")) frappe.db.set_default(key, self.get(key, ""))
@@ -104,6 +106,7 @@ class StockSettings(Document):
) )
self.validate_warehouses() self.validate_warehouses()
self.validate_serial_and_batch_no_settings()
self.cant_change_valuation_method() self.cant_change_valuation_method()
self.validate_clean_description_html() self.validate_clean_description_html()
self.validate_pending_reposts() self.validate_pending_reposts()
@@ -112,6 +115,25 @@ class StockSettings(Document):
self.change_precision_for_for_sales() self.change_precision_for_for_sales()
self.change_precision_for_purchase() 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): def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
for field in warehouse_fields: for field in warehouse_fields:

View File

@@ -8,7 +8,7 @@ from typing import Any, TypedDict
import frappe import frappe
from frappe import _ 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 import add_days, cint, date_diff, flt, getdate
from frappe.utils.nestedset import get_descendants_of from frappe.utils.nestedset import get_descendants_of
@@ -165,6 +165,7 @@ class StockBalanceReport:
sle.serial_no, sle.serial_no,
sle.serial_and_batch_bundle, sle.serial_and_batch_bundle,
sle.has_serial_no, sle.has_serial_no,
sle.voucher_detail_no,
item_table.item_group, item_table.item_group,
item_table.stock_uom, item_table.stock_uom,
item_table.item_name, item_table.item_name,
@@ -190,6 +191,8 @@ class StockBalanceReport:
if self.filters.get("show_stock_ageing_data"): if self.filters.get("show_stock_ageing_data"):
self.sle_entries = self.sle_query.run(as_dict=True) 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 # HACK: This is required to avoid causing db query in flt
_system_settings = frappe.get_cached_doc("System Settings") _system_settings = frappe.get_cached_doc("System Settings")
with frappe.db.unbuffered_cursor(): with frappe.db.unbuffered_cursor():
@@ -207,6 +210,71 @@ class StockBalanceReport:
self.item_warehouse_map, self.float_precision, self.inventory_dimensions 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): def prepare_new_data(self):
if self.filters.get("show_stock_ageing_data"): if self.filters.get("show_stock_ageing_data"):
self.filters["show_warehouse_wise_stock"] = True self.filters["show_warehouse_wise_stock"] = True
@@ -283,8 +351,13 @@ class StockBalanceReport:
qty_dict[field] = entry.get(field) qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and ( 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
): ):
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) qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
else: else:
qty_diff = flt(entry.actual_qty) qty_diff = flt(entry.actual_qty)

View File

@@ -27,10 +27,23 @@ def execute(filters=None):
items = get_items(filters) items = get_items(filters)
sl_entries = get_stock_ledger_entries(filters, items) sl_entries = get_stock_ledger_entries(filters, items)
item_details = get_item_details(items, sl_entries, include_uom) 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"): if filters.get("batch_no"):
opening_row = get_opening_balance_from_batch(filters, columns, sl_entries) 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: 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")) precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
bundle_details = {} bundle_details = {}
@@ -50,12 +63,16 @@ def execute(filters=None):
stock_value = opening_row.get("stock_value") stock_value = opening_row.get("stock_value")
available_serial_nos = {} available_serial_nos = {}
inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters)
batch_balance_dict = frappe._dict({}) batch_balance_dict = frappe._dict({})
if actual_qty and filters.get("batch_no"): if actual_qty and filters.get("batch_no"):
batch_balance_dict[filters.batch_no] = [actual_qty, stock_value] 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: for sle in sl_entries:
item_detail = item_details[sle.item_code] 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)) data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict, filters))
continue 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) actual_qty += flt(sle.actual_qty, precision)
stock_value += sle.stock_value_difference stock_value += sle.stock_value_difference
if sle.batch_no: if sle.batch_no:
@@ -103,6 +123,50 @@ def execute(filters=None):
return columns, data 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): def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filters):
segregated_entries = [] segregated_entries = []
qty_before_transaction = sle.qty_after_transaction - sle.actual_qty 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): if not (filters.item_code and filters.warehouse and filters.from_date):
return return
from erpnext.stock.stock_ledger import get_previous_sle 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( last_entry = get_previous_sle(
{ {
"item_code": filters.item_code, "item_code": filters.item_code,
"warehouse_condition": get_warehouse_condition(filters.warehouse), "warehouse_condition": get_warehouse_condition(filters.warehouse),
"posting_date": filters.from_date, "posting_date": filters.from_date,
"posting_time": "00:00:00", "posting_time": "00:00:00",
} "project": project,
},
) )
# check if any SLEs are actually Opening Stock Reconciliation # 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)" 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(): for dimension in get_inventory_dimensions():
if dimension.fieldname in filters and filters.get(dimension.fieldname): 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", options: "Item",
get_query: function () { get_query: function () {
return { 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): def submit_serial_and_batch_bundle(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle) 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) self.validate_actual_qty(doc)
doc.flags.ignore_voucher_validation = True 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: 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") 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") sn_table = frappe.qb.DocType("Serial No")
query = ( query = (

View File

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

View File

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

View File

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

View File

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