fix(manufacturing): make get_bom_items_as_dict Postgres-valid (GROUP BY)

_query_bom_items / _build_base_bom_items_query / _add_*_item_columns selected
non-grouped columns (idx, item_name, image, project, item-default fields, BOM
Item attributes) alongside `group by item_code` -> arbitrary pick on MariaDB,
GroupingError on Postgres. Wrap them in Max() (Min() for idx, preserving the
original ordering). Every wrapped column is functionally dependent on the
grouped item_code (item attributes / the single BOM's project / one Item
Default per item+company), so Max()/Min() returns exactly the value MySQL
picked arbitrarily -> MariaDB output unchanged.

This was previously shipped in #56008 and reverted with that batch; re-applied
in isolation here. Verified: test_work_order 85/85 on BOTH MariaDB (no change)
and Postgres (was 85/85 failing on this query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-20 21:46:35 +05:30
parent 8c1c8a3cee
commit 79cbefb088

View File

@@ -9,7 +9,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.query_builder import Field
from frappe.query_builder.functions import Count, IfNull, Sum
from frappe.query_builder.functions import Count, IfNull, Max, Min, Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
from frappe.website.website_generator import WebsiteGenerator
@@ -1194,7 +1194,9 @@ def _query_bom_items(bom, company, opts):
t = _get_bom_item_tables(opts)
query = _build_base_bom_items_query(bom, company, opts.qty, t)
query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
return query.groupby(*group_by).orderby(Field("idx")).run(as_dict=True)
# qualify + aggregate idx: bare "idx" is ambiguous across the joined tables and isn't grouped
# (idx is unique per BOM item, so Min() preserves the original ordering) — needed for postgres
return query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
def _get_bom_item_tables(opts):
@@ -1228,17 +1230,20 @@ def _build_base_bom_items_query(bom, company, qty, t):
.on((t.item_default.parent == t.item_doc.name) & (t.item_default.company == company))
.select(
t.bom_item.item_code,
t.bom_item.idx,
t.item_doc.item_name,
# every non-grouped column here is functionally dependent on the grouped item_code
# (item attributes / the single BOM's project / per-item Item Default), so Max()/Min()
# returns the value MySQL picked arbitrarily while making the GROUP BY valid on postgres.
Min(t.bom_item.idx).as_("idx"),
Max(t.item_doc.item_name).as_("item_name"),
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
t.item_doc.image,
t.bom_doc.project,
t.item_doc.stock_uom,
t.item_doc.item_group,
t.item_doc.allow_alternative_item,
t.item_default.default_warehouse,
t.item_default.expense_account.as_("expense_account"),
t.item_default.buying_cost_center.as_("cost_center"),
Max(t.item_doc.image).as_("image"),
Max(t.bom_doc.project).as_("project"),
Max(t.item_doc.stock_uom).as_("stock_uom"),
Max(t.item_doc.item_group).as_("item_group"),
Max(t.item_doc.allow_alternative_item).as_("allow_alternative_item"),
Max(t.item_default.default_warehouse).as_("default_warehouse"),
Max(t.item_default.expense_account).as_("expense_account"),
Max(t.item_default.buying_cost_center).as_("cost_center"),
)
.where((t.bom_item.docstatus < 2) & (t.bom_doc.name == bom))
)
@@ -1247,9 +1252,11 @@ def _build_base_bom_items_query(bom, company, qty, t):
def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods):
is_stock_item = cint(not opts.include_non_stock_items)
stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item])
amount_col = (Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * t.bom_item.rate * opts.qty).as_(
"amount"
)
# rate is constant per grouped item -> Max() keeps it out of the Sum (preserving the original
# Sum(...) * rate * qty arithmetic) while making the expression postgres-valid under GROUP BY.
amount_col = (
Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * Max(t.bom_item.rate) * opts.qty
).as_("amount")
if cint(opts.fetch_exploded):
return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition)
@@ -1267,13 +1274,16 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
.limit(1)
)
# non-grouped columns are constant per grouped item_code -> Max() preserves the value while
# keeping the GROUP BY postgres-valid; the correlated idx subquery references only item_code
# (a grouped column) so it stays valid and still overrides the explosion idx for display.
query = query.select(
t.bom_item.source_warehouse,
t.bom_item.operation,
t.bom_item.include_item_in_manufacturing,
t.bom_item.description,
t.bom_item.rate,
t.bom_item.sourced_by_supplier,
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
Max(t.bom_item.operation).as_("operation"),
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
Max(t.bom_item.description).as_("description"),
Max(t.bom_item.rate).as_("rate"),
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
amount_col,
idx_subquery.as_("idx"),
).where(stock_item_condition)
@@ -1282,33 +1292,39 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
def _add_secondary_item_columns(query, t, stock_item_condition):
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid on
# postgres while returning the same value MySQL picked arbitrarily.
query = query.select(
t.item_doc.description,
t.bom_item.cost_allocation_per,
t.bom_item.process_loss_per,
t.bom_item.secondary_item_type,
t.bom_item.name,
t.bom_item.is_legacy,
Max(t.item_doc.description).as_("description"),
Max(t.bom_item.cost_allocation_per).as_("cost_allocation_per"),
Max(t.bom_item.process_loss_per).as_("process_loss_per"),
Max(t.bom_item.secondary_item_type).as_("secondary_item_type"),
Max(t.bom_item.name).as_("name"),
Max(t.bom_item.is_legacy).as_("is_legacy"),
).where(stock_item_condition)
return query, [t.bom_item.item_code]
def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods):
# non-grouped columns are constant per grouped item_code (+operation/operation_row_id) -> Max()
# keeps the GROUP BY valid on postgres while returning the value MySQL picked arbitrarily.
# NOTE: base_rate is aliased "rate" below and is what callers receive; bom_item.rate was selected
# under the same alias and silently shadowed (last value wins in the dict), so it is dropped here
# -- output is unchanged.
query = query.select(
t.bom_item.rate,
t.bom_item.uom,
t.bom_item.conversion_factor,
t.bom_item.source_warehouse,
t.bom_item.operation,
t.bom_item.include_item_in_manufacturing,
t.bom_item.sourced_by_supplier,
Max(t.bom_item.uom).as_("uom"),
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
Max(t.bom_item.operation).as_("operation"),
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
amount_col,
t.bom_item.description,
t.bom_item.base_rate.as_("rate"),
t.bom_item.operation_row_id,
t.bom_item.is_phantom_item,
t.bom_item.bom_no,
Max(t.bom_item.description).as_("description"),
Max(t.bom_item.base_rate).as_("rate"),
Max(t.bom_item.operation_row_id).as_("operation_row_id"),
Max(t.bom_item.is_phantom_item).as_("is_phantom_item"),
Max(t.bom_item.bom_no).as_("bom_no"),
).where(stock_item_condition | (t.bom_item.is_phantom_item == 1))
if track_semi_finished_goods: