fix(stock): make Available Batch report GROUP BY Postgres-valid

Both get_batchwise_data_from_stock_ledger and
get_batchwise_data_from_serial_batch_bundle select Batch columns
(expiry_date, and item_name when show_item_name is set) while grouping
only by Stock Ledger Entry columns. MariaDB arbitrary-picks the Batch
columns; Postgres rejects the query with "column ... must appear in the
GROUP BY clause".

Add the Batch PK (batch.name) to both GROUP BYs. batch.name is 1:1 with
the grouped batch_no (the join condition), so groups are unchanged and the
result is identical on MariaDB.

The serial-batch-bundle query additionally grouped by ch_table.warehouse
while selecting table.warehouse; group by the selected (SLE) warehouse so
the grouped and selected columns match (also required by Postgres).

Adds a test (no test file existed) that receives batch stock and asserts
the report lists it with the correct balance, exercising the GROUP BY on
both engines (with show_item_name set to force the extra Batch column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-21 04:49:02 +05:30
parent 4255059846
commit 9fb08153d6
2 changed files with 56 additions and 2 deletions

View File

@@ -106,7 +106,9 @@ def get_batchwise_data_from_stock_ledger(filters):
Sum(table.actual_qty).as_("balance_qty"),
)
.where(table.is_cancelled == 0)
.groupby(table.batch_no, table.item_code, table.warehouse)
# batch.expiry_date comes from the Batch table; postgres requires its PK in the GROUP BY for
# it to be selectable. batch.name is 1:1 with the grouped batch_no, so groups are unchanged.
.groupby(table.batch_no, table.item_code, table.warehouse, batch.name)
)
query = get_query_based_on_filters(query, batch, table, filters)
@@ -137,7 +139,10 @@ def get_batchwise_data_from_serial_batch_bundle(batchwise_data, filters):
Sum(ch_table.qty).as_("balance_qty"),
)
.where((table.is_cancelled == 0) & (table.docstatus == 1))
.groupby(ch_table.batch_no, table.item_code, ch_table.warehouse)
# Group by the same (SLE) warehouse that is selected -- the original grouped by
# ch_table.warehouse while selecting table.warehouse, which postgres rejects. Also group by
# the Batch PK so batch.expiry_date is selectable (1:1 with the grouped batch_no).
.groupby(ch_table.batch_no, table.item_code, table.warehouse, batch.name)
)
query = get_query_based_on_filters(query, batch, table, filters)

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import today
from erpnext.tests.utils import ERPNextTestSuite
class TestAvailableBatchReport(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete_stock_entry(name):
if not frappe.db.exists("Stock Entry", name):
return
doc = frappe.get_doc("Stock Entry", name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc("Stock Entry", name, force=1)
def test_report_runs_and_lists_batch_qty(self):
# The report selects Batch columns (expiry_date, and item_name when show_item_name is set)
# while grouping by SLE columns; the Batch PK must be in the GROUP BY for the report to run
# on Postgres. show_item_name=1 forces the extra Batch column to be selected.
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.available_batch_report.available_batch_report import execute
item = make_item(
"_Test Available Batch Report Item",
{"has_batch_no": 1, "create_new_batch": 1, "is_stock_item": 1},
).name
se = make_stock_entry(
item_code=item, target="_Test Warehouse - _TC", qty=7, basic_rate=10, purpose="Material Receipt"
)
# make_item is idempotent (returns the existing item), but each receipt stacks a new batch,
# so cancel+delete the stock entry to keep repeated runs clean.
self.addCleanup(self._cancel_and_delete_stock_entry, se.name)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
filters = frappe._dict(to_date=today(), item_code=item, show_item_name=1)
columns, data = execute(filters)
self.assertTrue(columns)
row = next((d for d in data if d.batch_no == batch_no), None)
self.assertIsNotNone(row)
self.assertEqual(row.balance_qty, 7)