From 5a0e7f57a36880a3257d03da2d9fa95b90450800 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 10:20:39 +0530 Subject: [PATCH] fix(projects): use Coalesce for timesheet portal sales_invoice (not bitwise OR) get_timesheets_list selected `timesheet.sales_invoice | detail.sales_invoice`, intending COALESCE (pick the parent timesheet's invoice, else the detail's) -- the original raw SQL was COALESCE(ts.sales_invoice, tsd.sales_invoice). pypika's `|` is a bitwise OR, not a coalesce: - Postgres: `varchar | varchar` -> "operator does not exist" (hard error). - MariaDB: bitwise OR coerces the operands to integers; with a NULL detail invoice the result is NULL, so the portal showed no invoice even when the timesheet was billed. Replace with Coalesce(table.sales_invoice, child_table.sales_invoice). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../doctype/timesheet/test_timesheet.py | 36 +++++++++++++++++++ .../projects/doctype/timesheet/timesheet.py | 4 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 1f1245957a9..6703a95410b 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -391,6 +391,42 @@ class TestTimesheet(ERPNextTestSuite): self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name) self.assertEqual(timesheet.status, "Billed") + def test_get_timesheets_list_portal_sales_invoice(self): + # get_timesheets_list selects COALESCE(timesheet.sales_invoice, detail.sales_invoice). The earlier + # `timesheet.sales_invoice | detail.sales_invoice` bitwise-ORed two varchars -- it errored on + # Postgres and returned 0 (names cast to int) on MariaDB. + from erpnext.projects.doctype.timesheet.timesheet import get_timesheets_list + + customer = "_Test Customer" + + # tie the current user (Administrator) to the customer so the portal resolves it + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": "_Test Timesheet Portal Contact", + "user": "Administrator", + "links": [{"link_doctype": "Customer", "link_name": customer}], + } + ).insert(ignore_permissions=True) + self.addCleanup(self._delete_if_exists, "Contact", contact.name) + + si = create_sales_invoice(customer=customer) + + employee = make_employee("_test_timesheet_portal@example.com", company="_Test Company") + timesheet = make_timesheet(employee, is_billable=0) + frappe.db.set_value("Timesheet", timesheet.name, "sales_invoice", si.name) + + rows = get_timesheets_list("Timesheet", None, {}, 0, 500) + + row = next((r for r in rows if r.name == timesheet.name), None) + self.assertIsNotNone(row, "billed timesheet not returned by portal list") + self.assertEqual(row.sales_invoice, si.name) + + @staticmethod + def _delete_if_exists(doctype, name): + if frappe.db.exists(doctype, name): + frappe.delete_doc(doctype, name, force=True) + def make_timesheet( employee, diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index eb0d665823f..04819e68a0f 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder.functions import Concat, Date, Round +from frappe.query_builder.functions import Coalesce, Concat, Date, Round from frappe.utils import flt, get_datetime, getdate from frappe.utils.deprecations import deprecated @@ -562,7 +562,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 child_table.activity_type, table.status, child_table.billing_hours, - (table.sales_invoice | child_table.sales_invoice).as_("sales_invoice"), + Coalesce(table.sales_invoice, child_table.sales_invoice).as_("sales_invoice"), child_table.project, ) .orderby(table.end_date)