Merge pull request #53116 from frappe/version-16-hotfix

This commit is contained in:
diptanilsaha
2026-03-03 23:26:23 +05:30
committed by GitHub
63 changed files with 2293 additions and 984 deletions

View File

@@ -33,6 +33,12 @@ class FiscalYear(Document):
self.validate_dates()
self.validate_overlap()
def on_update(self):
frappe.cache().delete_key("fiscal_years")
def on_trash(self):
frappe.cache().delete_key("fiscal_years")
def validate_dates(self):
self.validate_from_to_dates("year_start_date", "year_end_date")
if self.is_short_year:

View File

@@ -303,10 +303,6 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
onload_post_render() {
this.frm.get_field("accounts").grid.set_multiple_add("account");
}
load_defaults() {
//this.frm.show_print_first = true;
if (this.frm.doc.__islocal && this.frm.doc.company) {

View File

@@ -10,18 +10,15 @@
"field_order": [
"entry_type_and_date",
"company",
"is_system_generated",
"title",
"voucher_type",
"naming_series",
"process_deferred_accounting",
"reversal_of",
"column_break1",
"from_template",
"naming_series",
"posting_date",
"finance_book",
"multi_currency",
"apply_tds",
"tax_withholding_category",
"is_system_generated",
"amended_from",
"section_break_tcvw",
"for_all_stock_asset_accounts",
"column_break_wpau",
@@ -30,52 +27,60 @@
"get_balance_for_periodic_accounting",
"2_add_edit_gl_entries",
"accounts",
"section_break99",
"cheque_no",
"cheque_date",
"user_remark",
"column_break99",
"section_break_ouaq",
"total_debit",
"column_break_cixu",
"total_credit",
"difference",
"get_balance",
"multi_currency",
"total_amount_currency",
"total_amount",
"total_amount_in_words",
"section_break99",
"cheque_no",
"cheque_date",
"clearance_date",
"column_break_oizh",
"user_remark",
"subscription_section",
"auto_repeat",
"tax_withholding_tab",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"more_info_tab",
"reference",
"clearance_date",
"remark",
"inter_company_journal_entry_reference",
"column_break98",
"bill_no",
"bill_date",
"due_date",
"column_break_isfa",
"inter_company_journal_entry_reference",
"process_deferred_accounting",
"reversal_of",
"payment_order",
"stock_entry",
"printing_settings",
"pay_to_recd_from",
"letter_head",
"select_print_heading",
"column_break_35",
"total_amount_currency",
"total_amount",
"total_amount_in_words",
"write_off",
"write_off_based_on",
"get_outstanding_invoices",
"column_break_30",
"write_off_amount",
"printing_settings",
"pay_to_recd_from",
"column_break_35",
"letter_head",
"select_print_heading",
"addtional_info",
"mode_of_payment",
"payment_order",
"party_not_required",
"column_break3",
"is_opening",
"stock_entry",
"subscription_section",
"auto_repeat",
"amended_from"
"finance_book",
"from_template",
"title",
"column_break3",
"remark",
"mode_of_payment",
"party_not_required"
],
"fields": [
{
@@ -155,6 +160,7 @@
{
"fieldname": "2_add_edit_gl_entries",
"fieldtype": "Section Break",
"hide_border": 1,
"oldfieldtype": "Section Break",
"options": "fa fa-table"
},
@@ -202,10 +208,6 @@
"oldfieldtype": "Small Text",
"print_hide": 1
},
{
"fieldname": "column_break99",
"fieldtype": "Column Break"
},
{
"fieldname": "total_debit",
"fieldtype": "Currency",
@@ -429,7 +431,7 @@
"collapsible": 1,
"fieldname": "addtional_info",
"fieldtype": "Section Break",
"label": "More Information",
"label": "Additional Info",
"oldfieldtype": "Section Break",
"options": "fa fa-file-text"
},
@@ -476,7 +478,7 @@
{
"fieldname": "subscription_section",
"fieldtype": "Section Break",
"label": "Subscription Section"
"label": "Subscription"
},
{
"allow_on_submit": 1,
@@ -593,12 +595,10 @@
"no_copy": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0",
"depends_on": "eval: doc.apply_tds",
"fieldname": "section_tax_withholding_entry",
"fieldtype": "Section Break",
"label": "Tax Withholding Entry"
"fieldtype": "Section Break"
},
{
"fieldname": "tax_withholding_group",
@@ -624,6 +624,33 @@
"label": "Tax Withholding Entries",
"options": "Tax Withholding Entry",
"read_only_depends_on": "eval: !doc.override_tax_withholding_entries"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
},
{
"fieldname": "section_break_ouaq",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_cixu",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_oizh",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_isfa",
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.apply_tds",
"fieldname": "tax_withholding_tab",
"fieldtype": "Tab Break",
"label": "Tax Withholding"
}
],
"icon": "fa fa-file-text",
@@ -638,7 +665,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2026-02-03 14:40:39.944524",
"modified": "2026-02-16 16:06:10.468482",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -43,7 +43,7 @@
"fields": [
{
"bold": 1,
"columns": 2,
"columns": 4,
"fieldname": "account",
"fieldtype": "Link",
"in_global_search": 1,
@@ -191,7 +191,6 @@
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
"no_copy": 1,
"options": "reference_type",
@@ -294,7 +293,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-19 17:01:22.642454",
"modified": "2026-02-16 16:04:16.022407",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",

View File

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

View File

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

View File

@@ -159,15 +159,16 @@
"language",
"column_break_84",
"select_print_heading",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_bhao",
"utm_campaign",
"more_information",
"inter_company_invoice_reference",
"customer_group",
"is_discounted",
"col_break23",
"utm_source",
"utm_campaign",
"utm_medium",
"column_break_gpiw",
"status",
"more_info",
"debit_to",
@@ -1541,10 +1542,6 @@
"fieldtype": "Check",
"label": "Update Billed Amount in Delivery Note"
},
{
"fieldname": "column_break_gpiw",
"fieldtype": "Column Break"
},
{
"fieldname": "utm_medium",
"fieldtype": "Link",
@@ -1612,12 +1609,22 @@
"no_copy": 1,
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
},
{
"fieldname": "column_break_bhao",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:20:51.376875",
"modified": "2026-02-10 14:23:07.181782",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -12,9 +12,6 @@
"disabled",
"column_break_9",
"warehouse",
"utm_source",
"utm_campaign",
"utm_medium",
"company_address",
"section_break_15",
"applicable_for_users",
@@ -61,7 +58,13 @@
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
"project",
"utm_analytics_section",
"utm_source",
"column_break_tvls",
"utm_campaign",
"column_break_xygw",
"utm_medium"
],
"fields": [
{
@@ -430,6 +433,20 @@
"fieldname": "allow_partial_payment",
"fieldtype": "Check",
"label": "Allow Partial Payment"
},
{
"fieldname": "column_break_tvls",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_xygw",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "Campaign"
}
],
"grid_page_length": 50,
@@ -458,7 +475,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2025-06-24 11:19:19.834905",
"modified": "2026-02-10 14:24:48.597412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

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

View File

@@ -219,16 +219,17 @@
"column_break_140",
"to_date",
"update_auto_repeat_reference",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_ixxw",
"utm_campaign",
"utm_content",
"more_information",
"status",
"remarks",
"customer_group",
"column_break_imbx",
"utm_source",
"utm_campaign",
"utm_medium",
"utm_content",
"col_break23",
"is_internal_customer",
"represents_company",
"inter_company_invoice_reference",
@@ -270,6 +271,7 @@
"oldfieldtype": "Link",
"options": "Customer",
"print_hide": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -798,8 +800,7 @@
"hide_seconds": 1,
"label": "Time Sheets",
"options": "Sales Invoice Timesheet",
"print_hide": 1,
"read_only": 1
"print_hide": 1
},
{
"default": "0",
@@ -1632,13 +1633,6 @@
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "col_break23",
"fieldtype": "Column Break",
"hide_days": 1,
"hide_seconds": 1,
"width": "50%"
},
{
"default": "Draft",
"fieldname": "status",
@@ -2313,6 +2307,16 @@
{
"fieldname": "column_break_rdks",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_ixxw",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
}
],
"grid_page_length": 50,
@@ -2326,7 +2330,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-02-23 14:29:00.301842",
"modified": "2026-02-28 17:58:56.453076",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -119,7 +119,7 @@ class SalesInvoice(SellingController):
cost_center: DF.Link | None
coupon_code: DF.Link | None
currency: DF.Link
customer: DF.Link | None
customer: DF.Link
customer_address: DF.Link | None
customer_group: DF.Link | None
customer_name: DF.SmallText | None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,13 +52,12 @@
"country",
"column_break2",
"contact_html",
"section_break_analytics",
"utm_analytics_section",
"utm_source",
"utm_content",
"utm_medium",
"column_break_gkxo",
"utm_campaign",
"column_break_gqka",
"utm_medium",
"utm_content",
"qualification_tab",
"qualification_status",
"column_break_64",
@@ -504,10 +503,6 @@
"fieldname": "column_break_gkxo",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_gqka",
"fieldtype": "Column Break"
},
{
"fieldname": "utm_content",
"fieldtype": "Data",
@@ -537,7 +532,8 @@
"options": "UTM Campaign"
},
{
"fieldname": "section_break_analytics",
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "Analytics"
}
@@ -547,7 +543,7 @@
"idx": 5,
"image_field": "image",
"links": [],
"modified": "2025-06-26 11:02:01.158901",
"modified": "2026-02-10 14:36:37.157961",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead",

View File

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

View File

@@ -44,13 +44,12 @@
"column_break_17",
"opportunity_amount",
"base_opportunity_amount",
"section_break_analytics",
"utm_analytics_section",
"utm_source",
"utm_content",
"utm_medium",
"column_break_emai",
"utm_campaign",
"column_break_whcu",
"utm_medium",
"utm_content",
"more_info",
"company",
"transaction_date",
@@ -629,15 +628,6 @@
"options": "UTM Campaign",
"print_hide": 1
},
{
"fieldname": "section_break_analytics",
"fieldtype": "Section Break",
"label": "Analytics"
},
{
"fieldname": "column_break_whcu",
"fieldtype": "Column Break"
},
{
"fieldname": "utm_medium",
"fieldtype": "Link",
@@ -650,13 +640,19 @@
"fieldtype": "Data",
"label": "Content",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "Analytics"
}
],
"grid_page_length": 50,
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
"modified": "2025-08-11 13:35:39.476016",
"modified": "2026-02-10 14:36:01.387984",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -225,7 +225,12 @@ class WorkOrder(Document):
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
def validate_fg_warehouse_for_reservation(self):
if self.reserve_stock and self.sales_order and not self.subcontracting_inward_order:
if (
self.reserve_stock
and self.sales_order
and not self.subcontracting_inward_order
and not self.production_plan_sub_assembly_item
):
warehouses = frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "item_code": self.production_item},
@@ -413,39 +418,52 @@ class WorkOrder(Document):
)
def validate_sales_order(self):
if self.production_plan_sub_assembly_item:
return
if self.sales_order:
self.check_sales_order_on_hold_or_close()
so = frappe.db.sql(
"""
select so.name, so_item.delivery_date, so.project
from `tabSales Order` so
inner join `tabSales Order Item` so_item on so_item.parent = so.name
left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent
where so.name=%s and so.docstatus = 1
and so.skip_delivery_note = 0 and (
so_item.item_code=%s or
pk_item.item_code=%s )
""",
(self.sales_order, self.production_item, self.production_item),
as_dict=1,
SalesOrder = frappe.qb.DocType("Sales Order")
SalesOrderItem = frappe.qb.DocType("Sales Order Item")
PackedItem = frappe.qb.DocType("Packed Item")
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
so = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
.left_join(ProductBundleItem)
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
.where(
(SalesOrder.skip_delivery_note == 0)
& (SalesOrder.docstatus == 1)
& (SalesOrder.name == self.sales_order)
& (
(SalesOrderItem.item_code == self.production_item)
| (ProductBundleItem.item_code == self.production_item)
)
)
.run(as_dict=1)
)
if not so:
so = frappe.db.sql(
"""
select
so.name, so_item.delivery_date, so.project
from
`tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item
where so.name=%s
and so.name=so_item.parent
and so.name=packed_item.parent
and so.skip_delivery_note = 0
and so_item.item_code = packed_item.parent_item
and so.docstatus = 1 and packed_item.item_code=%s
""",
(self.sales_order, self.production_item),
as_dict=1,
so = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
.inner_join(PackedItem)
.on(PackedItem.parent == SalesOrder.name)
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
.where(
(SalesOrder.name == self.sales_order)
& (SalesOrder.skip_delivery_note == 0)
& (SalesOrderItem.item_code == PackedItem.parent_item)
& (SalesOrder.docstatus == 1)
& (PackedItem.item_code == self.production_item)
)
.run(as_dict=1)
)
if len(so):
@@ -651,7 +669,7 @@ class WorkOrder(Document):
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
if self.sales_order and self.sales_order_item:
if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item:
update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
if self.production_plan:
@@ -1159,7 +1177,7 @@ class WorkOrder(Document):
doc.db_set("status", doc.status)
def update_work_order_qty_in_so(self):
if not self.sales_order and not self.sales_order_item:
if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
return
total_bundle_qty = 1

View File

@@ -191,6 +191,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
tax.item_wise_tax_detail = {};
}
var tax_fields = [
"net_amount",
"total",
"tax_amount_after_discount_amount",
"tax_amount_for_current_item",
@@ -400,9 +401,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
$.each(doc.taxes, function (i, tax) {
// tax_amount represents the amount of tax for the current step
var current_tax_amount = me.get_current_tax_amount(item, tax, item_tax_map);
var [current_net_amount, current_tax_amount] = me.get_current_tax_amount(
item,
tax,
item_tax_map
);
if (frappe.flags.round_row_wise_tax) {
current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax));
current_net_amount = flt(current_net_amount, precision("net_amount", tax));
}
// Adjust divisional loss to the last item
@@ -419,6 +425,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
!(me.discount_amount_applied && me.frm.doc.apply_discount_on == "Grand Total")
) {
tax.tax_amount += current_tax_amount;
tax.net_amount += current_net_amount;
}
// store tax_amount for current item as it will be used for
@@ -480,7 +487,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
for (const [i, tax] of doc.taxes.entries()) {
me.round_off_totals(tax);
me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]);
me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]);
me.round_off_base_values(tax);
@@ -555,7 +562,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
current_tax_amount = tax_rate * item.qty;
}
return current_tax_amount;
return [current_net_amount, current_tax_amount];
}
round_off_totals(tax) {
@@ -565,6 +572,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax));
tax.net_amount = flt(tax.net_amount, precision("net_amount", tax));
tax.tax_amount_after_discount_amount = flt(
tax.tax_amount_after_discount_amount,
precision("tax_amount", tax)

View File

@@ -2966,6 +2966,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.call({
method: "erpnext.controllers.stock_controller.make_quality_inspections",
args: {
company: me.frm.doc.company,
doctype: me.frm.doc.doctype,
docname: me.frm.doc.name,
items: selected_data,

View File

@@ -118,12 +118,37 @@ class Customer(TransactionBase):
def get_customer_name(self):
self.customer_name = self.customer_name.strip()
if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import:
count = frappe.db.sql(
"""select ifnull(MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), 0) from tabCustomer
where name like %s""",
f"%{self.customer_name} - %",
as_list=1,
)[0][0]
name_prefix = f"{self.customer_name} - %"
if frappe.db.db_type == "postgres":
# Postgres: extract trailing digits (e.g. "Customer - 3") and cast to int.
# NOTE: PostgreSQL is strict about types; MySQL's UNSIGNED cast does not exist.
count = frappe.db.sql(
"""
SELECT COALESCE(
MAX(CAST(SUBSTRING(name FROM '\\d+$') AS INTEGER)),
0
)
FROM tabCustomer
WHERE name LIKE %(name_prefix)s
""",
{"name_prefix": name_prefix},
as_list=1,
)[0][0]
else:
# MariaDB/MySQL: keep existing behavior.
count = frappe.db.sql(
"""
SELECT COALESCE(
MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)),
0
)
FROM tabCustomer
WHERE name LIKE %(name_prefix)s
""",
{"name_prefix": name_prefix},
as_list=1,
)[0][0]
count = cint(count) + 1
new_customer_name = f"{self.customer_name} - {cstr(count)}"

View File

@@ -123,19 +123,20 @@
"competitors",
"column_break_117",
"order_lost_reason",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_fozg",
"utm_campaign",
"utm_content",
"additional_info_section",
"status",
"customer_group",
"territory",
"column_break_108",
"utm_source",
"utm_campaign",
"utm_medium",
"utm_content",
"column_break4",
"opportunity",
"supplier_quotation",
"enq_det",
"supplier_quotation",
"connections_tab"
],
"fields": [
@@ -862,13 +863,6 @@
"oldfieldtype": "Small Text",
"print_hide": 1
},
{
"fieldname": "column_break4",
"fieldtype": "Column Break",
"oldfieldtype": "Column Break",
"print_hide": 1,
"width": "50%"
},
{
"default": "Draft",
"fieldname": "status",
@@ -1117,13 +1111,23 @@
"no_copy": 1,
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"fieldname": "column_break_fozg",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2026-01-29 21:18:48.836168",
"modified": "2026-02-06 17:34:22.170032",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -18,6 +18,7 @@
"column_break_7",
"order_type",
"transaction_date",
"transaction_time",
"delivery_date",
"column_break1",
"tax_id",
@@ -161,17 +162,18 @@
"column_break4",
"select_print_heading",
"language",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_ijxt",
"utm_campaign",
"utm_content",
"additional_info_section",
"is_internal_customer",
"po_no",
"po_date",
"represents_company",
"column_break_yvzv",
"utm_source",
"utm_campaign",
"utm_medium",
"utm_content",
"column_break_152",
"inter_company_order_reference",
"party_account_currency",
"connections_tab"
@@ -1567,10 +1569,6 @@
"fieldtype": "Section Break",
"label": "Additional Info"
},
{
"fieldname": "column_break_152",
"fieldtype": "Column Break"
},
{
"fieldname": "incoterm",
"fieldtype": "Link",
@@ -1717,6 +1715,24 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Additional Discount"
},
{
"fieldname": "column_break_ijxt",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
},
{
"default": "Now",
"depends_on": "is_internal_customer",
"fieldname": "transaction_time",
"fieldtype": "Time",
"label": "Time",
"mandatory_depends_on": "is_internal_customer"
}
],
"grid_page_length": 50,
@@ -1724,7 +1740,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-02-23 14:25:56.665392",
"modified": "2026-03-02 00:42:18.834823",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -186,6 +186,7 @@ class SalesOrder(SellingController):
total_qty: DF.Float
total_taxes_and_charges: DF.Currency
transaction_date: DF.Date
transaction_time: DF.Time | None
utm_campaign: DF.Link | None
utm_content: DF.Data | None
utm_medium: DF.Link | None

View File

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

View File

@@ -41,6 +41,8 @@
"allow_zero_qty_in_quotation",
"allow_zero_qty_in_sales_order",
"set_zero_rate_for_expired_batch",
"section_break_avhb",
"enable_utm",
"experimental_section",
"use_legacy_js_reactivity",
"subcontracting_inward_tab",
@@ -305,6 +307,19 @@
"fieldname": "enable_tracking_sales_commissions",
"fieldtype": "Check",
"label": "Enable tracking sales commissions"
},
{
"fieldname": "section_break_avhb",
"fieldtype": "Section Break",
"label": "Analytics"
},
{
"default": "0",
"description": "Enable Urchin Tracking Module parameters in Quotation, Sales Order, Sales Invoice, POS Invoice, Lead, and Delivery Note.",
"documentation_url": "https://en.wikipedia.org/wiki/UTM_parameters",
"fieldname": "enable_utm",
"fieldtype": "Check",
"label": "Enable UTM"
}
],
"grid_page_length": 50,
@@ -314,7 +329,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-04 16:16:57.618127",
"modified": "2026-02-12 10:38:34.605126",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@@ -10,6 +10,17 @@ from frappe.custom.doctype.property_setter.property_setter import make_property_
from frappe.model.document import Document
from frappe.utils import cint
UTM_DOCTYPES = [
"Lead",
"Quotation",
"POS Invoice",
"POS Profile",
"Opportunity",
"Sales Order",
"Sales Invoice",
"Delivery Note",
]
class SellingSettings(Document):
# begin: auto-generated types
@@ -38,6 +49,7 @@ class SellingSettings(Document):
enable_cutoff_date_on_bulk_delivery_note_creation: DF.Check
enable_discount_accounting: DF.Check
enable_tracking_sales_commissions: DF.Check
enable_utm: DF.Check
fallback_to_default_price_list: DF.Check
hide_tax_id: DF.Check
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
@@ -84,6 +96,9 @@ class SellingSettings(Document):
if old_doc.enable_tracking_sales_commissions != self.enable_tracking_sales_commissions:
toggle_tracking_sales_commissions_section(not self.enable_tracking_sales_commissions)
if old_doc.enable_utm != self.enable_utm:
toggle_utm_analytics_section(not self.enable_utm)
def validate_fallback_to_default_price_list(self):
if (
self.fallback_to_default_price_list
@@ -195,3 +210,14 @@ def toggle_tracking_sales_commissions_section(hide):
create_property_setter_for_hiding_field(doctype, "commission_section", hide)
if meta.has_field("sales_team_section"):
create_property_setter_for_hiding_field(doctype, "sales_team_section", hide)
def toggle_utm_analytics_section(hide):
from erpnext.accounts.doctype.accounts_settings.accounts_settings import (
create_property_setter_for_hiding_field,
)
for doctype in UTM_DOCTYPES:
meta = frappe.get_meta(doctype)
if meta.has_field("utm_analytics_section"):
create_property_setter_for_hiding_field(doctype, "utm_analytics_section", hide)

View File

@@ -156,17 +156,18 @@
"column_break_88",
"select_print_heading",
"language",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_neoj",
"utm_campaign",
"utm_content",
"more_info",
"is_internal_customer",
"represents_company",
"inter_company_reference",
"customer_group",
"territory",
"column_break_pxls",
"utm_source",
"utm_campaign",
"utm_medium",
"utm_content",
"column_break5",
"excise_page",
"instructions",
@@ -1366,10 +1367,6 @@
"options": "Delivery Trip",
"print_hide": 1
},
{
"fieldname": "column_break_pxls",
"fieldtype": "Column Break"
},
{
"fieldname": "utm_medium",
"fieldtype": "Link",
@@ -1438,7 +1435,13 @@
"options": "Company:company:default_currency"
},
{
"fieldname": "column_break_ydwe",
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
},
{
"fieldname": "column_break_neoj",
"fieldtype": "Column Break"
}
],
@@ -1446,7 +1449,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2026-02-23 23:05:39.097097",
"modified": "2026-02-10 14:35:08.523130",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",

View File

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

View File

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

View File

@@ -330,7 +330,8 @@ class MaterialRequest(BuyingController):
if mr_qty_allowance:
allowed_qty = flt(
(d.qty + (d.qty * (mr_qty_allowance / 100))), d.precision("ordered_qty")
(d.stock_qty + (d.stock_qty * (mr_qty_allowance / 100))),
d.precision("ordered_qty"),
)
if d.ordered_qty and flt(d.ordered_qty, precision) > flt(allowed_qty, precision):

View File

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

View File

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

View File

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

View File

@@ -237,7 +237,8 @@
"fieldname": "reposting_reference",
"fieldtype": "Data",
"label": "Reposting Reference",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"default": "0",
@@ -252,7 +253,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-12-24 14:59:15.512898",
"modified": "2026-02-25 14:22:21.681549",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",

View File

@@ -470,7 +470,15 @@ def repost_gl_entries(doc):
repost_affected_transaction = get_affected_transactions(doc)
transactions = directly_dependent_transactions + list(repost_affected_transaction)
if doc.based_on == "Item and Warehouse" and not doc.repost_only_accounting_ledgers:
enable_separate_reposting_for_gl = frappe.db.get_single_value(
"Stock Reposting Settings", "enable_separate_reposting_for_gl"
)
if (
enable_separate_reposting_for_gl
and doc.based_on == "Item and Warehouse"
and not doc.repost_only_accounting_ledgers
):
make_reposting_for_accounting_ledgers(
transactions,
doc.company,
@@ -562,7 +570,20 @@ def run_parallel_reposting():
riv_entries = get_repost_item_valuation_entries()
rq_jobs = frappe.get_all(
"RQ Job",
fields=["arguments"],
filters={
"status": ("like", "%started%"),
"job_name": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.execute_reposting_entry",
},
)
for row in riv_entries:
if rq_jobs:
if job_running_for_entry(row.name, rq_jobs):
continue
if row.based_on != "Item and Warehouse" or row.repost_only_accounting_ledgers:
execute_reposting_entry(row.name)
continue
@@ -671,25 +692,59 @@ def execute_repost_item_valuation():
def make_reposting_for_accounting_ledgers(transactions, company, repost_doc):
reposting_map = get_existing_reposting_only_gl_entries(repost_doc.name)
for voucher_type, voucher_no in transactions:
if frappe.db.exists(
"Repost Item Valuation",
{
"voucher_type": voucher_type,
"voucher_no": voucher_no,
"docstatus": 1,
"reposting_reference": repost_doc.name,
"repost_only_accounting_ledgers": 1,
"status": "Queued",
},
):
if reposting_map.get((voucher_type, voucher_no)):
continue
new_repost_doc = frappe.new_doc("Repost Item Valuation")
new_repost_doc.company = company
new_repost_doc.voucher_type = voucher_type
new_repost_doc.voucher_no = voucher_no
new_repost_doc.repost_only_accounting_ledgers = 1
new_repost_doc.reposting_reference = repost_doc.name
new_repost_doc.flags.ignore_permissions = True
new_repost_doc.submit()
try:
new_repost_doc = frappe.new_doc("Repost Item Valuation")
new_repost_doc.company = company
new_repost_doc.voucher_type = voucher_type
new_repost_doc.voucher_no = voucher_no
new_repost_doc.repost_only_accounting_ledgers = 1
new_repost_doc.reposting_reference = repost_doc.name
new_repost_doc.flags.ignore_permissions = True
new_repost_doc.submit()
except Exception:
pass
def get_existing_reposting_only_gl_entries(reposting_reference):
existing_reposting = frappe.get_all(
"Repost Item Valuation",
filters={
"reposting_reference": reposting_reference,
"docstatus": 1,
"status": "Queued",
"repost_only_accounting_ledgers": 1,
},
fields=["reposting_reference", "voucher_type", "voucher_no"],
)
if not existing_reposting:
return frappe._dict()
reposting_map = {}
for d in existing_reposting:
key = (d.voucher_type, d.voucher_no)
reposting_map[key] = d.reposting_reference
return reposting_map
def job_running_for_entry(reposting_entry, rq_jobs):
for job in rq_jobs:
if not job.arguments:
continue
try:
job_args = json.loads(job.arguments)
except (TypeError, json.JSONDecodeError):
continue
if isinstance(job_args, dict) and job_args.get("kwargs", {}).get("name") == reposting_entry:
return True
return False

View File

@@ -717,10 +717,13 @@ class SerialandBatchBundle(Document):
if rate is None and child_table in ["Delivery Note Item", "Sales Invoice Item"]:
rate = frappe.db.get_value(
"Packed Item",
self.voucher_detail_no,
{"parent_detail_docname": self.voucher_detail_no, "item_code": self.item_code},
"incoming_rate",
)
if rate is None:
rate = frappe.db.get_value("Packed Item", self.voucher_detail_no, "incoming_rate")
if rate is not None:
is_packed_item = True
@@ -787,6 +790,9 @@ class SerialandBatchBundle(Document):
if not self.voucher_detail_no or self.voucher_detail_no != row.name:
values_to_set["voucher_detail_no"] = row.name
if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"):
values_to_set["voucher_detail_no"] = row.get("parent_detail_docname")
if parent.get("posting_date") and parent.get("posting_time"):
posting_datetime = combine_datetime(parent.posting_date, parent.posting_time)
if not self.posting_datetime or self.posting_datetime != posting_datetime:
@@ -1325,7 +1331,21 @@ class SerialandBatchBundle(Document):
)
if not vouchers and self.voucher_type == "Delivery Note":
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
if frappe.db.exists("Packed Item", self.voucher_detail_no):
frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None)
else:
packed_items = frappe.get_all(
"Packed Item",
filters={
"parent_detail_docname": self.voucher_detail_no,
"serial_and_batch_bundle": self.name,
},
pluck="name",
)
for packed_item in packed_items:
frappe.db.set_value("Packed Item", packed_item, "serial_and_batch_bundle", None)
return
for voucher in vouchers:

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,11 @@
"end_time",
"limits_dont_apply_on",
"item_based_reposting",
"section_break_dxuf",
"enable_parallel_reposting",
"no_of_parallel_reposting",
"column_break_itvd",
"enable_separate_reposting_for_gl",
"errors_notification_section",
"notify_reposting_error_to_role"
],
@@ -81,13 +84,28 @@
"fieldname": "no_of_parallel_reposting",
"fieldtype": "Int",
"label": "No of Parallel Reposting (Per Item)"
},
{
"fieldname": "section_break_dxuf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_itvd",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "item_based_reposting",
"fieldname": "enable_separate_reposting_for_gl",
"fieldtype": "Check",
"label": "Enable Separate Reposting for GL"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-02 18:18:57.115176",
"modified": "2026-02-25 14:11:33.461173",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reposting Settings",

View File

@@ -17,6 +17,7 @@ class StockRepostingSettings(Document):
from frappe.types import DF
enable_parallel_reposting: DF.Check
enable_separate_reposting_for_gl: DF.Check
end_time: DF.Time | None
item_based_reposting: DF.Check
limit_reposting_timeslot: DF.Check

View File

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

View File

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

View File

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

View File

@@ -1305,7 +1305,7 @@ class update_entries_after:
else:
if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
ref_doctype = "Packed Item"
elif sle == "Subcontracting Receipt":
elif sle.voucher_type == "Subcontracting Receipt":
ref_doctype = "Subcontracting Receipt Supplied Item"
else:
ref_doctype = "Purchase Receipt Item Supplied"

View File

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