From 24a66d10e7f38e909a61e6dc5810901a82e5c210 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:18:56 +0530 Subject: [PATCH 01/12] fix(postgres): satisfy strict GROUP BY in Deferred Revenue And Expense 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) --- .../deferred_revenue_and_expense.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py index 22fabeabca6..1b7ea5063ee 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py @@ -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"), From 23778c3875e04cfeaf5f57abf2b289ab174a2906 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:18:57 +0530 Subject: [PATCH 02/12] fix(postgres): satisfy strict GROUP BY in Voucher Wise Balance 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) --- .../voucher_wise_balance/voucher_wise_balance.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py b/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py index 1918165c6db..e02a898d40d 100644 --- a/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py +++ b/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py @@ -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) ) From 812a06cf44139da98b296d281aff9c315c5a3191 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:18:59 +0530 Subject: [PATCH 03/12] fix(postgres): satisfy strict GROUP BY in Requested Items To Order And Receive 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) --- .../requested_items_to_order_and_receive.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 55189a722a3..b6f9bb13795 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -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 From 8a566e6ba5b994bfa8ff1b5a47023d73bd9ad6bd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:00 +0530 Subject: [PATCH 04/12] fix(postgres): satisfy strict GROUP BY in Sales Pipeline Analytics 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) --- .../sales_pipeline_analytics.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index 847e871f61e..48fffca8a26 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -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"), From 9ab8803fedfe2b2567656c3fc1ad350bbd18dd6d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:01 +0530 Subject: [PATCH 05/12] 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) From a3ec98a57c971ae210f6e72723ae7669c84393d8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:02 +0530 Subject: [PATCH 06/12] fix(postgres): satisfy strict GROUP BY in Process Loss Report 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) --- .../process_loss_report/process_loss_report.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py index 73560dd939b..2ba9f4742fd 100644 --- a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py +++ b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py @@ -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"), ) From 463103ebf131ad150ce6446a6cff20529884c25b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:02 +0530 Subject: [PATCH 07/12] fix(postgres): satisfy strict GROUP BY in Inactive Customers 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) --- .../report/inactive_customers/inactive_customers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 8c7c7b99a32..2dedb346601 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -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) From 60235f4b2bd148ce4f063831e87dc5b3da9c7754 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:03 +0530 Subject: [PATCH 08/12] fix(postgres): satisfy strict GROUP BY in Payment Terms Status For Sales Order 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) --- .../payment_terms_status_for_sales_order.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index eb2b7cc21d8..1f9c2ceb442 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -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) ) From e77b27ae992099564736ef0bfc16f8fa1375e95b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:04 +0530 Subject: [PATCH 09/12] fix(postgres): satisfy strict GROUP BY in Batch Wise Balance History 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) --- .../batch_wise_balance_history.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index c44dc9633ae..fb49b060fb7 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -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"), ) From e69eaa5102fbd1494c6f9cf5f5dfc9e386907c23 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:06 +0530 Subject: [PATCH 10/12] fix(postgres): satisfy strict GROUP BY in Product Bundle Balance 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) --- .../report/product_bundle_balance/product_bundle_balance.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py index b247013b62a..bd87552b6be 100644 --- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py +++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py @@ -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) ) From 9096b4a9df263ffe181f9934fe4c06b12ca571e6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:07 +0530 Subject: [PATCH 11/12] fix(postgres): satisfy strict GROUP BY in Stock And Account Value Comparison 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) --- .../stock_and_account_value_comparison.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index d3654606613..48ca58cd334 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -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", From c124e90a89e526d5050f02f4c4992dd956fd3638 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 13:19:08 +0530 Subject: [PATCH 12/12] fix(postgres): satisfy strict GROUP BY in Total Stock Summary 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) --- .../report/total_stock_summary/total_stock_summary.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/total_stock_summary/total_stock_summary.py b/erpnext/stock/report/total_stock_summary/total_stock_summary.py index 4848c216e81..9d6bda685d5 100644 --- a/erpnext/stock/report/total_stock_summary/total_stock_summary.py +++ b/erpnext/stock/report/total_stock_summary/total_stock_summary.py @@ -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()