From 2e5310f8a01a424fe8e42755726dbe9e2354c295 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:16 +0530 Subject: [PATCH 1/7] fix(manufacturing): case-sensitive variant BOM lookup on Postgres _bom_contains_item() lowercased the item name and then reused that lowercased value as a doc name in frappe.db.get_value("Item", item, "variant_of"). Doc names are case-sensitive on Postgres, so the lowercased name matched no row, variant_of came back NULL, and a Work Order for a variant item built from the template's BOM was wrongly rejected with 'BOM ... does not belong to Item ...'. Keep the original case for the Item lookup; the comparisons stay case-insensitive. MariaDB is unchanged (its name lookup was case-insensitive either way). --- erpnext/manufacturing/doctype/bom/bom.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index eebf131fd9d..90d898f4751 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1402,16 +1402,18 @@ def validate_bom_no(item, bom_no): def _bom_contains_item(bom, item): - item = item.lower() + item_lower = item.lower() for d in bom.items: - if d.item_code.lower() == item: + if d.item_code.lower() == item_lower: return True for d in bom.secondary_items: - if d.item_code.lower() == item: + if d.item_code.lower() == item_lower: return True + # Use the original-cased `item` for the Item lookup: names are case-sensitive on Postgres, + # so a lowercased name would miss the record and drop the variant->template BOM match. return ( - bom.item.lower() == item + bom.item.lower() == item_lower or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower() ) From e076a7800344b29554cd1ec95958b1fbb69bc04a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:19 +0530 Subject: [PATCH 2/7] fix(accounts): set Check field 'reconciled' with int, not bool (Postgres) frappe.db.set_value(..., "reconciled", True) renders SET reconciled=true; the column is smallint, which Postgres rejects (DatatypeMismatch). MariaDB coerces the boolean to 1. Pass 1 so both engines store the same value. --- .../process_payment_reconciliation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py index 316e691c89c..5ee78e5bbb3 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -479,7 +479,7 @@ def reconcile(doc: None | str = None) -> None: finally: if reconciled_entries == total_allocations: frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled") - frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True) + frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1) frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed") else: if frappe.db.get_value("Process Payment Reconciliation", doc, "status") != "Paused": From b97a0c9a13927c2a53e5bbf29d8670a65c01c377 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:25 +0530 Subject: [PATCH 3/7] test(accounts): set Check field with int, not bool (Postgres) set_value(Company, ..., "book_advance_payments_in_separate_party_account", True) errored on Postgres (smallint column, boolean expression). Use 1; MariaDB unchanged. --- .../doctype/unreconcile_payment/test_unreconcile_payment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py index 31f2c6b5508..ff17d6bbac2 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py @@ -424,7 +424,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin): self.disable_advance_as_liability() def test_07_adv_from_so_to_invoice(self): - frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True) + frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", 1) frappe.db.set_value( "Company", self.company, "default_advance_received_account", "Advance Received - _TC" ) From 72046d3688c888dddd24edf4c378054ab42e8576 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:30 +0530 Subject: [PATCH 4/7] test(stock): savepoint around expected duplicate Bin insert (Postgres) The deliberate UniqueValidationError from the second Bin insert aborts the transaction on Postgres, so the following _create_bin() (which takes its own savepoint) failed with InFailedSqlTransaction. Wrap the expected-failure insert in a savepoint and roll back to it, mirroring _create_bin's 'preserve transaction in postgres' pattern. No-op on MariaDB. --- erpnext/stock/doctype/bin/test_bin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index e66e453aca1..81b60d6ce19 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -19,8 +19,10 @@ class TestBin(ERPNextTestSuite): bin1.insert() bin2 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse) + frappe.db.savepoint("dup_bin") with self.assertRaises(frappe.UniqueValidationError): bin2.insert() + frappe.db.rollback(save_point="dup_bin") # preserve transaction in postgres # util method should handle it bin = _create_bin(item_code, warehouse) From b760b9d9353b76467833673968d27b624b3561b9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:36 +0530 Subject: [PATCH 5/7] test(stock): savepoint around expected duplicate barcode save (Postgres) The deliberate UniqueValidationError when re-adding a barcode aborts the transaction on Postgres, so the next frappe.get_doc() failed with InFailedSqlTransaction. Wrap the expected-failure save in a savepoint and roll back to it. No-op on MariaDB. --- erpnext/stock/doctype/item/test_item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 4b6537edb87..6bb368fb123 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -751,7 +751,9 @@ class TestItem(ERPNextTestSuite): item_doc = frappe.get_doc("Item", item_code) new_barcode = item_doc.append("barcodes") new_barcode.update(barcode_properties_list[0]) + frappe.db.savepoint("dup_barcode") self.assertRaises(frappe.UniqueValidationError, item_doc.save) + frappe.db.rollback(save_point="dup_barcode") # preserve transaction in postgres # Add invalid barcode - should cause InvalidBarcode item_doc = frappe.get_doc("Item", item_code) From f1a7b14e25c22a09b9c303f195b50a53f98c4516 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:29:41 +0530 Subject: [PATCH 6/7] test(perf): Postgres-valid index introspection in test_ensure_indexes SHOW INDEX is MySQL-only and errored on Postgres. Add a db-aware helper that reads the leading index column from pg_index on Postgres and keeps SHOW INDEX on MariaDB; both assert the field is the first column of some index. --- erpnext/tests/test_perf.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/erpnext/tests/test_perf.py b/erpnext/tests/test_perf.py index cb4ecd2ee13..f37922f645c 100644 --- a/erpnext/tests/test_perf.py +++ b/erpnext/tests/test_perf.py @@ -9,6 +9,37 @@ INDEXED_FIELDS = { } +def _is_leading_index_column(doctype: str, field: str) -> bool: + """Whether `field` is the first column of some index on the doctype's table. + + `SHOW INDEX` is MySQL-only; on Postgres read the leading key column (indkey[0]) + from the pg_index catalog. Both check the same thing across engines. + """ + table = f"tab{doctype}" + if frappe.db.db_type == "postgres": + return bool( + frappe.db.sql( + """ + SELECT 1 + FROM pg_index i + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = i.indkey[0] + WHERE t.relname = %s AND a.attname = %s + LIMIT 1 + """, + (table, field), + ) + ) + # `table` is a trusted constant (from INDEXED_FIELDS); a table identifier can't be a %s + # placeholder in SHOW INDEX, so the f-string is unavoidable and safe here. + return bool( + frappe.db.sql( + f"""SHOW INDEX FROM `{table}` WHERE Column_name = %s AND Seq_in_index = 1""", + (field,), + ) + ) + + class TestPerformance(ERPNextTestSuite): def test_ensure_indexes(self): # These fields are not explicitly indexed BUT they are prefix in some @@ -17,8 +48,6 @@ class TestPerformance(ERPNextTestSuite): for doctype, fields in INDEXED_FIELDS.items(): for field in fields: self.assertTrue( - frappe.db.sql( - f"""SHOW INDEX FROM `tab{doctype}` - WHERE Column_name = "{field}" AND Seq_in_index = 1""" - ) + _is_leading_index_column(doctype, field), + msg=f"{field} is not the leading column of any index on tab{doctype}", ) From 3cd2a3611700050f9dd49788e145ad58b67b5dc3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 15:59:04 +0530 Subject: [PATCH 7/7] test(stock): tolerate timezone slack in test_heatmap_data on Postgres get_timeline_data uses UnixTimestamp(posting_date); on Postgres that is the date's midnight epoch in the DB session timezone, which can sit up to a day ahead of the Python time.time() instant when the app timezone is ahead of UTC. The strict '<= now' upper bound is therefore flaky on Postgres. Allow a day of slack on the upper bound; MariaDB's UNIX_TIMESTAMP stays <= now so its pass/fail is unchanged. --- erpnext/stock/doctype/item/test_item.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 6bb368fb123..16d8f0f469c 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -770,10 +770,14 @@ class TestItem(ERPNextTestSuite): now = time.time() one_year_ago = now - 366 * 24 * 60 * 60 + # posting_date is a calendar date; its midnight unix timestamp (taken in the database + # session timezone) can sit up to a day ahead of the precise current instant when the app + # timezone is ahead of UTC, so allow a day of slack on the upper bound. + one_day = 24 * 60 * 60 for timestamp, count in data.items(): self.assertIsInstance(timestamp, int) - self.assertTrue(one_year_ago <= timestamp <= now) + self.assertTrue(one_year_ago <= timestamp <= now + one_day) self.assertIsInstance(count, int) self.assertGreaterEqual(count, 0)