From 469d58d1f447a795a06397e773751e76da38aa3c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 09:35:56 +0530 Subject: [PATCH] fix(manufacturing): make Production Planning report GROUP BY Postgres-valid get_purchase_details grouped Purchase Order Item by (item_code, warehouse) while selecting `qty` ungrouped/unaggregated. MariaDB arbitrary-picks one row's qty; Postgres rejects the query ("must appear in the GROUP BY clause"), so the report is broken on Postgres. Sum the qty per item+warehouse ({"SUM": "qty"}). The column is the "Arrival Qty" (quantity on order arriving) display figure; summing the open PO lines is the meaningful planning number, and is deterministic vs MariaDB's arbitrary single-line pick (which only differed when an item+warehouse had multiple open PO lines). Adds a test (no test file existed) that creates a Work Order plus two PO lines for a BOM raw material and asserts the report runs and reports arrival_qty = 7 (3 + 4), on both engines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../production_planning_report.py | 5 +- .../test_production_planning_report.py | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 erpnext/manufacturing/report/production_planning_report/test_production_planning_report.py diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py index 7130c2c63ea..4ce607b11a1 100644 --- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py +++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py @@ -233,7 +233,10 @@ class ProductionPlanReport: fields=[ "item_code", {"MIN": "schedule_date", "as": "arrival_date"}, - "qty as arrival_qty", + # qty is not in the GROUP BY, so it must be aggregated to be valid on postgres; sum the + # on-order qty per item+warehouse (the meaningful "arriving" figure) instead of MariaDB's + # arbitrary single-row pick. + {"SUM": "qty", "as": "arrival_qty"}, "warehouse", ], filters={ diff --git a/erpnext/manufacturing/report/production_planning_report/test_production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/test_production_planning_report.py new file mode 100644 index 00000000000..10a427aa38e --- /dev/null +++ b/erpnext/manufacturing/report/production_planning_report/test_production_planning_report.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from erpnext.tests.utils import ERPNextTestSuite + + +class TestProductionPlanningReport(ERPNextTestSuite): + def test_report_runs_and_sums_on_order_qty(self): + # get_purchase_details groups Purchase Order Item by (item_code, warehouse) while summing qty; + # this exercises that GROUP BY on both engines (loose on MariaDB, must be aggregated on Postgres). + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.report.production_planning_report.production_planning_report import execute + + wh = "_Test Warehouse - _TC" + wo = make_wo_order_test_record(production_item="_Test FG Item", qty=2, source_warehouse=wh) + self.addCleanup(self._cancel_and_delete, "Work Order", wo.name) + + rm = wo.required_items[0].item_code + for qty in (3, 4): + po = create_purchase_order(item_code=rm, warehouse=wh, qty=qty, rate=10) + self.addCleanup(self._cancel_and_delete, "Purchase Order", po.name) + + filters = { + "company": "_Test Company", + "based_on": "Work Order", + "docnames": [wo.name], + "raw_material_warehouse": wh, + } + columns, data = execute(filters) + + self.assertTrue(columns) + rm_rows = [d for d in data if d.get("item_code") == rm and d.get("arrival_qty")] + self.assertTrue(rm_rows) + # both on-order PO lines (3 + 4) are summed, not arbitrary-picked + self.assertEqual(rm_rows[0]["arrival_qty"], 7) + + @staticmethod + def _cancel_and_delete(doctype, name): + import frappe + + if not frappe.db.exists(doctype, name): + return + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=1)