diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index c020ab35f90..a806b5b6a31 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -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): diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 5406d722b3f..0e1c42c0828 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -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"):