feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report (#57863)

* 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
This commit is contained in:
rohitwaghchaure
2026-08-08 19:18:13 +05:30
committed by GitHub
parent 11a902eb5f
commit ca0a5cb67c
3 changed files with 231 additions and 0 deletions

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -46,3 +46,35 @@ 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)