Merge pull request #52730 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
ruthra kumar
2026-02-17 19:38:01 +05:30
committed by GitHub
29 changed files with 749 additions and 224 deletions

View File

@@ -26,7 +26,7 @@
},
{
"default": "0",
"depends_on": "eval:parent.doctype == 'Sales Invoice'",
"depends_on": "eval: [\"POS Invoice\", \"Sales Invoice\"].includes(parent.doctype)",
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
@@ -85,7 +85,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-01-23 16:20:06.436979",
"modified": "2026-02-16 20:46:34.592604",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Payment",
@@ -95,4 +95,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -804,12 +804,19 @@ def validate_against_pcv(is_opening, posting_date, company):
title=_("Invalid Opening Entry"),
)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, "max(period_end_date)"
)
# Local import so you don't have to touch file-level imports
from frappe.query_builder.functions import Max
pcv = frappe.qb.DocType("Period Closing Voucher")
last_pcv_date = (
frappe.qb.from_(pcv)
.select(Max(pcv.period_end_date))
.where((pcv.docstatus == 1) & (pcv.company == company))
).run(pluck=True)[0]
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
message = _("Books have been closed till the period ending on {0}.").format(formatdate(last_pcv_date))
message += "</br >"
message += _("You cannot create/amend any accounting entries till this date.")
frappe.throw(message, title=_("Period Closed"))

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _
from frappe import _, qb
from frappe.query_builder import Criterion
from frappe.utils import cstr, flt
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
@@ -33,11 +34,19 @@ def execute(filters=None):
def get_accounts_data(based_on, company):
if based_on == "Cost Center":
return frappe.db.sql(
"""select name, parent_cost_center as parent_account, cost_center_name as account_name, lft, rgt
from `tabCost Center` where company=%s order by name""",
company,
as_dict=True,
cc = qb.DocType("Cost Center")
return (
qb.from_(cc)
.select(
cc.name,
cc.parent_cost_center.as_("parent_account"),
cc.cost_center_name.as_("account_name"),
cc.lft,
cc.rgt,
)
.where(cc.company.eq(company))
.orderby(cc.name)
.run(as_dict=True)
)
elif based_on == "Project":
return frappe.get_all("Project", fields=["name"], filters={"company": company}, order_by="name")
@@ -206,27 +215,38 @@ def set_gl_entries_by_account(
company, from_date, to_date, based_on, gl_entries_by_account, ignore_closing_entries=False
):
"""Returns a dict like { "account": [gl entries], ... }"""
additional_conditions = []
gl = qb.DocType("GL Entry")
acc = qb.DocType("Account")
conditions = []
conditions.append(gl.company.eq(company))
conditions.append(gl[based_on].notnull())
conditions.append(gl.is_cancelled.eq(0))
if from_date and to_date:
conditions.append(gl.posting_date.between(from_date, to_date))
elif from_date and not to_date:
conditions.append(gl.posting_date.gte(from_date))
elif not from_date and to_date:
conditions.append(gl.posting_date.lte(to_date))
if ignore_closing_entries:
additional_conditions.append("and voucher_type !='Period Closing Voucher'")
conditions.append(gl.voucher_type.ne("Period Closing Voucher"))
if from_date:
additional_conditions.append("and posting_date >= %(from_date)s")
gl_entries = frappe.db.sql(
"""select posting_date, {based_on} as based_on, debit, credit,
is_opening, (select root_type from `tabAccount` where name = account) as type
from `tabGL Entry` where company=%(company)s
{additional_conditions}
and posting_date <= %(to_date)s
and {based_on} is not null
and is_cancelled = 0
order by {based_on}, posting_date""".format(
additional_conditions="\n".join(additional_conditions), based_on=based_on
),
{"company": company, "from_date": from_date, "to_date": to_date},
as_dict=True,
root_subquery = qb.from_(acc).select(acc.root_type).where(acc.name.eq(gl.account))
gl_entries = (
qb.from_(gl)
.select(
gl.posting_date,
gl[based_on].as_("based_on"),
gl.debit,
gl.credit,
gl.is_opening,
root_subquery.as_("type"),
)
.where(Criterion.all(conditions))
.orderby(gl[based_on], gl.posting_date)
.run(as_dict=True)
)
for entry in gl_entries:

View File

@@ -51,7 +51,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
entries = {}
for name, details in gle_map.items():
for entry in details:
tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0
tax_amount, total_amount, grand_total, base_total, base_tax_withholding_net_total = 0, 0, 0, 0, 0
tax_withholding_category, rate = None, None
bill_no, bill_date = "", ""
party = entry.party or entry.against
@@ -83,6 +83,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
# back calculate total amount from rate and tax_amount
base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0])
total_amount = grand_total = base_total
base_tax_withholding_net_total = total_amount
else:
if tax_amount and rate:
@@ -93,12 +94,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
grand_total = values[1]
base_total = values[2]
base_tax_withholding_net_total = total_amount
if voucher_type == "Purchase Invoice":
base_tax_withholding_net_total = values[0]
bill_no = values[3]
bill_date = values[4]
else:
total_amount += entry.credit
base_tax_withholding_net_total = total_amount
if tax_amount:
if party_map.get(party, {}).get("party_type") == "Supplier":
@@ -125,6 +130,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
"rate": rate,
"total_amount": total_amount,
"grand_total": grand_total,
"base_tax_withholding_net_total": base_tax_withholding_net_total,
"base_total": base_total,
"tax_amount": tax_amount,
"transaction_date": posting_date,
@@ -252,14 +258,14 @@ def get_columns(filters):
"width": 60,
},
{
"label": _("Total Amount"),
"fieldname": "total_amount",
"label": _("Tax Withholding Net Total"),
"fieldname": "base_tax_withholding_net_total",
"fieldtype": "Float",
"width": 120,
"width": 150,
},
{
"label": _("Base Total"),
"fieldname": "base_total",
"label": _("Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Float",
"width": 120,
},
@@ -270,10 +276,16 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Grand Total"),
"label": _("Grand Total (Company Currency)"),
"fieldname": "base_total",
"fieldtype": "Float",
"width": 150,
},
{
"label": _("Grand Total (Transaction Currency)"),
"fieldname": "grand_total",
"fieldtype": "Float",
"width": 120,
"width": 170,
},
{"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 130},
{

View File

@@ -35,9 +35,9 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
result = execute(filters)[1]
expected_values = [
# Check for JV totals using back calculation logic
[jv.name, "TCS", 0.075, -10000.0, -7.5, -10000.0],
[pe.name, "TCS", 0.075, 2550, 0.53, 2550.53],
[si.name, "TCS", 0.075, 1000, 0.52, 1000.52],
[jv.name, "TCS", 0.075, -10000.0, -10000.0, -7.5, -10000.0],
[pe.name, "TCS", 0.075, 706.67, 2550.0, 0.53, 2550.53],
[si.name, "TCS", 0.075, 693.33, 1000.0, 0.52, 1000.52],
]
self.check_expected_values(result, expected_values)
@@ -55,8 +55,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
frappe._dict(company="_Test Company", party_type="Supplier", from_date=today(), to_date=today())
)[1]
expected_values = [
[inv_1.name, "TDS - 1", 10, 5000, 500, 5500],
[inv_2.name, "TDS - 2", 20, 5000, 1000, 6000],
[inv_1.name, "TDS - 1", 10, 5000, 5000, 500, 5500],
[inv_2.name, "TDS - 2", 20, 5000, 5000, 1000, 6000],
]
self.check_expected_values(result, expected_values)
@@ -107,8 +107,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
)[1]
expected_values = [
[inv_1.name, "TDS - 3", 10.0, 5000, 500, 4500],
[inv_2.name, "TDS - 3", 20.0, 5000, 1000, 4000],
[inv_1.name, "TDS - 3", 10.0, 5000, 5000, 500, 4500],
[inv_2.name, "TDS - 3", 20.0, 5000, 5000, 1000, 4000],
]
self.check_expected_values(result, expected_values)
@@ -120,6 +120,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
voucher.ref_no,
voucher.section_code,
voucher.rate,
voucher.base_tax_withholding_net_total,
voucher.base_total,
voucher.tax_amount,
voucher.grand_total,

View File

@@ -128,7 +128,7 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Total Amount"),
"label": _("Total Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Float",
"width": 120,

View File

@@ -2580,12 +2580,12 @@ class AccountsController(TransactionBase):
def get_order_details(self):
if self.doctype == "Sales Invoice":
po_or_so = self.get("items")[0].get("sales_order")
po_or_so = self.get("items") and self.get("items")[0].get("sales_order")
po_or_so_doctype = "Sales Order"
po_or_so_doctype_name = "sales_order"
else:
po_or_so = self.get("items")[0].get("purchase_order")
po_or_so = self.get("items") and self.get("items")[0].get("purchase_order")
po_or_so_doctype = "Purchase Order"
po_or_so_doctype_name = "purchase_order"
@@ -4002,6 +4002,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
flt(d.get("conversion_factor"), conv_fac_precision) or conversion_factor
)
if child_item.get("total_weight") and child_item.get("weight_per_unit"):
child_item.total_weight = flt(
child_item.weight_per_unit * child_item.qty * child_item.conversion_factor,
child_item.precision("total_weight"),
)
if d.get("delivery_date") and parent_doctype == "Sales Order":
child_item.delivery_date = d.get("delivery_date")
@@ -4054,6 +4060,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.set_payment_schedule()
if parent_doctype == "Purchase Order":
parent.set_tax_withholding()
parent.validate_minimum_order_qty()
parent.validate_budget()
if parent.is_against_so():

View File

@@ -626,7 +626,9 @@ class BuyingController(SubcontractingController):
or self.is_return
or (self.is_internal_transfer() and self.docstatus == 2)
else self.get_package_for_target_warehouse(
d, type_of_transaction=type_of_transaction
d,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
),
},
@@ -714,7 +716,22 @@ class BuyingController(SubcontractingController):
via_landed_cost_voucher=via_landed_cost_voucher,
)
def get_package_for_target_warehouse(self, item, warehouse=None, type_of_transaction=None) -> str:
def get_package_for_target_warehouse(
self, item, warehouse=None, type_of_transaction=None, via_landed_cost_voucher=None
) -> str:
if via_landed_cost_voucher and item.get("warehouse"):
if sabb := frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_detail_no": item.name,
"warehouse": item.get("warehouse"),
"docstatus": 1,
"is_cancelled": 0,
},
"name",
):
return sabb
if not item.serial_and_batch_bundle:
return ""

View File

@@ -1591,7 +1591,7 @@ def get_gl_entries_for_preview(doctype, docname, fields):
def get_columns(raw_columns, fields):
return [
{"name": d.get("label"), "editable": False, "width": 110}
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
for d in raw_columns
if not d.get("hidden") and d.get("fieldname") in fields
]

View File

@@ -1,96 +1,48 @@
{
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "field:market_segment",
"beta": 0,
"creation": "2018-10-01 09:59:14.479509",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"allow_rename": 1,
"autoname": "field:market_segment",
"creation": "2018-10-01 09:59:14.479509",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"market_segment"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "market_segment",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Market Segment",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"fieldname": "market_segment",
"fieldtype": "Data",
"label": "Market Segment",
"unique": 1
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-10-01 09:59:14.479509",
"modified_by": "Administrator",
"module": "CRM",
"name": "Market Segment",
"name_case": "",
"owner": "Administrator",
],
"links": [],
"modified": "2025-12-17 12:09:34.687368",
"modified_by": "Administrator",
"module": "CRM",
"name": "Market Segment",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0,
"track_views": 0
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"translated_doctype": 1
}

View File

@@ -133,7 +133,7 @@
"label": "Batch Size"
},
{
"depends_on": "eval:doc.parenttype == \"Routing\"",
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
"fieldname": "sequence_id",
"fieldtype": "Int",
"label": "Sequence ID"
@@ -196,7 +196,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-31 16:17:47.287117",
"modified": "2026-02-17 15:33:28.495850",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -49,7 +49,8 @@
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Warehouse",
"options": "Warehouse"
"options": "Warehouse",
"set_only_once": 1
},
{
"depends_on": "eval:!doc.__islocal && doc.warehouse",
@@ -66,13 +67,14 @@
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
"options": "Company",
"set_only_once": 1
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-19 19:06:36.481625",
"modified": "2026-02-17 11:53:17.940039",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Plant Floor",

View File

@@ -648,8 +648,8 @@ class ProductionPlan(Document):
self.status = "Completed"
if self.status != "Completed":
self.update_ordered_status()
self.update_requested_status()
self.update_ordered_status()
if close is not None:
self.db_set("status", self.status)
@@ -658,25 +658,17 @@ class ProductionPlan(Document):
self.update_bin_qty()
def update_ordered_status(self):
update_status = False
for d in self.po_items:
if d.planned_qty == d.ordered_qty:
update_status = True
if update_status and self.status != "Completed":
self.status = "In Process"
for child_table in ["po_items", "sub_assembly_items"]:
for item in self.get(child_table):
if item.ordered_qty:
self.status = "In Process"
return
def update_requested_status(self):
if not self.mr_items:
return
update_status = True
for d in self.mr_items:
if d.quantity != d.requested_qty:
update_status = False
if update_status:
self.status = "Material Requested"
if d.requested_qty:
self.status = "Material Requested"
break
def get_production_items(self):
item_dict = {}
@@ -798,6 +790,8 @@ class ProductionPlan(Document):
"stock_uom",
"bom_level",
"schedule_date",
"sales_order",
"sales_order_item",
]:
if row.get(field):
wo_data[field] = row.get(field)
@@ -837,6 +831,8 @@ class ProductionPlan(Document):
"qty",
"description",
"production_plan_item",
"sales_order",
"sales_order_item",
]:
po_data[field] = row.get(field)
@@ -1023,6 +1019,10 @@ class ProductionPlan(Document):
if not is_group_warehouse:
data.fg_warehouse = self.sub_assembly_warehouse
if not self.combine_sub_items:
data.sales_order = row.sales_order
data.sales_order_item = row.sales_order_item
def set_default_supplier_for_subcontracting_order(self):
items = [
d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract"

View File

@@ -565,6 +565,90 @@ class TestProductionPlan(FrappeTestCase):
self.assertEqual(po_doc.items[0].fg_item, fg_item)
self.assertEqual(po_doc.items[0].item_code, service_item)
def test_sales_order_references_for_sub_assembly_items(self):
"""
Test that Sales Order and Sales Order Item references in Work Order and Purchase Order
are correctly propagated from the Production Plan.
"""
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
# Setup Test Items & BOM
fg_item = "Test FG Good Item"
sub_assembly_item1 = "Test Sub Assembly Item 1"
sub_assembly_item2 = "Test Sub Assembly Item 2"
bom_tree = {
fg_item: {
sub_assembly_item1: {"Test Raw Material 1": {}},
sub_assembly_item2: {"Test Raw Material 2": {}},
}
}
create_nested_bom(bom_tree, prefix="")
# Create Sales Order
so = make_sales_order(item_code=fg_item, qty=10)
so_item_row = so.items[0].name
# Create Production Plan from Sales Order
production_plan = frappe.new_doc("Production Plan")
production_plan.company = so.company
production_plan.get_items_from = "Sales Order"
production_plan.item_code = fg_item
production_plan.get_open_sales_orders()
self.assertEqual(production_plan.sales_orders[0].sales_order, so.name)
production_plan.get_so_items()
production_plan.skip_available_sub_assembly_item = 0
production_plan.get_sub_assembly_items()
self.assertEqual(len(production_plan.sub_assembly_items), 2)
# Validate Sales Order references in Sub Assembly Items
for row in production_plan.sub_assembly_items:
if row.production_item == sub_assembly_item1:
row.supplier = "_Test Supplier"
row.type_of_manufacturing = "Subcontract"
self.assertEqual(row.sales_order, so.name)
self.assertEqual(row.sales_order_item, so_item_row)
# Submit Production Plan
production_plan.save()
production_plan.submit()
production_plan.make_work_order()
# Validate Purchase Order (Subcontracted Item)
po_items = frappe.get_all(
"Purchase Order Item",
{
"production_plan": production_plan.name,
"fg_item": sub_assembly_item1,
},
["sales_order", "sales_order_item"],
)
self.assertTrue(po_items)
self.assertEqual(po_items[0].sales_order, so.name)
self.assertEqual(po_items[0].sales_order_item, so_item_row)
# Validate Work Order (In-house Item)
work_orders = frappe.get_all(
"Work Order",
{
"production_plan": production_plan.name,
"production_item": sub_assembly_item2,
},
["sales_order", "sales_order_item"],
)
self.assertTrue(work_orders)
self.assertEqual(work_orders[0].sales_order, so.name)
self.assertEqual(work_orders[0].sales_order_item, so_item_row)
def test_production_plan_combine_subassembly(self):
"""
Test combining Sub assembly items belonging to the same BOM in Prod Plan.

View File

@@ -10,6 +10,8 @@
"fg_warehouse",
"parent_item_code",
"schedule_date",
"sales_order",
"sales_order_item",
"column_break_3",
"qty",
"bom_no",
@@ -212,20 +214,36 @@
"label": "Ordered Qty",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Sales Order",
"options": "Sales Order",
"read_only": 1
},
{
"fieldname": "sales_order_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item",
"no_copy": 1,
"print_hide": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-10 13:36:24.759101",
"modified": "2026-02-17 12:06:02.309032",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -33,6 +33,8 @@ class ProductionPlanSubAssemblyItem(Document):
purchase_order: DF.Link | None
qty: DF.Float
received_qty: DF.Float
sales_order: DF.Link | None
sales_order_item: DF.Data | None
schedule_date: DF.Datetime | None
stock_uom: DF.Link | None
supplier: DF.Link | None

View File

@@ -598,9 +598,20 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff
: this.frm.doc.net_total);
if(["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(this.frm.doc.doctype)) {
this.frm.doc.base_grand_total = (this.frm.doc.total_taxes_and_charges) ?
flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate) : this.frm.doc.base_net_total;
// total taxes and charges is calculated before adjusting base grand total
this.frm.doc.total_taxes_and_charges = flt(
this.frm.doc.grand_total - this.frm.doc.net_total - grand_total_diff,
precision("total_taxes_and_charges")
);
if (
["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(
this.frm.doc.doctype
)
) {
this.frm.doc.base_grand_total = this.frm.doc.total_taxes_and_charges
? flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate)
: this.frm.doc.base_net_total;
} else {
// other charges added/deducted
this.frm.doc.taxes_and_charges_added = this.frm.doc.taxes_and_charges_deducted = 0.0;
@@ -626,11 +637,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
["taxes_and_charges_added", "taxes_and_charges_deducted"]);
}
this.frm.doc.total_taxes_and_charges = flt(this.frm.doc.grand_total - this.frm.doc.net_total
- grand_total_diff, precision("total_taxes_and_charges"));
this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges"]);
// Round grand total as per precision
frappe.model.round_floats_in(this.frm.doc, ["grand_total", "base_grand_total"]);

View File

@@ -80,6 +80,14 @@ erpnext.accounts.ledger_preview = {
},
get_datatable(columns, data, wrapper) {
columns.forEach((col) => {
if (col.fieldtype === "Currency") {
col.format = (value) => {
return format_currency(value);
};
}
});
const datatable_options = {
columns: columns,
data: data,

View File

@@ -707,7 +707,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
}
render_data() {
if (this.bundle || this.frm.doc.is_return) {
if (this.bundle || (this.frm.doc.is_return && this.frm.doc.return_against)) {
frappe
.call({
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_serial_batch_ledgers",

View File

@@ -1203,7 +1203,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
"doctype": "Sales Invoice",
"field_map": {
"party_account_currency": "party_account_currency",
"payment_terms_template": "payment_terms_template",
},
"field_no_map": ["payment_terms_template"],
"validation": {"docstatus": ["=", 1]},

View File

@@ -202,7 +202,11 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
@change_settings(
"Accounts Settings",
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1},
{
"add_taxes_from_item_tax_template": 0,
"add_taxes_from_taxes_and_charges_template": 1,
"automatically_fetch_payment_terms": 1,
},
)
def test_make_sales_invoice_with_terms(self):
so = make_sales_order(do_not_submit=True)
@@ -232,6 +236,34 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
si1 = make_sales_invoice(so.name)
self.assertEqual(len(si1.get("items")), 0)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) # Enable auto fetch
def test_auto_fetch_terms_enable(self):
so = make_sales_order(do_not_submit=True)
so.payment_terms_template = "_Test Payment Term Template"
so.save()
so.submit()
si = make_sales_invoice(so.name)
# Check if payment terms are copied from sales order to sales invoice
self.assertTrue(si.payment_terms_template)
si.insert()
si.submit()
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 0}) # Disable auto fetch
def test_auto_fetch_terms_disable(self):
so = make_sales_order(do_not_submit=True)
so.payment_terms_template = "_Test Payment Term Template"
so.save()
so.submit()
si = make_sales_invoice(so.name)
# Check if payment terms are not copied from sales order to sales invoice
self.assertFalse(si.payment_terms_template)
si.insert()
si.submit()
def test_update_qty(self):
so = make_sales_order()

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _, msgprint
from frappe import _, msgprint, qb
from frappe.query_builder import Criterion
def execute(filters=None):
@@ -97,45 +98,53 @@ def get_columns(filters):
def get_entries(filters):
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
dt = qb.DocType(filters["doc_type"])
st = qb.DocType("Sales Team")
date_field = dt["transaction_date"] if filters["doc_type"] == "Sales Order" else dt["posting_date"]
conditions, values = get_conditions(filters, date_field)
entries = frappe.db.sql(
"""
select
dt.name, dt.customer, dt.territory, dt.{} as posting_date,dt.base_net_total as base_net_amount,
st.commission_rate,st.sales_person, st.allocated_percentage, st.allocated_amount, st.incentives
from
`tab{}` dt, `tabSales Team` st
where
st.parent = dt.name and st.parenttype = {}
and dt.docstatus = 1 {} order by dt.name desc,st.sales_person
""".format(date_field, filters["doc_type"], "%s", conditions),
tuple([filters["doc_type"], *values]),
as_dict=1,
conditions = get_conditions(dt, st, filters, date_field)
entries = (
qb.from_(dt)
.join(st)
.on(st.parent.eq(dt.name) & st.parenttype.eq(filters["doc_type"]))
.select(
dt.name,
dt.customer,
dt.territory,
date_field.as_("posting_date"),
dt.base_net_total.as_("base_net_amount"),
st.commission_rate,
st.sales_person,
st.allocated_percentage,
st.allocated_amount,
st.incentives,
)
.where(Criterion.all(conditions))
.orderby(dt.name, st.sales_person)
.run(as_dict=True)
)
return entries
def get_conditions(filters, date_field):
conditions = [""]
values = []
def get_conditions(dt, st, filters, date_field):
conditions = []
conditions.append(dt.docstatus.eq(1))
from_dt = filters.get("from_date")
to_dt = filters.get("to_date")
if from_dt and to_dt:
conditions.append(date_field.between(from_dt, to_dt))
elif from_dt and not to_dt:
conditions.append(date_field.gte(from_dt))
elif not from_dt and to_dt:
conditions.append(date_field.lte(to_dt))
for field in ["company", "customer", "territory"]:
if filters.get(field):
conditions.append(f"dt.{field}=%s")
values.append(filters[field])
conditions.append(dt[field].eq(filters.get(field)))
if filters.get("sales_person"):
conditions.append("st.sales_person = '{}'".format(filters.get("sales_person")))
conditions.append(st["sales_person"].eq(filters.get("sales_person")))
if filters.get("from_date"):
conditions.append(f"dt.{date_field}>=%s")
values.append(filters["from_date"])
if filters.get("to_date"):
conditions.append(f"dt.{date_field}<=%s")
values.append(filters["to_date"])
return " and ".join(conditions), values
return conditions

View File

@@ -1015,6 +1015,102 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.cancel()
def test_lcv_for_internal_transfer(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
item_code = make_item(
"Test Item For LCV in Internal Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_naming_series": "TEST-SBATCH.###"},
).name
pr1 = make_purchase_receipt(
item_code=item_code,
qty=10,
rate=100,
warehouse="Stores - TCP1",
company="_Test Company with perpetual inventory",
)
dn1 = create_delivery_note(
item_code=pr1.items[0].item_code,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=500,
warehouse="Stores - TCP1",
target_warehouse="Work In Progress - TCP1",
)
pr = make_inter_company_purchase_receipt(dn1.name)
pr.items[0].from_warehouse = "Work In Progress - TCP1"
pr.items[0].warehouse = "Stores - TCP1"
pr.submit()
sle_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
fields=["serial_and_batch_bundle", "actual_qty"],
)
self.assertEqual(len(sle_entries), 2)
inward_sabb = frappe.get_all(
"Serial and Batch Bundle",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"total_qty": (">", 0),
"docstatus": 1,
},
pluck="name",
)
self.assertEqual(len(inward_sabb), 1)
original_cost = frappe.db.get_value("Serial and Batch Bundle", inward_sabb[0], "total_amount")
make_landed_cost_voucher(
company=pr.company,
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=100,
distribute_charges_based_on="Qty",
expense_account="Expenses Included In Valuation - TCP1",
)
sle_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["serial_and_batch_bundle", "actual_qty"],
)
self.assertEqual(len(sle_entries), 2)
new_inward_sabb = frappe.get_all(
"Serial and Batch Bundle",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"total_qty": (">", 0),
"docstatus": 1,
},
pluck="name",
)
self.assertEqual(len(new_inward_sabb), 1)
new_cost = frappe.db.get_value("Serial and Batch Bundle", new_inward_sabb[0], "total_amount")
self.assertEqual(new_cost, original_cost + 100)
self.assertTrue(new_inward_sabb[0] == inward_sabb[0])
def test_stock_transfer_from_purchase_receipt_with_valuation(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note

View File

@@ -15,6 +15,7 @@ from frappe.utils import (
cint,
cstr,
flt,
format_datetime,
get_datetime,
get_link_to_form,
getdate,
@@ -1426,22 +1427,33 @@ class SerialandBatchBundle(Document):
if flt(available_qty, precision) < 0:
self.throw_negative_batch(d.batch_no, available_qty, precision)
def throw_negative_batch(self, batch_no, available_qty, precision):
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
from erpnext.stock.stock_ledger import NegativeStockError
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
return
date_msg = ""
if posting_datetime:
date_msg = " " + _("as of {0}").format(format_datetime(posting_datetime))
msg = _(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}.
Please add a stock quantity of {4} to proceed with this entry.
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed.
However, enabling this setting may lead to negative stock in the system.
So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."""
).format(
bold(batch_no),
bold(self.item_code),
bold(self.warehouse),
date_msg,
bold(abs(flt(available_qty, precision))),
)
frappe.throw(
_(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
).format(
bold(batch_no),
bold(self.item_code),
bold(self.warehouse),
bold(abs(flt(available_qty, precision))),
),
msg,
title=_("Negative Stock Error"),
exc=NegativeStockError,
)
@@ -1464,7 +1476,9 @@ class SerialandBatchBundle(Document):
available_qty[row.batch_no] = flt(row.qty)
if flt(available_qty[row.batch_no], precision) < 0:
self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision)
self.throw_negative_batch(
row.batch_no, available_qty[row.batch_no], precision, row.posting_datetime
)
return available_qty
@@ -1498,17 +1512,17 @@ class SerialandBatchBundle(Document):
def get_available_qty_from_sabb(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
parent = frappe.qb.DocType("Serial and Batch Bundle")
parent = frappe.qb.DocType("Stock Ledger Entry")
child = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
.on(parent.serial_and_batch_bundle == child.parent)
.select(
child.batch_no,
child.qty,
CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"),
parent.posting_datetime,
parent.creation,
)
.where(
@@ -1517,16 +1531,16 @@ class SerialandBatchBundle(Document):
& (child.batch_no.isin(batches))
& (parent.docstatus == 1)
& (parent.is_cancelled == 0)
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
)
query = query.where(parent.voucher_type != "Pick List")
return query.run(as_dict=True)
def validate_voucher_no_docstatus(self):
if self.is_cancelled:
return
if self.voucher_type == "POS Invoice":
return

View File

@@ -542,7 +542,7 @@
},
{
"default": "0",
"description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.",
"description": "If enabled, the system will allow negative stock entries for the batch. But, this may lead to incorrect valuation rates, so it is recommended to avoid using this option. The system will permit negative stock only when it is caused by backdated entries and will validate and block negative stock in all other cases.",
"fieldname": "allow_negative_stock_for_batch",
"fieldtype": "Check",
"label": "Allow Negative Stock for Batch"
@@ -553,7 +553,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-09 15:01:12.466175",
"modified": "2026-02-16 10:36:59.921491",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Negative Batch Report"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_default("company"),
},
{
fieldname: "item_code",
label: __("Item Code"),
fieldtype: "Link",
options: "Item",
get_query: function () {
return {
filters: {
has_batch_no: 1,
},
};
},
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
options: "Warehouse",
get_query: function () {
return {
filters: {
is_group: 0,
company: frappe.query_report.get_filter_value("company"),
},
};
},
},
],
};

View File

@@ -0,0 +1,53 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-02-17 11:34:21.549485",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "",
"letter_head": null,
"modified": "2026-02-17 11:34:59.106045",
"modified_by": "Administrator",
"module": "Stock",
"name": "Negative Batch Report",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Serial and Batch Bundle",
"report_name": "Negative Batch Report",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
},
{
"role": "Purchase User"
},
{
"role": "Purchase Manager"
},
{
"role": "Stock User"
},
{
"role": "Stock Manager"
},
{
"role": "Delivery User"
},
{
"role": "Delivery Manager"
},
{
"role": "Manufacturing User"
},
{
"role": "Manufacturing Manager"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,145 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.utils import add_to_date, flt, today
from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute
def execute(filters: dict | None = None):
"""Return columns and data for the report.
This is the main entry point for the report. It accepts the filters as a
dictionary and should return columns and data. It is called by the framework
every time the report is refreshed or a filter is updated.
"""
columns = get_columns()
data = get_data(filters)
return columns, data
def get_columns() -> list[dict]:
return [
{
"label": _("Posting Datetime"),
"fieldname": "posting_date",
"fieldtype": "Datetime",
"width": 160,
},
{
"label": _("Batch No"),
"fieldname": "batch_no",
"fieldtype": "Link",
"options": "Batch",
"width": 120,
},
{
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{
"label": _("Warehouse"),
"fieldname": "warehouse",
"fieldtype": "Link",
"options": "Warehouse",
"width": 160,
},
{
"label": _("Previous Qty"),
"fieldname": "previous_qty",
"fieldtype": "Float",
"width": 130,
},
{
"label": _("Transaction Qty"),
"fieldname": "actual_qty",
"fieldtype": "Float",
"width": 130,
},
{
"label": _("Qty After Transaction"),
"fieldname": "qty_after_transaction",
"fieldtype": "Float",
"width": 180,
},
{
"label": _("Document Type"),
"fieldname": "voucher_type",
"fieldtype": "Data",
"width": 130,
},
{
"label": _("Document No"),
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"options": "voucher_type",
"width": 130,
},
]
def get_data(filters) -> list[dict]:
batches = get_batches(filters)
companies = get_companies(filters)
batch_negative_data = []
flt_precision = frappe.db.get_default("float_precision") or 2
for company in companies:
for batch in batches:
_c, data = stock_ledger_execute(
frappe._dict(
{
"company": company,
"batch_no": batch,
"from_date": add_to_date(today(), years=-12),
"to_date": today(),
"segregate_serial_batch_bundle": 1,
"warehouse": filters.get("warehouse"),
"valuation_field_type": "Currency",
}
)
)
previous_qty = 0
for row in data:
if flt(row.get("qty_after_transaction"), flt_precision) < 0:
batch_negative_data.append(
{
"posting_date": row.get("date"),
"batch_no": row.get("batch_no"),
"item_code": row.get("item_code"),
"item_name": row.get("item_name"),
"warehouse": row.get("warehouse"),
"actual_qty": row.get("actual_qty"),
"qty_after_transaction": row.get("qty_after_transaction"),
"previous_qty": previous_qty,
"voucher_type": row.get("voucher_type"),
"voucher_no": row.get("voucher_no"),
}
)
previous_qty = row.get("qty_after_transaction")
return batch_negative_data
def get_batches(filters):
batch_filters = {}
if filters.get("item_code"):
batch_filters["item"] = filters["item_code"]
return frappe.get_all("Batch", pluck="name", filters=batch_filters)
def get_companies(filters):
company_filters = {}
if filters.get("company"):
company_filters["name"] = filters["company"]
return frappe.get_all("Company", pluck="name", filters=company_filters)