mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 14:41:53 +00:00
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
This commit is contained in:
@@ -2,6 +2,30 @@
|
|||||||
// For license information, please see license.txt
|
// For license information, please see license.txt
|
||||||
|
|
||||||
frappe.query_reports["Stock Qty vs Serial No Count"] = {
|
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: [
|
filters: [
|
||||||
{
|
{
|
||||||
fieldname: "company",
|
fieldname: "company",
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
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):
|
def execute(filters=None):
|
||||||
@@ -77,3 +83,172 @@ def get_data(warehouse, show_disabled_items):
|
|||||||
data.append(row)
|
data.append(row)
|
||||||
|
|
||||||
return data
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user