mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 23:18:40 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user