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) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-21 10:20:39 +05:30
parent 3d9b704730
commit 5a0e7f57a3
2 changed files with 38 additions and 2 deletions

View File

@@ -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,

View File

@@ -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)