mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-18 17:08:42 +00:00
refactor(stock): convert serial_no lookups to ORM
Replace the on_trash Stock Ledger Entry serial-match SELECT and the update_maintenance_status expiry SELECT with frappe.get_all (or_filters for the amc/warranty expiry OR). Same result on MariaDB; valid under Postgres. Tests: maintenance-status expiry transition, the not-in exclusion (with a negative-control candidate), and NULL-status handling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -101,11 +101,10 @@ class SerialNo(StockController):
|
||||
self.maintenance_status = "Under Warranty"
|
||||
|
||||
def on_trash(self):
|
||||
sl_entries = frappe.db.sql(
|
||||
"""select serial_no from `tabStock Ledger Entry`
|
||||
where serial_no like %s and item_code=%s and is_cancelled=0""",
|
||||
("%%%s%%" % self.name, self.item_code),
|
||||
as_dict=True,
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"serial_no": ["like", f"%{self.name}%"], "item_code": self.item_code, "is_cancelled": 0},
|
||||
fields=["serial_no"],
|
||||
)
|
||||
|
||||
# Find the exact match
|
||||
@@ -172,13 +171,14 @@ def clean_serial_no_string(serial_no: str) -> str:
|
||||
|
||||
|
||||
def update_maintenance_status():
|
||||
serial_nos = frappe.db.sql(
|
||||
"""select name from `tabSerial No` where (amc_expiry_date<%s or
|
||||
warranty_expiry_date<%s) and maintenance_status not in ('Out of Warranty', 'Out of AMC')""",
|
||||
(nowdate(), nowdate()),
|
||||
serial_nos = frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"maintenance_status": ["not in", ["Out of Warranty", "Out of AMC"]]},
|
||||
or_filters=[["amc_expiry_date", "<", nowdate()], ["warranty_expiry_date", "<", nowdate()]],
|
||||
pluck="name",
|
||||
)
|
||||
for serial_no in serial_nos:
|
||||
doc = frappe.get_doc("Serial No", serial_no[0])
|
||||
doc = frappe.get_doc("Serial No", serial_no)
|
||||
doc.set_maintenance_status()
|
||||
frappe.db.set_value("Serial No", doc.name, "maintenance_status", doc.maintenance_status)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _dict
|
||||
from frappe.utils import add_days, nowdate, random_string
|
||||
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -16,6 +17,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
get_serial_nos_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import *
|
||||
from erpnext.stock.doctype.serial_no.serial_no import update_maintenance_status
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
@@ -331,6 +333,129 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(non_expired_serials, [])
|
||||
|
||||
def test_update_maintenance_status_expires_past_warranty(self):
|
||||
"""update_maintenance_status() must pick up the past-warranty Serial No via or_filters and flip it Out of Warranty."""
|
||||
item_code = "_Test Serialized Item"
|
||||
past_date = add_days(nowdate(), -10)
|
||||
future_date = add_days(nowdate(), 10)
|
||||
|
||||
# Serial No whose warranty has lapsed; force maintenance_status back to a
|
||||
# value that passes the `not in [Out of Warranty, Out of AMC]` filter so the
|
||||
# cron job is the thing that actually transitions it.
|
||||
expired_sr = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": "_TCWARREXP" + random_string(6),
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
frappe.db.set_value("Serial No", expired_sr.name, "maintenance_status", "Under Warranty")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", expired_sr.name, "maintenance_status"), "Under Warranty"
|
||||
)
|
||||
|
||||
# Serial No whose warranty is still valid; it must stay Under Warranty.
|
||||
active_sr = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": "_TCWARRACT" + random_string(6),
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", active_sr.name, "maintenance_status"), "Under Warranty"
|
||||
)
|
||||
|
||||
update_maintenance_status()
|
||||
|
||||
# The lapsed Serial No was selected by the or_filters and re-evaluated.
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", expired_sr.name, "maintenance_status"), "Out of Warranty"
|
||||
)
|
||||
# The in-warranty Serial No keeps its correct Under Warranty status.
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", active_sr.name, "maintenance_status"), "Under Warranty"
|
||||
)
|
||||
|
||||
def test_update_maintenance_status_excludes_out_of_amc(self):
|
||||
"""The `not in [Out of Warranty, Out of AMC]` filter must skip rows already pinned to
|
||||
those statuses, even when they match the expiry or_filters, while rows in any other
|
||||
status ARE re-evaluated. The contrast makes the `not in` clause load-bearing."""
|
||||
item_code = "_Test Serialized Item"
|
||||
past_date = add_days(nowdate(), -10)
|
||||
future_date = add_days(nowdate(), 10)
|
||||
|
||||
# Excluded row: matches or_filters (amc lapsed) AND is pinned to "Out of AMC", so the
|
||||
# `not in` filter must skip it. Its warranty is in the FUTURE, so if the filter were
|
||||
# broken and the row were re-evaluated, set_maintenance_status() would flip it to
|
||||
# "Under Warranty" (the last cascade branch to match). Staying "Out of AMC" proves it.
|
||||
excluded_sr = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": "_TCAMCEXCL" + random_string(6),
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
frappe.db.set_value("Serial No", excluded_sr.name, "maintenance_status", "Out of AMC")
|
||||
|
||||
# Negative control: same lapsed amc date, but a status NOT in the excluded list, so it
|
||||
# must be picked up and re-evaluated to "Out of AMC". This proves update_maintenance_status()
|
||||
# actually processes candidates — i.e. the exclusion above is meaningful, not a no-op.
|
||||
candidate_sr = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": "_TCAMCCAND" + random_string(6),
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
frappe.db.set_value("Serial No", candidate_sr.name, "maintenance_status", "Under AMC")
|
||||
|
||||
update_maintenance_status()
|
||||
|
||||
# Excluded by the `not in` filter -> pinned status left untouched.
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", excluded_sr.name, "maintenance_status"), "Out of AMC"
|
||||
)
|
||||
# Not excluded -> re-evaluated; lapsed amc -> "Out of AMC".
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", candidate_sr.name, "maintenance_status"), "Out of AMC"
|
||||
)
|
||||
|
||||
def test_update_maintenance_status_includes_null_status(self):
|
||||
"""Converting the raw `maintenance_status not in (...)` to a get_all filter changes NULL
|
||||
handling: frappe wraps the clause as `ifnull(maintenance_status, '') not in (...)`, so a
|
||||
NULL-status row that matches the expiry or_filters is now re-evaluated (consistently on
|
||||
MariaDB and Postgres). Pin that contract."""
|
||||
item_code = "_Test Serialized Item"
|
||||
past_date = add_days(nowdate(), -10)
|
||||
|
||||
null_sr = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": "_TCAMCNULL" + random_string(6),
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
# Force a NULL maintenance_status while a lapsed amc date keeps the row in or_filters.
|
||||
frappe.db.set_value("Serial No", null_sr.name, "maintenance_status", None)
|
||||
self.assertIsNone(frappe.db.get_value("Serial No", null_sr.name, "maintenance_status"))
|
||||
|
||||
update_maintenance_status()
|
||||
|
||||
# Picked up (NULL -> '' -> not in the excluded list) and re-evaluated: lapsed amc -> "Out of AMC".
|
||||
self.assertEqual(frappe.db.get_value("Serial No", null_sr.name, "maintenance_status"), "Out of AMC")
|
||||
|
||||
|
||||
def get_auto_serial_nos(kwargs):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
|
||||
Reference in New Issue
Block a user