refactor(stock): convert Material Request raw SQL to ORM

validate_qty_against_so: the already-indented (Material Request Item) and
Sales-Order-qty (Sales Order Item) sum lookups -> frappe.get_all({SUM}).
check_modified_date: raw `select modified` + MariaDB-only `TIMEDIFF` ->
frappe.db.get_value + a get_datetime() comparison. The TIMEDIFF removal also
fixes a real Postgres bug: update_status() (Stop/Reopen/Cancel) ran TIMEDIFF,
which errors on PG (`function timediff does not exist`); this greens 7
previously-failing status-change tests on Postgres.

Same result on MariaDB. Tests: concurrent-modification guard (pass + throw
branches) and the over-request-against-SO throw (both converted SUM queries +
the boundary). mapper.py is intentionally left untouched (no raw SQL; its
staging copy predates develop's RFQ cost_center field-map).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-20 09:16:57 +05:30
parent 4479d7ff18
commit 4062f72bdb
2 changed files with 79 additions and 16 deletions

View File

@@ -13,7 +13,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate
from frappe.utils import cint, flt, get_datetime, get_link_to_form, getdate, new_line_sep, nowdate
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
@@ -125,21 +125,24 @@ class MaterialRequest(BuyingController):
for so_no in so_items.keys():
for item in so_items[so_no].keys():
already_indented = frappe.db.sql(
"""select sum(qty)
from `tabMaterial Request Item`
where item_code = %s and sales_order = %s and
docstatus = 1 and parent != %s""",
(item, so_no, self.name),
already_indented = frappe.get_all(
"Material Request Item",
filters={
"item_code": item,
"sales_order": so_no,
"docstatus": 1,
"parent": ["!=", self.name],
},
fields=[{"SUM": "qty", "as": "qty"}],
)
already_indented = already_indented and flt(already_indented[0][0]) or 0
already_indented = flt(already_indented[0].qty) if already_indented else 0
actual_so_qty = frappe.db.sql(
"""select sum(stock_qty) from `tabSales Order Item`
where parent = %s and item_code = %s and docstatus = 1""",
(so_no, item),
actual_so_qty = frappe.get_all(
"Sales Order Item",
filters={"parent": so_no, "item_code": item, "docstatus": 1},
fields=[{"SUM": "stock_qty", "as": "stock_qty"}],
)
actual_so_qty = actual_so_qty and flt(actual_so_qty[0][0]) or 0
actual_so_qty = flt(actual_so_qty[0].stock_qty) if actual_so_qty else 0
if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty):
frappe.throw(
@@ -249,10 +252,9 @@ class MaterialRequest(BuyingController):
self.set_status(update=True, status="Cancelled")
def check_modified_date(self):
mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name)
date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified)))
mod_db = frappe.db.get_value("Material Request", self.name, "modified")
if date_diff and date_diff[0][0]:
if mod_db and get_datetime(mod_db) != get_datetime(self.modified):
frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name))
def update_status(self, status):

View File

@@ -1191,6 +1191,67 @@ class TestMaterialRequest(ERPNextTestSuite):
self.assertEqual(material_request.status, "Transferred")
self.assertEqual(material_request.transfer_status, "Completed")
def test_check_modified_date_detects_concurrent_modification(self):
"""check_modified_date must raise when the in-memory doc is stale vs the DB modified
timestamp. Covers the converted get_value + get_datetime comparison that replaced the
raw MariaDB-only TIMEDIFF (which errors on Postgres); update_status() runs this guard."""
from frappe.utils import add_to_date, get_datetime
mr = make_material_request(qty=10)
fresh = frappe.get_doc("Material Request", mr.name)
# modified matches the DB row -> guard passes.
fresh.check_modified_date()
# Stale in-memory modified -> concurrent-modification guard must fire.
fresh.modified = add_to_date(get_datetime(fresh.modified), seconds=-120)
with self.assertRaises(frappe.ValidationError) as cm:
fresh.check_modified_date()
self.assertIn("has been modified", str(cm.exception))
def test_validate_qty_against_so_blocks_over_request(self):
"""validate_qty_against_so must block requesting more than the Sales Order qty, net of
already-indented submitted MRs. Covers the converted Sales Order Item and Material Request
Item SUM queries. (The guard is currently not wired into validate(), so call it directly.)"""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
item_code = "_Test Item"
so = make_sales_order(item_code=item_code, qty=10) # submitted -> SO Item stock_qty 10
def _mr_against_so(qty):
mr = frappe.new_doc("Material Request")
mr.material_request_type = "Purchase"
mr.company = "_Test Company"
mr.append(
"items",
{
"item_code": item_code,
"qty": qty,
"uom": "_Test UOM",
"conversion_factor": 1,
"schedule_date": today(),
"warehouse": "_Test Warehouse - _TC",
"sales_order": so.name,
},
)
return mr
# An already-submitted MR consuming 6 of the SO's 10.
mr1 = _mr_against_so(6)
mr1.insert()
mr1.submit()
# A new request for 5 more -> already_indented 6 + 5 = 11 > 10 -> must throw.
over = _mr_against_so(5)
over.insert()
with self.assertRaises(frappe.ValidationError) as cm:
over.validate_qty_against_so()
self.assertIn("maximum", str(cm.exception))
# Exactly within the remaining 4 -> 6 + 4 = 10, not greater -> no throw.
over.items[0].qty = 4
over.validate_qty_against_so()
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):