diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 723c60964e4..29ed697ccaf 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -591,7 +591,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p & (batch_table.disabled == 0) & (stock_ledger_entry.batch_no.isnotnull()) ) - .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse) + .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse, batch_table.name) .having(Sum(stock_ledger_entry.actual_qty) != 0) .offset(start) .limit(page_len) @@ -608,8 +608,12 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p query = query.where((batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull())) query = query.select( - Concat("MFG-", batch_table.manufacturing_date).as_("manufacturing_date"), - Concat("EXP-", batch_table.expiry_date).as_("expiry_date"), + Case() + .when(batch_table.manufacturing_date.isnotnull(), Concat("MFG-", batch_table.manufacturing_date)) + .as_("manufacturing_date"), + Case() + .when(batch_table.expiry_date.isnotnull(), Concat("EXP-", batch_table.expiry_date)) + .as_("expiry_date"), ) if filters.get("warehouse"): @@ -651,7 +655,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0 & (batch_table.disabled == 0) & (stock_ledger_entry.serial_and_batch_bundle.isnotnull()) ) - .groupby(bundle.batch_no, bundle.warehouse) + .groupby(bundle.batch_no, bundle.warehouse, batch_table.name) .having(Sum(bundle.qty) != 0) .offset(start) .limit(page_len) @@ -670,8 +674,12 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0 ) bundle_query = bundle_query.select( - Concat("MFG-", batch_table.manufacturing_date), - Concat("EXP-", batch_table.expiry_date), + Case() + .when(batch_table.manufacturing_date.isnotnull(), Concat("MFG-", batch_table.manufacturing_date)) + .as_("manufacturing_date"), + Case() + .when(batch_table.expiry_date.isnotnull(), Concat("EXP-", batch_table.expiry_date)) + .as_("expiry_date"), ) if filters.get("warehouse"): diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index e2062c4e6a9..20d49d780d8 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -386,6 +386,52 @@ class TestBatch(ERPNextTestSuite): self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90) + def test_get_batch_no_search_returns_batches(self): + """The batch-number picker must run on every engine. + + Both query builders group by Stock Ledger Entry / Serial-and-Batch-Entry + columns while selecting un-aggregated Batch-master columns; PostgreSQL only + accepts that when the Batch primary key is in the GROUP BY, so the picker + errors there without it. + """ + from erpnext.controllers.queries import ( + get_batch_no, + get_batches_from_serial_and_batch_bundle, + get_batches_from_stock_ledger_entries, + ) + + self.make_batch_item("ITEM-BATCH-PICKER") + self.make_new_batch_and_entry("ITEM-BATCH-PICKER", "batch picker a", "_Test Warehouse - _TC") + self.make_new_batch_and_entry("ITEM-BATCH-PICKER", "batch picker b", "_Test Warehouse - _TC") + + searchfields = frappe.get_meta("Batch").get_search_fields() + filters = {"item_code": "ITEM-BATCH-PICKER", "warehouse": "_Test Warehouse - _TC"} + + # Exercise both query builders directly so each GROUP BY is covered regardless + # of which path holds the data: PostgreSQL validates the GROUP BY even when no + # rows match, so a missing Batch primary key raises GroupingError here. + get_batches_from_stock_ledger_entries(searchfields, "", filters) + get_batches_from_serial_and_batch_bundle(searchfields, "", filters) + + result = get_batch_no( + doctype="Batch", + txt="", + searchfield="name", + start=0, + page_len=20, + filters=filters, + ) + returned = {row[0] for row in result} + self.assertIn("batch picker a", returned) + self.assertIn("batch picker b", returned) + + # These batches have no manufacturing/expiry date. MariaDB CONCAT('MFG-', NULL) + # is NULL, but Postgres CONCAT drops the NULL and would surface a bare "MFG-"/ + # "EXP-"; the null-guarded select must keep both engines free of that artifact. + flat = [value for row in result for value in row] + self.assertNotIn("MFG-", flat) + self.assertNotIn("EXP-", flat) + def test_ignore_reserved_qty(self): from erpnext.selling.doctype.sales_order.mapper import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order