Compare commits

...

10 Commits

Author SHA1 Message Date
Mihir Kandoi
bf9744e145 fix(manufacturing): correct nested BOM quantities 2026-08-10 13:25:38 +05:30
Shllokkk
a25decfa50 fix: skip incoming rate calc when serial no qty is zero (#57427) 2026-08-10 11:54:49 +05:30
Pandiyan P
399ff463cc fix: re-check future sle before queuing repost on submit (#57664)
* test: cover both repost branches and the no-repost case

* fix: queue repost for entries backdated by a concurrent submit

---------

Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
2026-08-10 11:53:49 +05:30
Khushi Rawat
d618ad24f1 Merge pull request #57822 from Shllokkk/asset-repair-downtime-recalc
fix: keep asset repair downtime in sync with entered dates
2026-08-10 11:50:22 +05:30
Diptanil Saha
e342bf765e fix: escape customer_details on lead creation from appointment (#57947) 2026-08-09 19:14:33 +00:00
Shllokkk
5486fbff03 Merge pull request #57927 from Shllokkk/sales-register-ledger-pos-paid
fix: reflect in-invoice receivable credits in Sales Register ledger view
2026-08-09 23:39:10 +05:30
Shllokkk
45a9294476 test: cover POS-paid invoice in Sales Register ledger view 2026-08-09 20:22:14 +05:30
Shllokkk
40c356d166 fix: reflect in-invoice receivable settlements in Sales Register ledger view 2026-08-09 20:22:12 +05:30
Shllokkk
8269f8a362 test: assert asset repair downtime recalculates on date change 2026-08-06 00:33:26 +05:30
Shllokkk
4406bb9068 fix: keep asset repair downtime in sync with entered dates 2026-08-06 00:33:24 +05:30
12 changed files with 330 additions and 21 deletions

View File

@@ -160,7 +160,8 @@ def _execute(filters, additional_table_columns=None):
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
# credits the invoice itself posts to the receivable (mirrors its GL)
"credit": get_in_invoice_receivable_credit(inv),
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
@@ -181,6 +182,14 @@ def _execute(filters, additional_table_columns=None):
return columns, res, None, None, None, include_payments
def get_in_invoice_receivable_credit(inv):
# amount the invoice settles against its own receivable, matching the invoice's GL entries
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
return credit
def get_columns(invoice_list, additional_table_columns, include_payments=False):
"""return columns based on filters"""
columns = [
@@ -458,6 +467,11 @@ def get_invoices(filters, additional_query_columns):
si.base_net_total,
si.base_grand_total,
si.base_rounded_total,
si.is_pos,
si.base_paid_amount,
si.base_change_amount,
si.base_write_off_amount,
si.loyalty_amount,
si.outstanding_amount,
si.is_internal_customer,
si.represents_company,

View File

@@ -1,6 +1,7 @@
import frappe
from frappe.utils import add_days, flt, getdate, today
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -251,6 +252,46 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
self.assertDictEqual(result_output, expected_result)
def test_ledger_view_nets_pos_paid_invoice(self):
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
# and net to zero instead of showing a phantom outstanding.
make_pos_profile()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_save=1,
)
si.is_pos = 1
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si = si.save().submit()
self.assertEqual(flt(si.outstanding_amount), 0.0)
filters = frappe._dict(
{
"from_date": today(),
"to_date": today(),
"company": self.company,
"include_payments": True,
"customer": self.customer,
}
)
rows = execute(filters)[1]
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
self.assertEqual(flt(inv_row.get("debit")), 100.0)
self.assertEqual(flt(inv_row.get("credit")), 100.0)
# running balance is unchanged by a fully-paid POS invoice
idx = rows.index(inv_row)
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
def test_outstanding_currency_conversion(self):
foreign_invoice = create_sales_invoice(
customer="_Test Customer",

View File

@@ -116,24 +116,39 @@ frappe.ui.form.on("Asset Repair", {
},
repair_status: (frm) => {
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
}
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
frm.set_value("completion_date", frappe.datetime.now_datetime());
}
frm.events.set_downtime(frm);
},
failure_date: (frm) => {
frm.events.set_downtime(frm);
},
completion_date: (frm) => {
frm.events.set_downtime(frm);
},
set_downtime: (frm) => {
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
frm.set_value("downtime", null);
return;
}
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
},
stock_items_on_form_rendered() {

View File

@@ -67,6 +67,7 @@ class AssetRepair(AccountsController):
self.calculate_repair_cost()
self.calculate_total_repair_cost()
self.check_repair_status()
self.set_downtime()
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Scrapped"):
@@ -239,6 +240,13 @@ class AssetRepair(AccountsController):
if self.repair_status == "Pending" and self.docstatus == 1:
frappe.throw(_("Please update Repair Status."))
def set_downtime(self):
# keep downtime in sync with the entered dates, regardless of edit order
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
else:
self.downtime = None
def update_asset_value(self):
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost

View File

@@ -98,6 +98,21 @@ class TestAssetRepair(ERPNextTestSuite):
asset_repair = create_asset_repair(submit=1)
self.assertNotEqual(asset_repair.repair_status, "Pending")
def test_downtime_stays_in_sync_with_dates(self):
asset = create_asset(submit=1)
asset_repair = create_asset_repair(asset=asset)
asset_repair.failure_date = "2026-07-31 09:00:00"
asset_repair.completion_date = "2026-07-31 11:00:00"
asset_repair.repair_status = "Completed"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
# editing a date must refresh downtime, not leave a stale value
asset_repair.completion_date = "2026-07-31 14:30:00"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
def test_stock_items(self):
asset_repair = create_asset_repair(stock_consumption=1)
self.assertTrue(asset_repair.stock_consumption)

View File

@@ -702,6 +702,11 @@ def is_reposting_pending():
)
def invalidate_future_sle_cache(voucher_type, voucher_no):
if hasattr(frappe.local, "future_sle"):
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
def future_sle_exists(args, sl_entries=None):
from erpnext.stock.utils import get_combine_datetime

View File

@@ -40,3 +40,140 @@ class TestStockControllerConversions(ERPNextTestSuite):
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
self.assertTrue(future_sle_exists(args, sl_entries))
def _make_opening_entry(self, item, warehouse):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
opening = make_stock_entry(
item_code=item,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(today(), -5),
posting_time="01:00:00",
)
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
return opening
def _later_sle(self, item, warehouse, opening):
sle = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": warehouse,
"posting_date": today(),
"posting_time": "12:00:00",
"voucher_type": "Stock Entry",
"voucher_no": opening.name,
"actual_qty": 7,
"incoming_rate": 100,
"qty_after_transaction": 107,
"valuation_rate": 100,
"stock_value": 10700,
"company": opening.company,
"stock_uom": "Nos",
}
)
sle.flags.ignore_permissions = True
sle.flags.ignore_links = True
return sle
def _submit_entry(self, item, warehouse, inject=None):
from erpnext.stock import stock_ledger
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
original_make_entry = stock_ledger.make_entry
injected = []
def make_entry_with_injection(*args, **kwargs):
if inject is not None and not injected:
injected.append(True)
inject.submit()
return original_make_entry(*args, **kwargs)
stock_ledger.make_entry = make_entry_with_injection
try:
entry = make_stock_entry(
item_code=item,
target=warehouse,
qty=5,
basic_rate=500,
posting_date=today(),
posting_time="06:00:00",
)
finally:
stock_ledger.make_entry = original_make_entry
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
if inject is not None:
self.assertTrue(injected, "the later SL Entry was not written during the submit")
return entry
def _reposts_queued_for(self, item, warehouse, voucher_no):
names = set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
pluck="name",
)
) | set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": voucher_no},
pluck="name",
)
)
for name in names:
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
return names
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
self._reposts_queued_for(item, warehouse, backdated.name),
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
)
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": backdated.name},
pluck="name",
),
"No voucher based Repost Item Valuation was queued",
)
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
self._make_opening_entry(item, warehouse)
entry = self._submit_entry(item, warehouse)
self.assertFalse(
self._reposts_queued_for(item, warehouse, entry.name),
"A Repost Item Valuation was queued for an entry with nothing posted after it",
)

View File

@@ -13,6 +13,7 @@ from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from frappe.utils.html_utils import escape_html
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -269,7 +270,11 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
{
"note": escape_html(self.customer_details),
"added_by": frappe.session.user,
"added_on": now(),
},
)
self.party = lead.insert(ignore_permissions=True).name

View File

@@ -25,10 +25,12 @@ def fetch_exploded_bom_items(root_bom):
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
and fetches each sub-BOM's items only once even when it is reused across the tree."""
bom_item = frappe.qb.DocType("BOM Item")
child_bom = frappe.qb.DocType("BOM").as_("child_bom")
tree = frappe.qb.Table("exploded_bom")
fields = [
bom_item.parent,
bom_item.qty,
bom_item.stock_qty,
bom_item.bom_no,
bom_item.item_code,
bom_item.item_name,
@@ -46,7 +48,11 @@ def fetch_exploded_bom_items(root_bom):
.where(tree.bom_no != "")
)
rows = (
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True)
.from_(tree)
.left_join(child_bom)
.on(tree.bom_no == child_bom.name)
.select(tree.star, child_bom.quantity.as_("child_bom_qty"))
).run(as_dict=True)
children_map = defaultdict(list)
@@ -71,7 +77,13 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
}
)
if item.bom_no:
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
build_exploded_rows(
item.bom_no,
children_map,
data,
indent + 1,
qty * item.stock_qty / item.child_bom_qty,
)
def get_columns():

View File

@@ -4,7 +4,7 @@
import frappe
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
from erpnext.manufacturing.report.bom_explorer.bom_explorer import build_exploded_rows, execute
from erpnext.tests.utils import ERPNextTestSuite
@@ -78,3 +78,55 @@ class TestBOMExplorer(ERPNextTestSuite):
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)
def test_nested_bom_uses_stock_qty_for_output_normalization(self):
parent_bom = create_nested_bom(
{"parent": {"sub": {"leaf": {}}}},
prefix="_Test explorer converted quantity ",
)
sub_bom = frappe.get_doc("BOM", parent_bom.items[0].bom_no)
# The parent needs two boxes (20 units). The child BOM produces five units per batch.
frappe.db.set_value("BOM", sub_bom.name, "quantity", 5)
frappe.db.set_value("BOM Item", sub_bom.items[0].name, {"qty": 3, "stock_qty": 3})
frappe.db.set_value(
"BOM Item",
parent_bom.items[0].name,
{"qty": 2, "uom": "Box", "conversion_factor": 10, "stock_qty": 20},
)
data = self.run_report(parent_bom.name)
rows_by_item = {row["item_code"]: row for row in data}
self.assertEqual(rows_by_item["_Test explorer converted quantity sub"]["qty"], 2)
self.assertEqual(rows_by_item["_Test explorer converted quantity leaf"]["qty"], 12)
def test_nested_bom_multiplies_qty_at_every_level(self):
children_map = {
"root": [
frappe._dict(
item_code="parent",
idx=1,
bom_no="parent-bom",
child_bom_qty=1,
qty=8,
stock_qty=8,
)
],
"parent-bom": [
frappe._dict(
item_code="child",
idx=1,
bom_no="child-bom",
child_bom_qty=1,
qty=4,
stock_qty=4,
)
],
"child-bom": [frappe._dict(item_code="raw-material", idx=1, bom_no="", qty=2, stock_qty=2)],
}
data = []
build_exploded_rows("root", children_map, data)
self.assertEqual([row["qty"] for row in data], [8, 32, 64])

View File

@@ -789,6 +789,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse)
def get_incoming_rate(self):
if not self.sle.actual_qty and self.sle.voucher_type == "Stock Reconciliation":
return 0.0
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
def get_incoming_rate_of_serial_no(self, serial_no):

View File

@@ -137,7 +137,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
such cases certain validations need to be ignored (like negative
stock)
"""
from erpnext.controllers.stock_controller import future_sle_exists
from erpnext.controllers.stock_controller import future_sle_exists, invalidate_future_sle_cache
if sl_entries:
# Sorted so two vouchers touching the same pairs can't take the gates in opposite order.
@@ -195,6 +195,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))
)
invalidate_future_sle_cache(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False, cancelled=False):
if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation":