mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user