feat(collection-by-doctor) - fixed query
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "@repo/db/client";
|
import { prisma } from "@repo/db/client";
|
||||||
import {
|
import {
|
||||||
DoctorBalancesAndSummary,
|
|
||||||
GetPatientBalancesResult,
|
GetPatientBalancesResult,
|
||||||
PatientBalanceRow,
|
PatientBalanceRow,
|
||||||
} from "../../../../packages/db/types/payments-reports-types";
|
} from "../../../../packages/db/types/payments-reports-types";
|
||||||
@@ -81,10 +80,14 @@ function isoStartOfNextDayLiteral(d?: Date | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cursor helpers — base64(JSON) */
|
/** Cursor helpers — base64(JSON) */
|
||||||
|
/** Cursor format (backwards compatible):
|
||||||
|
* { staffId?: number, lastPaymentDate: string | null, lastPatientId: number, lastPaymentMs?: number | null }
|
||||||
|
*/
|
||||||
function encodeCursor(obj: {
|
function encodeCursor(obj: {
|
||||||
staffId?: number;
|
staffId?: number;
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
|
lastPaymentMs?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,7 @@ function decodeCursor(token?: string | null): {
|
|||||||
staffId?: number; // optional because older cursors might not include it
|
staffId?: number; // optional because older cursors might not include it
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
|
lastPaymentMs?: number | null;
|
||||||
} | null {
|
} | null {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +114,12 @@ function decodeCursor(token?: string | null): {
|
|||||||
? null
|
? null
|
||||||
: String((parsed as any).lastPaymentDate),
|
: String((parsed as any).lastPaymentDate),
|
||||||
lastPatientId: Number((parsed as any).lastPatientId),
|
lastPatientId: Number((parsed as any).lastPatientId),
|
||||||
|
lastPaymentMs:
|
||||||
|
"lastPaymentMs" in parsed
|
||||||
|
? parsed.lastPaymentMs === null
|
||||||
|
? null
|
||||||
|
: Number(parsed.lastPaymentMs)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -563,27 +573,45 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
? `AND pay."createdAt" <= ${toNextStart}`
|
? `AND pay."createdAt" <= ${toNextStart}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Keyset predicate for paging (same semantics as before)
|
// Keyset predicate — prefer numeric epoch-ms comparison for stability
|
||||||
let pageKeysetPredicate = "";
|
let pageKeysetPredicate = "";
|
||||||
if (effectiveCursor) {
|
if (effectiveCursor) {
|
||||||
const lp = effectiveCursor.lastPaymentDate
|
// Use epoch ms if present in cursor (more precise); otherwise fall back to timestamptz literal.
|
||||||
? `'${effectiveCursor.lastPaymentDate}'`
|
const hasCursorMs =
|
||||||
: "NULL";
|
typeof effectiveCursor.lastPaymentMs === "number" &&
|
||||||
|
!Number.isNaN(effectiveCursor.lastPaymentMs);
|
||||||
|
|
||||||
const id = Number(effectiveCursor.lastPatientId);
|
const id = Number(effectiveCursor.lastPatientId);
|
||||||
|
|
||||||
pageKeysetPredicate = `AND (
|
if (hasCursorMs) {
|
||||||
( ${lp} IS NOT NULL AND (
|
const lpMs = Number(effectiveCursor.lastPaymentMs);
|
||||||
(p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp})
|
// Compare numeric epoch ms; handle NULL last_payment_date rows too.
|
||||||
OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id})
|
pageKeysetPredicate = `
|
||||||
|
AND (
|
||||||
|
(p.last_payment_ms IS NOT NULL AND ${lpMs} IS NOT NULL AND (
|
||||||
|
p.last_payment_ms < ${lpMs}
|
||||||
|
OR (p.last_payment_ms = ${lpMs} AND p.id < ${id})
|
||||||
|
))
|
||||||
|
OR (p.last_payment_ms IS NULL AND ${lpMs} IS NOT NULL)
|
||||||
|
OR (p.last_payment_ms IS NULL AND ${lpMs} IS NULL AND p.id < ${id})
|
||||||
)
|
)
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// fall back to timestamptz string literal (older cursor)
|
||||||
|
const lpLiteral = effectiveCursor.lastPaymentDate
|
||||||
|
? `('${effectiveCursor.lastPaymentDate}'::timestamptz)`
|
||||||
|
: "NULL";
|
||||||
|
pageKeysetPredicate = `
|
||||||
|
AND (
|
||||||
|
(p.last_payment_date IS NOT NULL AND ${lpLiteral} IS NOT NULL AND (
|
||||||
|
p.last_payment_date < ${lpLiteral}
|
||||||
|
OR (p.last_payment_date = ${lpLiteral} AND p.id < ${id})
|
||||||
|
))
|
||||||
|
OR (p.last_payment_date IS NULL AND ${lpLiteral} IS NOT NULL)
|
||||||
|
OR (p.last_payment_date IS NULL AND ${lpLiteral} IS NULL AND p.id < ${id})
|
||||||
)
|
)
|
||||||
OR
|
`;
|
||||||
( ${lp} IS NULL AND (
|
}
|
||||||
p.last_payment_date IS NOT NULL
|
|
||||||
OR (p.last_payment_date IS NULL AND p.id < ${id})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentsJoinForPatients =
|
const paymentsJoinForPatients =
|
||||||
@@ -630,6 +658,10 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted,
|
COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted,
|
||||||
(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance,
|
(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance,
|
||||||
pa.last_payment_date,
|
pa.last_payment_date,
|
||||||
|
-- epoch milliseconds for last payment date (NULL when last_payment_date is NULL)
|
||||||
|
(CASE WHEN pa.last_payment_date IS NULL THEN NULL
|
||||||
|
ELSE (EXTRACT(EPOCH FROM (pa.last_payment_date AT TIME ZONE 'UTC')) * 1000)::bigint
|
||||||
|
END) AS last_payment_ms,
|
||||||
la.last_appointment_date
|
la.last_appointment_date
|
||||||
FROM "Patient" p
|
FROM "Patient" p
|
||||||
INNER JOIN staff_patients sp ON sp.patient_id = p.id
|
INNER JOIN staff_patients sp ON sp.patient_id = p.id
|
||||||
@@ -638,7 +670,9 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Query A: fetch the page of patient rows as JSON array
|
// Fetch one extra row to detect whether there's a following page.
|
||||||
|
const fetchLimit = safeLimit + 1;
|
||||||
|
|
||||||
const balancesQuery = `
|
const balancesQuery = `
|
||||||
${commonCtes}
|
${commonCtes}
|
||||||
|
|
||||||
@@ -651,38 +685,34 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
p.total_paid::text AS "totalPaid",
|
p.total_paid::text AS "totalPaid",
|
||||||
p.total_adjusted::text AS "totalAdjusted",
|
p.total_adjusted::text AS "totalAdjusted",
|
||||||
p.current_balance::text AS "currentBalance",
|
p.current_balance::text AS "currentBalance",
|
||||||
p.last_payment_date AS "lastPaymentDate",
|
-- ISO text for UI (optional)
|
||||||
p.last_appointment_date AS "lastAppointmentDate"
|
to_char(p.last_payment_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastPaymentDate",
|
||||||
|
-- epoch ms (number) used for precise keyset comparisons
|
||||||
|
p.last_payment_ms::bigint AS "lastPaymentMs",
|
||||||
|
to_char(p.last_appointment_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastAppointmentDate"
|
||||||
FROM patients p
|
FROM patients p
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
${pageKeysetPredicate}
|
${pageKeysetPredicate}
|
||||||
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
||||||
LIMIT ${safeLimit}
|
LIMIT ${fetchLimit}
|
||||||
) t;
|
) t;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Query Count: total_count (same logic as previous combined query's CASE)
|
|
||||||
const countQuery = `
|
|
||||||
${commonCtes}
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
|
||||||
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
|
||||||
ELSE
|
|
||||||
(SELECT COUNT(*)::int FROM staff_patients)
|
|
||||||
END) AS total_count;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Execute balancesQuery
|
|
||||||
const balancesRawRows = (await prisma.$queryRawUnsafe(
|
const balancesRawRows = (await prisma.$queryRawUnsafe(
|
||||||
balancesQuery
|
balancesQuery
|
||||||
)) as Array<{ balances_json?: any }>;
|
)) as Array<{ balances_json?: any }>;
|
||||||
|
|
||||||
const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? [];
|
const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? [];
|
||||||
|
const fetchedArr = Array.isArray(balancesJson) ? balancesJson : [];
|
||||||
|
|
||||||
const balancesArr = Array.isArray(balancesJson) ? balancesJson : [];
|
// If we fetched > safeLimit, there is another page.
|
||||||
|
let hasMore = false;
|
||||||
|
let pageRows = fetchedArr;
|
||||||
|
if (fetchedArr.length > safeLimit) {
|
||||||
|
hasMore = true;
|
||||||
|
pageRows = fetchedArr.slice(0, safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
const balances: PatientBalanceRow[] = balancesArr.map((r: any) => ({
|
const balances: PatientBalanceRow[] = (pageRows || []).map((r: any) => ({
|
||||||
patientId: Number(r.patientId),
|
patientId: Number(r.patientId),
|
||||||
firstName: r.firstName ?? null,
|
firstName: r.firstName ?? null,
|
||||||
lastName: r.lastName ?? null,
|
lastName: r.lastName ?? null,
|
||||||
@@ -698,21 +728,52 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Determine hasMore and nextCursor
|
// Build nextCursor only when we actually have more rows.
|
||||||
const hasMore = balances.length === safeLimit;
|
|
||||||
let nextCursor: string | null = null;
|
let nextCursor: string | null = null;
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
const last = balances[balances.length - 1];
|
// If we somehow have no balances for this page (defensive), don't build a cursor.
|
||||||
if (last) {
|
if (!Array.isArray(balances) || balances.length === 0) {
|
||||||
|
nextCursor = null;
|
||||||
|
} else {
|
||||||
|
// Now balances.length > 0, so last is definitely present.
|
||||||
|
const lastIndex = balances.length - 1;
|
||||||
|
const last = balances[lastIndex];
|
||||||
|
if (!last) {
|
||||||
|
// defensive fallback (shouldn't happen because of length check)
|
||||||
|
nextCursor = null;
|
||||||
|
} else {
|
||||||
|
// get the raw JSON row corresponding to the last returned page row so we can read the numeric ms
|
||||||
|
// `pageRows` is the array of raw JSON objects fetched from the DB (slice(0, safeLimit) applied above).
|
||||||
|
const corresponding = (pageRows as any[])[pageRows.length - 1];
|
||||||
|
const lastPaymentMs =
|
||||||
|
typeof corresponding?.lastPaymentMs === "number"
|
||||||
|
? Number(corresponding.lastPaymentMs)
|
||||||
|
: corresponding?.lastPaymentMs === null
|
||||||
|
? null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
nextCursor = encodeCursor({
|
nextCursor = encodeCursor({
|
||||||
staffId: Number(staffId),
|
staffId: Number(staffId),
|
||||||
lastPaymentDate: last.lastPaymentDate,
|
lastPaymentDate: last.lastPaymentDate ?? null,
|
||||||
lastPatientId: Number(last.patientId),
|
lastPatientId: Number(last.patientId),
|
||||||
|
lastPaymentMs: lastPaymentMs ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count query (same logic as before)
|
||||||
|
const countQuery = `
|
||||||
|
${commonCtes}
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
||||||
|
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
||||||
|
ELSE
|
||||||
|
(SELECT COUNT(*)::int FROM staff_patients)
|
||||||
|
END) AS total_count;
|
||||||
|
`;
|
||||||
|
|
||||||
// Execute countQuery
|
|
||||||
const countRows = (await prisma.$queryRawUnsafe(countQuery)) as Array<{
|
const countRows = (await prisma.$queryRawUnsafe(countQuery)) as Array<{
|
||||||
total_count?: number;
|
total_count?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export default function CollectionsByDoctorReport({
|
|||||||
const isLastKnown = idx === cursorStack.length - 1;
|
const isLastKnown = idx === cursorStack.length - 1;
|
||||||
|
|
||||||
if (isLastKnown) {
|
if (isLastKnown) {
|
||||||
if (serverNextCursor) {
|
if (serverNextCursor && serverNextCursor !== currentCursor && balances.length > 0) {
|
||||||
setCursorStack((s) => [...s, serverNextCursor]);
|
setCursorStack((s) => [...s, serverNextCursor]);
|
||||||
setCursorIndex((i) => i + 1);
|
setCursorIndex((i) => i + 1);
|
||||||
// React Query will fetch automatically because queryKey includes currentCursor
|
// React Query will fetch automatically because queryKey includes currentCursor
|
||||||
@@ -171,7 +171,7 @@ export default function CollectionsByDoctorReport({
|
|||||||
} else {
|
} else {
|
||||||
setCursorIndex((i) => i + 1);
|
setCursorIndex((i) => i + 1);
|
||||||
}
|
}
|
||||||
}, [cursorIndex, cursorStack.length, serverNextCursor]);
|
}, [cursorIndex, cursorStack.length, serverNextCursor, balances, currentCursor]);
|
||||||
|
|
||||||
// Map server rows to GenericRow
|
// Map server rows to GenericRow
|
||||||
const genericRows: GenericRow[] = balances.map((r) => {
|
const genericRows: GenericRow[] = balances.map((r) => {
|
||||||
|
|||||||
@@ -16,16 +16,3 @@ export interface GetPatientBalancesResult {
|
|||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DoctorBalancesAndSummary {
|
|
||||||
balances: PatientBalanceRow[];
|
|
||||||
totalCount: number;
|
|
||||||
nextCursor: string | null;
|
|
||||||
hasMore: boolean;
|
|
||||||
summary: {
|
|
||||||
totalPatients: number;
|
|
||||||
totalOutstanding: number;
|
|
||||||
totalCollected: number;
|
|
||||||
patientsWithBalance: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user