From 9ab8803fedfe2b2567656c3fc1ad350bbd18dd6d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:01 +0530 Subject: [PATCH] fix(postgres): satisfy strict GROUP BY in Bom Stock Analysis report Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict GROUP BY. No behaviour change on MariaDB. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bom_stock_analysis/bom_stock_analysis.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py index 568fdf90054..95522adfdb6 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.query_builder.functions import Floor, IfNull, Sum +from frappe.query_builder.functions import Floor, IfNull, Max, Min, Sum from frappe.utils import flt from frappe.utils.data import comma_and from pypika.terms import ExistsCriterion @@ -202,14 +202,15 @@ def get_bom_data(filters): .on(bom_item.item_code == bin.item_code) .select( bom_item.item_code, - bom_item.description, - bom_item.parent.as_("from_bom_no"), + # non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid + Max(bom_item.description).as_("description"), + Max(bom_item.parent).as_("from_bom_no"), Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"), IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), ) .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) .groupby(bom_item.item_code) - .orderby(bom_item.idx) + .orderby(Min(bom_item.idx)) ) if filters.get("warehouse"): @@ -233,7 +234,9 @@ def get_bom_data(filters): query = query.where(bin.warehouse == filters.get("warehouse")) if bom_item_table == "BOM Item": - query = query.select(bom_item.bom_no, bom_item.is_phantom_item) + query = query.select( + Max(bom_item.bom_no).as_("bom_no"), Max(bom_item.is_phantom_item).as_("is_phantom_item") + ) data = query.run(as_dict=True) return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data @@ -312,15 +315,17 @@ def get_producible_fg_items(filters): .on(BOM_ITEM.item_code == bin_subquery.item_code) .select( BOM_ITEM.item_code, - BOM_ITEM.description, - BOM_ITEM.parent.as_("from_bom_no"), - (BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"), - IfNull(bin_subquery.actual_qty, 0).as_("available_qty"), - Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)), + # Sum() below makes this an aggregate query; the other columns are constant per grouped + # item_code -> Max() keeps them valid on postgres with the same value MySQL picked. + Max(BOM_ITEM.description).as_("description"), + Max(BOM_ITEM.parent).as_("from_bom_no"), + Max(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"), + Max(IfNull(bin_subquery.actual_qty, 0)).as_("available_qty"), + Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))), ) .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) .groupby(BOM_ITEM.item_code) - .orderby(BOM_ITEM.idx) + .orderby(Min(BOM_ITEM.idx)) ) return query.run(as_list=True)