From ea5cbb116c95a8fe2314d220b4784dd67615ac03 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 8 Aug 2026 19:17:53 +0530 Subject: [PATCH] feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-16-hotfix) (#57864) * feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report * fix: pick last bundle move in SQL ordered by posting datetime and SLE creation * fix: derive synced serial no status from stock ledger helper and validate sync args --- .../stock_qty_vs_serial_no_count.js | 24 +++ .../stock_qty_vs_serial_no_count.py | 175 ++++++++++++++++++ .../test_stock_qty_vs_serial_no_count.py | 41 ++++ 3 files changed, 240 insertions(+) create mode 100644 erpnext/stock/report/stock_qty_vs_serial_no_count/test_stock_qty_vs_serial_no_count.py diff --git a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.js b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.js index c38f0237436..6df8458fd3c 100644 --- a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.js +++ b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.js @@ -2,6 +2,30 @@ // For license information, please see license.txt frappe.query_reports["Stock Qty vs Serial No Count"] = { + onload: function (report) { + report.page.add_inner_button(__("Sync Serial No Status"), () => { + const warehouse = report.get_filter_value("warehouse"); + if (!warehouse) { + frappe.msgprint(__("Please select a warehouse first.")); + return; + } + + frappe.confirm( + __( + "This will update the warehouse and status of Serial Nos counted in {0} to match the stock ledger. Continue?", + [warehouse.bold()] + ), + () => { + frappe.call({ + method: "erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count.sync_serial_no_status", + args: { warehouse: warehouse }, + freeze: true, + }); + } + ); + }); + }, + filters: [ { fieldname: "company", diff --git a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py index 6087c747374..2ea732180ad 100644 --- a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py +++ b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py @@ -4,6 +4,12 @@ import frappe from frappe import _ +from frappe.query_builder import Order +from frappe.query_builder.functions import Coalesce +from frappe.utils import cstr, flt +from pypika import analytics as an + +from erpnext.stock.serial_batch_bundle import get_serial_no_status def execute(filters=None): @@ -77,3 +83,172 @@ def get_data(warehouse, show_disabled_items): data.append(row) return data + + +SYNC_CHUNK_SIZE = 1000 + + +@frappe.whitelist(methods=["POST"]) +def sync_serial_no_status(warehouse: str, item_code: str | None = None): + if not frappe.has_permission("Serial No", "write"): + frappe.throw(_("Not permitted to update Serial No"), frappe.PermissionError) + + warehouse = cstr(warehouse) + item_code = cstr(item_code) if item_code else None + if not frappe.db.exists("Warehouse", warehouse): + frappe.throw(_("Warehouse {0} does not exist").format(warehouse)) + + if item_code and not frappe.db.exists("Item", item_code): + frappe.throw(_("Item {0} does not exist").format(item_code)) + + frappe.enqueue( + sync_serial_no_status_for_warehouse, + queue="long", + warehouse=warehouse, + item_code=item_code, + ) + frappe.msgprint( + _("Serial No status sync has been queued. Reload the report after a few minutes."), + alert=True, + ) + + +def sync_serial_no_status_for_warehouse(warehouse, item_code=None): + filters = {"has_serial_no": 1} + if item_code: + filters["name"] = item_code + + for item in frappe.get_all("Item", filters=filters, pluck="name"): + sync_serial_no_status_for_item(item, warehouse) + + +def sync_serial_no_status_for_item(item_code, warehouse): + """Correct Serial No records this report counts in the warehouse but whose last + stock ledger movement says the stock left it. Reposting rebuilds qty and valuation + from the ledger but never rewrites Serial No warehouse/status, so records orphaned + by cancelled or amended vouchers keep inflating the serial count.""" + serial_nos = frappe.get_all( + "Serial No", + filters={"item_code": item_code, "warehouse": warehouse, "status": ("in", ["Active", "Expired"])}, + pluck="name", + ) + if not serial_nos: + return + + last_moves = get_last_ledger_moves(item_code, serial_nos) + for serial_no in serial_nos: + row = last_moves.get(serial_no) + if row and flt(row.qty) > 0 and row.warehouse == warehouse: + continue + + set_serial_no_state_from_ledger(serial_no, row) + + +def set_serial_no_state_from_ledger(serial_no, row): + if not row: + frappe.db.set_value( + "Serial No", serial_no, {"warehouse": None, "status": "Inactive"}, update_modified=False + ) + return + + status = get_serial_no_status( + frappe._dict( + actual_qty=flt(row.qty), + warehouse=row.warehouse, + voucher_type=row.voucher_type, + voucher_no=row.voucher_no, + is_cancelled=0, + ) + ) + warehouse = row.warehouse if status == "Active" else None + frappe.db.set_value( + "Serial No", serial_no, {"warehouse": warehouse, "status": status}, update_modified=False + ) + + +def get_last_ledger_moves(item_code, serial_nos): + last_moves = get_last_bundle_moves(item_code, serial_nos) + if missing := [serial_no for serial_no in serial_nos if serial_no not in last_moves]: + set_legacy_last_moves(item_code, missing, last_moves) + + return last_moves + + +def get_last_bundle_moves(item_code, serial_nos): + last_moves = {} + for start in range(0, len(serial_nos), SYNC_CHUNK_SIZE): + for row in get_last_bundle_moves_chunk(item_code, serial_nos[start : start + SYNC_CHUNK_SIZE]): + last_moves[row.serial_no] = row + + return last_moves + + +def get_last_bundle_moves_chunk(item_code, serial_nos): + """A bundle can be created much before its Stock Ledger Entry, so same-posting-datetime + ties are broken on the creation of the bundle's own SLE. The SLE join also keeps only + real stock movements - reservation bundles (Pick List) carry no SLE.""" + entry = frappe.qb.DocType("Serial and Batch Entry") + bundle = frappe.qb.DocType("Serial and Batch Bundle") + sle = frappe.qb.DocType("Stock Ledger Entry") + + row_number = ( + an.RowNumber() + .over(entry.serial_no) + .orderby(Coalesce(entry.posting_datetime, bundle.posting_datetime), order=Order.desc) + .orderby(sle.creation, order=Order.desc) + ) + + ranked = ( + frappe.qb.from_(entry) + .inner_join(bundle) + .on(entry.parent == bundle.name) + .inner_join(sle) + .on(sle.serial_and_batch_bundle == bundle.name) + .select( + entry.serial_no, + entry.qty, + Coalesce(entry.warehouse, bundle.warehouse).as_("warehouse"), + bundle.voucher_type, + bundle.voucher_no, + row_number.as_("row_no"), + ) + .where( + (bundle.docstatus == 1) + & (Coalesce(bundle.is_cancelled, 0) == 0) + & (sle.is_cancelled == 0) + & (bundle.item_code == item_code) + & (entry.serial_no.isin(serial_nos)) + ) + ).as_("ranked") + + return ( + frappe.qb.from_(ranked) + .select(ranked.serial_no, ranked.qty, ranked.warehouse, ranked.voucher_type, ranked.voucher_no) + .where(ranked.row_no == 1) + .run(as_dict=True) + ) + + +def set_legacy_last_moves(item_code, serial_nos, last_moves): + """Movements posted before Serial and Batch Bundle exist only as newline-separated + text on Stock Ledger Entry.""" + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + pending = set(serial_nos) + rows = frappe.get_all( + "Stock Ledger Entry", + filters={"item_code": item_code, "is_cancelled": 0, "serial_no": ("is", "set")}, + fields=["serial_no", "actual_qty", "warehouse", "voucher_type", "voucher_no"], + order_by="posting_datetime asc, creation asc", + ) + + for row in rows: + qty = 1 if flt(row.actual_qty) > 0 else -1 + for serial_no in get_serial_nos(row.serial_no): + if serial_no in pending: + last_moves[serial_no] = frappe._dict( + qty=qty, + warehouse=row.warehouse, + voucher_type=row.voucher_type, + voucher_no=row.voucher_no, + ) diff --git a/erpnext/stock/report/stock_qty_vs_serial_no_count/test_stock_qty_vs_serial_no_count.py b/erpnext/stock/report/stock_qty_vs_serial_no_count/test_stock_qty_vs_serial_no_count.py new file mode 100644 index 00000000000..ba72eaff63f --- /dev/null +++ b/erpnext/stock/report/stock_qty_vs_serial_no_count/test_stock_qty_vs_serial_no_count.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + + +class TestStockQtyVsSerialNoCount(ERPNextTestSuite): + def test_sync_serial_no_status(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count import ( + sync_serial_no_status_for_warehouse, + ) + + item = "_Test Serialized Item With Series" + warehouse = "Stores - _TC" + se = make_stock_entry(item_code=item, to_warehouse=warehouse, qty=2, rate=100) + serial_no = frappe.get_all( + "Serial and Batch Entry", + {"parent": se.items[0].serial_and_batch_bundle}, + pluck="serial_no", + )[0] + + create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + ) + self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered") + + frappe.db.set_value("Serial No", serial_no, {"status": "Active", "warehouse": warehouse}) + + sync_serial_no_status_for_warehouse(warehouse, item_code=item) + + details = frappe.db.get_value("Serial No", serial_no, ["status", "warehouse"], as_dict=True) + self.assertEqual(details.status, "Delivered") + self.assertFalse(details.warehouse)