Merge pull request #56036 from mihir-kandoi/pg-groupby-reports

fix(postgres): satisfy strict GROUP BY in 12 reports
This commit is contained in:
Mihir Kandoi
2026-06-17 13:39:35 +05:30
committed by GitHub
12 changed files with 123 additions and 78 deletions

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _, qb
from frappe.query_builder import Column, functions
from frappe.query_builder import functions
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, getdate, rounded
from erpnext.accounts.report.financial_statements import get_period_list
@@ -300,8 +301,10 @@ class Deferred_Revenue_and_Expense_Report:
Get all sales and purchase invoices which has deferred revenue/expense items
"""
gle = qb.DocType("GL Entry")
# column doesn't have an alias option
posted = Column("posted")
# a literal marker: real GL rows are "posted" (dummy/simulated future entries use "not").
# ConstantColumn renders a single-quoted string literal, valid on both backends -- a plain
# Column rendered as "posted", which MySQL reads as the string but postgres as an identifier.
posted = ConstantColumn("posted").as_("posted")
if self.filters.type == "Revenue":
inv = qb.DocType("Sales Invoice")
@@ -327,13 +330,15 @@ class Deferred_Revenue_and_Expense_Report:
)
.select(
inv.name.as_("doc"),
inv.posting_date,
# non-grouped columns are constant per grouped invoice / invoice item -> Max() keeps the
# GROUP BY valid on postgres while returning the same value MySQL picked.
functions.Max(inv.posting_date).as_("posting_date"),
inv_item.name.as_("item"),
inv_item.item_name,
inv_item.service_start_date,
inv_item.service_end_date,
inv_item.base_net_amount,
deferred_account_field,
functions.Max(inv_item.item_name).as_("item_name"),
functions.Max(inv_item.service_start_date).as_("service_start_date"),
functions.Max(inv_item.service_end_date).as_("service_end_date"),
functions.Max(inv_item.base_net_amount).as_("base_net_amount"),
functions.Max(deferred_account_field).as_(deferred_account_field.name),
gle.posting_date.as_("gle_posting_date"),
functions.Sum(gle.debit).as_("debit"),
functions.Sum(gle.credit).as_("credit"),

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
def execute(filters=None):
@@ -43,7 +43,13 @@ def get_data(filters):
gle = frappe.qb.DocType("GL Entry")
query = (
frappe.qb.from_(gle)
.select(gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit"))
# voucher_type is constant per grouped voucher_no -> Max() keeps the GROUP BY valid on postgres
.select(
Max(gle.voucher_type).as_("voucher_type"),
gle.voucher_no,
Sum(gle.debit).as_("debit"),
Sum(gle.credit).as_("credit"),
)
.where(gle.is_cancelled == 0)
.groupby(gle.voucher_no)
)

View File

@@ -6,7 +6,7 @@ import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cint, date_diff, flt, getdate
@@ -44,13 +44,15 @@ def get_data(filters):
.on(mr_item.parent == mr.name)
.select(
mr.name.as_("material_request"),
mr.transaction_date.as_("date"),
mr_item.schedule_date.as_("required_date"),
# non-grouped columns are constant per grouped mr.name / item_code -> Max() keeps the
# GROUP BY valid on postgres while returning the same value MySQL picked.
Max(mr.transaction_date).as_("date"),
Max(mr_item.schedule_date).as_("required_date"),
mr_item.item_code.as_("item_code"),
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
Coalesce(mr_item.uom, "").as_("uom"),
Coalesce(mr_item.stock_uom, "").as_("stock_uom"),
Max(Coalesce(mr_item.uom, "")).as_("uom"),
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_(
@@ -58,9 +60,9 @@ def get_data(filters):
),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
mr_item.item_name,
mr_item.description,
mr.company,
Max(mr_item.item_name).as_("item_name"),
Max(mr_item.description).as_("description"),
Max(mr.company).as_("company"),
)
.where(
(mr.material_request_type == "Purchase")
@@ -72,7 +74,7 @@ def get_data(filters):
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
query = query.groupby(mr.name, mr_item.item_code).orderby(mr.transaction_date, mr.schedule_date)
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
data = query.run(as_dict=True)
return data

View File

@@ -86,10 +86,12 @@ class SalesPipelineAnalytics:
if self.filters.get("range") == "Monthly":
self.group_by_period = Month(opp.expected_closing)
self.duration = MonthName(opp.expected_closing).as_("month")
self.duration_expr = MonthName(opp.expected_closing)
self.duration = self.duration_expr.as_("month")
else:
self.group_by_period = Quarter(opp.expected_closing)
self.duration = Quarter(opp.expected_closing).as_("quarter")
self.duration_expr = Quarter(opp.expected_closing)
self.duration = self.duration_expr.as_("quarter")
self.pipeline_by = {"Owner": "opportunity_owner", "Sales Stage": "sales_stage"}[
self.filters.get("pipeline_by")
@@ -101,27 +103,35 @@ class SalesPipelineAnalytics:
self.get_fields()
opp = frappe.qb.DocType("Opportunity")
query = frappe.qb.get_query(
"Opportunity",
filters=self.get_conditions(),
ignore_permissions=True,
)
pipeline_field = opp._assign if self.group_by_based_on == "_assign" else opp.sales_stage
if self.filters.get("based_on") == "Number":
# Ask get_query for exactly the grouped columns via `fields`, instead of taking its
# default un-grouped "name" select and stripping it. Group by the displayed period
# expression too, so postgres accepts MonthName alongside the numeric Month used for
# chronological ordering (for Quarterly they're the same expression).
self.query_result = (
query.select(
pipeline_field.as_(self.pipeline_by),
frappe.query_builder.functions.Count("*").as_("count"),
self.duration,
frappe.qb.get_query(
"Opportunity",
filters=self.get_conditions(),
fields=[
pipeline_field.as_(self.pipeline_by),
frappe.query_builder.functions.Count("*").as_("count"),
self.duration,
],
ignore_permissions=True,
)
.groupby(pipeline_field, self.group_by_period)
.groupby(pipeline_field, self.group_by_period, self.duration_expr)
.orderby(self.group_by_period)
.run(as_dict=True)
)
if self.filters.get("based_on") == "Amount":
query = frappe.qb.get_query(
"Opportunity",
filters=self.get_conditions(),
ignore_permissions=True,
)
self.query_result = query.select(
pipeline_field.as_(self.pipeline_by),
opp.opportunity_amount.as_("amount"),

View File

@@ -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)

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
Filters = frappe._dict
Row = frappe._dict
@@ -29,12 +29,14 @@ def get_data(filters: Filters) -> Data:
.inner_join(se)
.on(wo.name == se.work_order)
.select(
wo.name,
wo.status,
wo.production_item,
wo.produced_qty,
wo.process_loss_qty,
wo.qty.as_("qty_to_manufacture"),
# grouped by se.work_order (== wo.name); the work-order columns are constant per group ->
# Max() keeps the GROUP BY valid on postgres with the same value.
Max(wo.name).as_("name"),
Max(wo.status).as_("status"),
Max(wo.production_item).as_("production_item"),
Max(wo.produced_qty).as_("produced_qty"),
Max(wo.process_loss_qty).as_("process_loss_qty"),
Max(wo.qty).as_("qty_to_manufacture"),
Sum(se.total_incoming_value).as_("total_fg_value"),
Sum(se.total_outgoing_value).as_("total_rm_value"),
)

View File

@@ -4,8 +4,8 @@
import frappe
from frappe import _
from frappe.query_builder import Case, CustomFunction
from frappe.query_builder.functions import Count, Max, Sum
from frappe.query_builder import Case
from frappe.query_builder.functions import Count, CurDate, DateDiff, Max, Sum
from frappe.utils import cint
@@ -37,9 +37,6 @@ def get_sales_details(doctype):
customer = frappe.qb.DocType("Customer")
sales_doctype = frappe.qb.DocType(doctype)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
if doctype == "Sales Order":
total_considered = Sum(
Case()
@@ -55,7 +52,9 @@ def get_sales_details(doctype):
date_col = sales_doctype.posting_date
last_order_date = Max(date_col)
days_since_last_order = date_diff(current_date(), last_order_date)
# DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate()
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
days_since_last_order = DateDiff(CurDate(), last_order_date)
return (
frappe.qb.from_(customer)

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _, qb, query_builder
from frappe.query_builder import Criterion, functions
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Max
from frappe.utils.dateutils import getdate
@@ -185,9 +186,6 @@ def get_so_with_invoices(filters):
conditions = get_conditions(filters)
filter_criterions = build_filter_criterions(filters)
datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
query_so = (
qb.from_(so)
.join(soi)
@@ -199,7 +197,8 @@ def get_so_with_invoices(filters):
.select(
so.customer,
so.transaction_date.as_("submitted"),
ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
# CASE + a Python date is portable; MySQL's IF()/DATEDIFF()/CURDATE() don't exist on postgres
query_builder.Case().when(ps.due_date < getdate(), "Overdue").else_("Unpaid").as_("status"),
ps.payment_term,
ps.description,
ps.due_date,
@@ -230,7 +229,13 @@ def get_so_with_invoices(filters):
.on(si.name == sii.parent)
.inner_join(soi)
.on(soi.name == sii.so_detail)
.select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
.select(
# grouped by the invoice (sii.parent); sales_order is arbitrary per invoice on MySQL and
# base_grand_total is constant per invoice -> Max() keeps the GROUP BY postgres-valid.
Max(sii.sales_order).as_("sales_order"),
sii.parent.as_("invoice"),
Max(si.base_grand_total).as_("invoice_amount"),
)
.where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
.groupby(sii.parent)
)

View File

@@ -137,7 +137,8 @@ def get_stock_ledger_entries_for_batch_no(filters):
sle.item_code,
sle.warehouse,
sle.batch_no,
sle.posting_date,
# posting_date is constant per voucher_no (grouped) -> Max() is unchanged and postgres-valid
fn.Max(sle.posting_date).as_("posting_date"),
fn.Sum(sle.actual_qty).as_("actual_qty"),
fn.Sum(sle.stock_value_difference).as_("stock_value_difference"),
)
@@ -182,10 +183,13 @@ def get_stock_ledger_entries_for_batch_bundle(filters):
.inner_join(batch_package)
.on(batch_package.parent == sle.serial_and_batch_bundle)
.select(
sle.item_code,
sle.warehouse,
# item_code/warehouse/posting_date are constant per grouped voucher_no+batch_no+warehouse
# (a batch belongs to one item; warehouse mirrors the grouped batch_package.warehouse;
# a voucher has one posting_date) -> Max() is unchanged and postgres-valid
fn.Max(sle.item_code).as_("item_code"),
fn.Max(sle.warehouse).as_("warehouse"),
batch_package.batch_no,
sle.posting_date,
fn.Max(sle.posting_date).as_("posting_date"),
fn.Sum(batch_package.qty).as_("actual_qty"),
fn.Sum(batch_package.stock_value_difference).as_("stock_value_difference"),
)

View File

@@ -254,11 +254,13 @@ def get_stock_ledger_entries(filters, items):
def get_item_wise_max_posting_datetime(filters, items):
"""Get the maximum Stock Ledger Entry name for the given filters and items."""
"""Get the latest posting datetime per item+warehouse for the given filters and items."""
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(sle.item_code, sle.warehouse, sle.name, Max(sle.posting_datetime).as_("posting_datetime"))
# `name` was selected but never read by the caller (the join below only uses item_code,
# warehouse and posting_datetime); drop it so the GROUP BY is valid on postgres.
.select(sle.item_code, sle.warehouse, Max(sle.posting_datetime).as_("posting_datetime"))
.where(sle.item_code.isin(items) & (sle.is_cancelled == 0))
.groupby(sle.item_code, sle.warehouse)
)

View File

@@ -86,12 +86,14 @@ def get_stock_ledger_data(report_filters, filters):
"Stock Ledger Entry",
filters=filters,
fields=[
"name",
# name is arbitrary per grouped voucher (many SLEs); posting_date/posting_time are constant
# per voucher -> MAX() keeps the GROUP BY valid on postgres with the same values MySQL picked.
{"MAX": "name", "as": "name"},
"voucher_type",
"voucher_no",
{"SUM": "stock_value_difference", "as": "stock_value"},
"posting_date",
"posting_time",
{"MAX": "posting_date", "as": "posting_date"},
{"MAX": "posting_time", "as": "posting_time"},
],
group_by="voucher_type, voucher_no",
order_by="posting_date ASC, posting_time ASC",
@@ -113,10 +115,12 @@ def get_gl_data(report_filters, filters):
"GL Entry",
filters=filters,
fields=[
"name",
# name is arbitrary per grouped voucher (many GL entries); posting_date is constant per
# voucher -> MAX() keeps the GROUP BY valid on postgres with the same values MySQL picked.
{"MAX": "name", "as": "name"},
"voucher_type",
"voucher_no",
"posting_date",
{"MAX": "posting_date", "as": "posting_date"},
{
"SUB": [{"SUM": "debit_in_account_currency"}, {"SUM": "credit_in_account_currency"}],
"as": "account_value",

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
def execute(filters=None):
@@ -53,8 +53,9 @@ def get_total_stock(filters):
else:
query = query.select(wh.company).groupby(wh.company)
query = query.select(item.item_code, item.description, Sum(bin.actual_qty).as_("actual_qty")).groupby(
item.item_code
)
# description is constant per grouped item_code -> Max() keeps the GROUP BY valid on postgres
query = query.select(
item.item_code, Max(item.description).as_("description"), Sum(bin.actual_qty).as_("actual_qty")
).groupby(item.item_code)
return query.run()