From 9fb08153d6907438416e4028672d36c7fcfbd8e7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 04:49:02 +0530 Subject: [PATCH 1/2] 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) --- .../available_batch_report.py | 9 +++- .../test_available_batch_report.py | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 erpnext/stock/report/available_batch_report/test_available_batch_report.py diff --git a/erpnext/stock/report/available_batch_report/available_batch_report.py b/erpnext/stock/report/available_batch_report/available_batch_report.py index 1fd612eb0b7..3ace62b19dd 100644 --- a/erpnext/stock/report/available_batch_report/available_batch_report.py +++ b/erpnext/stock/report/available_batch_report/available_batch_report.py @@ -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) diff --git a/erpnext/stock/report/available_batch_report/test_available_batch_report.py b/erpnext/stock/report/available_batch_report/test_available_batch_report.py new file mode 100644 index 00000000000..9de21bc9818 --- /dev/null +++ b/erpnext/stock/report/available_batch_report/test_available_batch_report.py @@ -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) From 7fe79b115d88e35d4bd03cfd156886b2e0002d5e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 04:49:03 +0530 Subject: [PATCH 2/2] refactor(stock): convert ItemAttribute.validate_exising_items to query builder validate_exising_items() used a raw frappe.db.sql implicit-join to find variant items using the attribute. Convert it to a frappe.qb inner join (engine-portable, MariaDB-identical) so it no longer relies on raw SQL. Only this query is converted; develop's update_variant_attribute_values on_update hook and its imports are left intact (the staging branch's whole-file version predated and would have reverted them). Adds a focused test that creates a variant and asserts validate_exising_items finds it (the validation only raises if the converted query returned the variant row). Passes on MariaDB and Postgres. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../doctype/item_attribute/item_attribute.py | 21 ++++++++----------- .../item_attribute/test_item_attribute.py | 19 +++++++++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 2e50479b409..822d257e050 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -66,18 +66,15 @@ class ItemAttribute(Document): attributes_list = [d.attribute_value for d in self.item_attribute_values] # Get Item Variant Attribute details of variant items - items = frappe.db.sql( - """ - select - i.name, iva.attribute_value as value - from - `tabItem Variant Attribute` iva, `tabItem` i - where - iva.attribute = %(attribute)s - and iva.parent = i.name and - i.variant_of is not null and i.variant_of != ''""", - {"attribute": self.name}, - as_dict=1, + iva = frappe.qb.DocType("Item Variant Attribute") + i = frappe.qb.DocType("Item") + items = ( + frappe.qb.from_(iva) + .inner_join(i) + .on(iva.parent == i.name) + .select(i.name, iva.attribute_value.as_("value")) + .where((iva.attribute == self.name) & i.variant_of.isnotnull() & (i.variant_of != "")) + .run(as_dict=1) ) for item in items: diff --git a/erpnext/stock/doctype/item_attribute/test_item_attribute.py b/erpnext/stock/doctype/item_attribute/test_item_attribute.py index 63958733eba..2d45e94a4fc 100644 --- a/erpnext/stock/doctype/item_attribute/test_item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/test_item_attribute.py @@ -30,3 +30,22 @@ class TestItemAttribute(ERPNextTestSuite): item_attribute.increment = 0.5 item_attribute.save() + + def test_validate_existing_items_finds_variants(self): + # validate_exising_items() joins Item Variant Attribute to Item to find variants using this + # attribute. Exercises the converted query builder version on both engines and asserts it + # finds the variant (the raise only fires if the query returned the variant row). + from erpnext.controllers.item_variant import InvalidItemAttributeValueError, create_variant + + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + self.addCleanup(frappe.delete_doc_if_exists, "Item", "_Test Variant Item-L", force=1) + + attribute = frappe.get_doc("Item Attribute", "Test Size") + attribute.item_attribute_values = [] + frappe.flags.attribute_values = None + + # "Large" is no longer a permitted value, so the variant found by validate_exising_items + # is invalid; the save must abort (and so never persists the cleared values). + self.assertRaises(InvalidItemAttributeValueError, attribute.save)