From 5b68db0156c02beb3bb51d88215dc30851392bee Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 12 Aug 2026 11:39:53 +0530 Subject: [PATCH] test(manufacturing): cover MRP chart date range (cherry picked from commit 592924cc0d382c2ecc9188fc2b16484a271fdaf8) # Conflicts: # erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py --- ...t_material_requirements_planning_report.py | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py b/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py new file mode 100644 index 00000000000..d91f44be985 --- /dev/null +++ b/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py @@ -0,0 +1,205 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.utils import add_days, flt, formatdate, today + +from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule +from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report import ( + MaterialRequirementsPlanningReport, + execute, + get_item_lead_time, + make_order, +) +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestSuite + +COMPANY = "_Test Company" +WAREHOUSE = "_Test Warehouse - _TC" +SUPPLIER = "_Test Supplier" +TAX_TEMPLATE = "_Test Purchase Taxes and Charges Template - _TC" + + +class TestMaterialRequirementsPlanningReport(ERPNextTestSuite): + def test_detailed_chart_includes_full_date_range(self): + start_date = add_days(today(), 1) + delivery_dates = [add_days(start_date, offset) for offset in range(12)] + rows = [make_chart_row(delivery_date) for delivery_date in delivery_dates] + rows.append(make_chart_row(delivery_dates[-1], planned_qty=2)) + + chart = MaterialRequirementsPlanningReport(frappe._dict()).get_detailed_view_chart_data(rows) + + self.assertEqual( + chart["data"]["labels"], + [formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates], + ) + self.assertEqual(chart["data"]["datasets"][0]["values"], [1] * 11 + [3]) + + def test_detailed_chart_excludes_past_and_empty_delivery_dates(self): + delivery_dates = [today(), add_days(today(), 1)] + rows = [ + make_chart_row(add_days(today(), -1)), + make_chart_row(None), + *[make_chart_row(delivery_date) for delivery_date in delivery_dates], + ] + + chart = MaterialRequirementsPlanningReport(frappe._dict()).get_detailed_view_chart_data(rows) + + self.assertEqual( + chart["data"]["labels"], + [formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates], + ) + + def test_manufacture_lead_time_is_not_int_truncated(self): + """lead_time = 1440 / manufacturing_time_in_mins + buffer_time. Both columns are Int; + integer/integer division truncates on Postgres (1440/7 -> 205) while MariaDB yields a + decimal, so the computed lead time (and the derived release date) diverged by engine.""" + item = make_item("_Test MRP Lead Time Item", {"is_stock_item": 1}).name + frappe.get_doc( + { + "doctype": "Item Lead Time", + "item_code": item, + "manufacturing_time_in_mins": 7, + "buffer_time": 2, + } + ).insert() + + lead_time = get_item_lead_time(item, "Manufacture") + # 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207. + self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2) + + def test_make_order_creates_draft_purchase_and_work_orders(self): + plan = make_mrp_plan(self) + + make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps) + + purchase_order = get_created_order(plan.mps, "Purchase Order") + self.assertEqual(purchase_order.docstatus, 0) + self.assertEqual(purchase_order.supplier, SUPPLIER) + self.assertEqual([d.item_code for d in purchase_order.items], [plan.rm_item]) + self.assertEqual(purchase_order.items[0].qty, plan.planned_qty * plan.rm_qty) + + work_order = get_created_order(plan.mps, "Work Order") + self.assertEqual(work_order.docstatus, 0) + self.assertEqual(work_order.production_item, plan.fg_item) + self.assertEqual(work_order.bom_no, plan.bom) + self.assertEqual(work_order.qty, plan.planned_qty) + + def test_purchase_order_gets_defaults_from_set_missing_values(self): + plan = make_mrp_plan(self) + make_tax_rule(tax_type="Purchase", purchase_tax_template=TAX_TEMPLATE, priority=1, save=1) + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": plan.rm_item, + "price_list": "Standard Buying", + "price_list_rate": 100, + } + ).insert() + + make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps) + + purchase_order = get_created_order(plan.mps, "Purchase Order") + self.assertEqual(purchase_order.buying_price_list, "Standard Buying") + self.assertEqual(purchase_order.items[0].rate, 100) + template = frappe.get_doc("Purchase Taxes and Charges Template", TAX_TEMPLATE) + self.assertEqual(purchase_order.taxes_and_charges, TAX_TEMPLATE) + self.assertEqual([d.rate for d in purchase_order.taxes], [d.rate for d in template.taxes]) + + net_total = flt(purchase_order.net_total) + self.assertEqual( + purchase_order.grand_total, net_total + net_total * flt(template.taxes[0].rate) / 100 + ) + + +def make_chart_row(delivery_date, planned_qty=1): + return frappe._dict( + { + "delivery_date": delivery_date, + "planned_qty": planned_qty, + "in_hand_qty": 0, + "po_ordered_qty": 0, + "wo_ordered_qty": 0, + } + ) + + +def make_mrp_plan(test_case, planned_qty=10, rm_qty=2): + """Build a finished good with a submitted BOM and an MPS demanding it, then return the + report's own output rows -- the same payload the report's client sends to `make_order`.""" + rm_item = make_item( + properties={ + "is_stock_item": 1, + "is_purchase_item": 1, + "item_defaults": [ + {"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER} + ], + } + ).name + fg_item = make_item( + properties={ + "is_stock_item": 1, + "item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}], + } + ).name + + # on_submit sets Item.default_bom, which is how the report finds the raw materials + bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=rm_qty, rate=100).name + + mps = frappe.get_doc( + { + "doctype": "Master Production Schedule", + "company": COMPANY, + "posting_date": today(), + "from_date": today(), + "parent_warehouse": WAREHOUSE, + "items": [ + { + "item_code": fg_item, + "warehouse": WAREHOUSE, + "delivery_date": add_days(today(), 30), + "planned_qty": planned_qty, + "uom": frappe.get_cached_value("Item", fg_item, "stock_uom"), + } + ], + } + ) + # left in draft: on_submit enqueues MRP Log creation in a background job + mps.insert() + + _, data, _, _ = execute( + frappe._dict( + { + "company": COMPANY, + "from_date": today(), + "to_date": add_days(today(), 90), + "warehouse": WAREHOUSE, + "mps": mps.name, + "type_of_material": "All", + "add_safety_stock": 0, + } + ) + ) + + # the report separates each finished good with a blank row + rows = [row for row in data if row.get("item_code")] + test_case.assertTrue(rows, msg="the report returned no rows to create orders from") + + return frappe._dict( + rm_item=rm_item, + fg_item=fg_item, + bom=bom, + mps=mps.name, + planned_qty=planned_qty, + rm_qty=rm_qty, + rows=rows, + ) + + +def get_created_order(mps, doctype): + names = frappe.get_all(doctype, filters={"mps": mps}, pluck="name") + if len(names) != 1: + frappe.throw(f"Expected exactly one {doctype} for {mps}, got {names}") + + return frappe.get_doc(doctype, names[0])