feat(ocr) - route fixed, saving done

This commit is contained in:
2025-09-05 00:17:18 +05:30
parent 399c47dcfd
commit 62834f6eb9
11 changed files with 279 additions and 54 deletions

View File

@@ -1,9 +1,9 @@
HOST="localhost" HOST=localhost
PORT=5000 PORT=5000
FRONTEND_URL="http://localhost:3000" FRONTEND_URL=http://localhost:3000
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
DB_HOST="localhost" DB_HOST=localhost
DB_USER="postgres" DB_USER=postgres
DB_PASSWORD="mypassword" DB_PASSWORD=mypassword
DB_NAME="dentalapp" DB_NAME=dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp

View File

@@ -152,6 +152,42 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
} }
}); });
// POST /api/payments/full-ocr-import
router.post(
"/full-ocr-import",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { rows } = req.body;
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ message: "Invalid OCR payload" });
}
const paymentIds = await paymentService.fullOcrPaymentService.importRows(
rows,
userId
);
res.status(200).json({
message: "OCR rows imported successfully",
paymentIds,
});
} catch (err) {
console.error(err);
if (err instanceof Error) {
return res.status(500).json({ message: err.message });
}
return res
.status(500)
.json({ message: "Unknown error importing OCR payments" });
}
}
);
// POST /api/payments/:claimId // POST /api/payments/:claimId
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => { router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
try { try {

View File

@@ -1,7 +1,15 @@
import Decimal from "decimal.js"; import Decimal from "decimal.js";
import { NewTransactionPayload, Payment, PaymentStatus } from "@repo/db/types"; import {
NewTransactionPayload,
OcrRow,
Payment,
PaymentMethod,
paymentMethodOptions,
PaymentStatus,
} from "@repo/db/types";
import { storage } from "../storage"; import { storage } from "../storage";
import { prisma } from "@repo/db/client"; import { prisma } from "@repo/db/client";
import { convertOCRDate } from "../utils/dateUtils";
/** /**
* Validate transactions against a payment record * Validate transactions against a payment record
@@ -149,3 +157,112 @@ export async function updatePayment(
await validateTransactions(paymentId, serviceLineTransactions, options); await validateTransactions(paymentId, serviceLineTransactions, options);
return applyTransactions(paymentId, serviceLineTransactions, userId); return applyTransactions(paymentId, serviceLineTransactions, userId);
} }
// handling full-ocr-payments-import
export const fullOcrPaymentService = {
async importRows(rows: OcrRow[], userId: number) {
const results: number[] = [];
for (const [index, row] of rows.entries()) {
try {
if (!row.patientName || !row.insuranceId) {
throw new Error(
`Row ${index + 1}: missing patientName or insuranceId`
);
}
if (!row.procedureCode) {
throw new Error(`Row ${index + 1}: missing procedureCode`);
}
const billed = new Decimal(row.totalBilled ?? 0);
const allowed = new Decimal(row.totalAllowed ?? row.totalBilled ?? 0);
const paid = new Decimal(row.totalPaid ?? 0);
const adjusted = billed.minus(allowed); // write-off
const due = billed.minus(paid).minus(adjusted); // patient responsibility
// Step 13 in a transaction
const { paymentId, serviceLineId } = await prisma.$transaction(
async (tx) => {
// 1. Find or create patient
let patient = await tx.patient.findFirst({
where: { insuranceId: row.insuranceId.toString() },
});
if (!patient) {
const [firstNameRaw, ...rest] = (row.patientName ?? "")
.trim()
.split(" ");
const firstName = firstNameRaw || "Unknown";
const lastName = rest.length > 0 ? rest.join(" ") : "Unknown";
patient = await tx.patient.create({
data: {
firstName,
lastName,
insuranceId: row.insuranceId.toString(),
dateOfBirth: new Date(Date.UTC(1900, 0, 1)), // fallback (1900, jan, 1)
gender: "",
phone: "",
userId,
},
});
}
// 2. Create payment (claimId null) — IMPORTANT: start with zeros, due = billed
const payment = await tx.payment.create({
data: {
patientId: patient.id,
userId,
totalBilled: billed,
totalPaid: new Decimal(0),
totalAdjusted: new Decimal(0),
totalDue: billed,
status: "PENDING", // updatePayment will fix it
notes: `OCR import from ${row.sourceFile ?? "Unknown file"}`,
},
});
// 3. Create service line — IMPORTANT: start with zeros, due = billed
const serviceLine = await tx.serviceLine.create({
data: {
paymentId: payment.id,
procedureCode: row.procedureCode,
toothNumber: row.toothNumber ?? null,
toothSurface: row.toothSurface ?? null,
procedureDate: convertOCRDate(row.procedureDate),
totalBilled: billed,
totalPaid: new Decimal(0),
totalAdjusted: new Decimal(0),
totalDue: billed,
},
});
return { paymentId: payment.id, serviceLineId: serviceLine.id };
}
);
// Step 4: AFTER commit, recalc using updatePayment (global prisma can see it now)
// Build transaction & let updatePayment handle recalculation
const txn = {
serviceLineId,
paidAmount: paid.toNumber(),
adjustedAmount: adjusted.toNumber(),
method: "OTHER" as PaymentMethod,
receivedDate: new Date(),
notes: "OCR import",
};
await updatePayment(paymentId, [txn], userId);
results.push(paymentId);
} catch (err) {
console.error(`❌ Failed to import OCR row ${index + 1}:`, err);
throw err;
}
}
return results;
},
};

View File

@@ -820,12 +820,14 @@ export const storage: IStorage = {
serviceLines: true, serviceLines: true,
}, },
}, },
serviceLines: true,
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true, serviceLine: true,
}, },
}, },
updatedBy: true, updatedBy: true,
patient: true,
}, },
}); });
@@ -852,12 +854,14 @@ export const storage: IStorage = {
serviceLines: true, serviceLines: true,
}, },
}, },
serviceLines: true,
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true, serviceLine: true,
}, },
}, },
updatedBy: true, updatedBy: true,
patient: true,
}, },
}); });
@@ -882,12 +886,14 @@ export const storage: IStorage = {
serviceLines: true, serviceLines: true,
}, },
}, },
serviceLines: true,
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true, serviceLine: true,
}, },
}, },
updatedBy: true, updatedBy: true,
patient: true,
}, },
}); });
@@ -915,12 +921,14 @@ export const storage: IStorage = {
serviceLines: true, serviceLines: true,
}, },
}, },
serviceLines: true,
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true, serviceLine: true,
}, },
}, },
updatedBy: true, updatedBy: true,
patient: true,
}, },
}); });
@@ -950,12 +958,14 @@ export const storage: IStorage = {
serviceLines: true, serviceLines: true,
}, },
}, },
serviceLines: true,
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true, serviceLine: true,
}, },
}, },
updatedBy: true, updatedBy: true,
patient: true,
}, },
}); });

View File

@@ -1,7 +0,0 @@
export function extractDobParts(date: Date) {
return {
dob_day: date.getUTCDate(),
dob_month: date.getUTCMonth() + 1,
dob_year: date.getUTCFullYear(),
};
}

View File

@@ -0,0 +1,26 @@
/**
* Convert any OCR string-like value into a safe string.
*/
export function toStr(val: string | number | null | undefined): string {
if (val == null) return "";
return String(val).trim();
}
/**
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
* Example: "070825" → 2025-08-07.
*/
export function convertOCRDate(input: string | number | null | undefined): Date {
const raw = toStr(input);
if (!/^\d{6}$/.test(raw)) {
throw new Error(`Invalid OCR date format: ${raw}`);
}
const month = parseInt(raw.slice(0, 2), 10) - 1;
const day = parseInt(raw.slice(2, 4), 10);
const year2 = parseInt(raw.slice(4, 6), 10);
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
return new Date(year, month, day);
}

View File

@@ -156,41 +156,56 @@ export default function PaymentOCRBlock() {
const handleSave = async () => { const handleSave = async () => {
try { try {
const payload = rows.map((row) => { const skipped: string[] = [];
const billed = Number(row["Billed Amount"] ?? 0);
const allowed = Number(row["Allowed Amount"] ?? 0);
const paid = Number(row["Paid Amount"] ?? 0);
return { const payload = rows
patientId: parseInt(row["Patient ID"] as string, 10), .map((row, idx) => {
totalBilled: billed, const patientName = row["Patient Name"];
totalPaid: paid, const patientId = row["Patient ID"];
totalAdjusted: billed - allowed, // ❗ write-off const procedureCode = row["CDT Code"];
totalDue: allowed - paid, // ❗ patient responsibility
notes: `OCR import - CDT ${row["CDT Code"]}, Tooth ${row["Tooth"]}, Date ${row["Date SVC"]}`, if (!patientName || !patientId || !procedureCode) {
serviceLine: { skipped.push(`Row ${idx + 1} (missing name/id/procedureCode)`);
return null;
}
return {
patientName,
insuranceId: patientId,
icn: row["ICN"] ?? null,
procedureCode: row["CDT Code"], procedureCode: row["CDT Code"],
procedureDate: convertOCRDate(row["Date SVC"]), // youll parse "070825" → proper Date toothNumber: row["Tooth"] ?? null,
toothNumber: row["Tooth"], toothSurface: row["Surface"] ?? null,
totalBilled: billed, procedureDate: row["Date SVC"] ?? null,
totalPaid: paid, totalBilled: Number(row["Billed Amount"] ?? 0),
totalAdjusted: billed - allowed, totalAllowed: Number(row["Allowed Amount"] ?? 0),
totalDue: allowed - paid, totalPaid: Number(row["Paid Amount"] ?? 0),
}, sourceFile: row["Source File"] ?? null,
transaction: { };
paidAmount: paid, })
adjustedAmount: billed - allowed, // same as totalAdjusted .filter((r) => r !== null);
method: "OTHER", // fallback, since OCR doesnt give this
receivedDate: new Date(),
},
};
});
const res = await apiRequest( if (skipped.length > 0) {
"POST", toast({
"/api/payments/ocr", title:
JSON.stringify({ payments: payload }) "Some rows skipped, because of either no patient Name or MemberId given.",
); description: skipped.join(", "),
variant: "destructive",
});
}
if (payload.length === 0) {
toast({
title: "Error",
description: "No valid rows to save",
variant: "destructive",
});
return;
}
const res = await apiRequest("POST", "/api/payments/full-ocr-import", {
rows: payload,
});
if (!res.ok) throw new Error("Failed to save OCR payments"); if (!res.ok) throw new Error("Failed to save OCR payments");

View File

@@ -370,6 +370,11 @@ export default function PaymentsRecentTable({
paymentsData?.totalCount || 0 paymentsData?.totalCount || 0
); );
const getName = (p: PaymentWithExtras) =>
p.patient
? `${p.patient.firstName} ${p.patient.lastName}`.trim()
: (p.patientName ?? "Unknown");
const getInitials = (fullName: string) => { const getInitials = (fullName: string) => {
const parts = fullName.trim().split(/\s+/); const parts = fullName.trim().split(/\s+/);
const filteredParts = parts.filter((part) => part.length > 0); const filteredParts = parts.filter((part) => part.length > 0);
@@ -480,7 +485,7 @@ export default function PaymentsRecentTable({
<TableHead>Claim ID</TableHead> <TableHead>Claim ID</TableHead>
<TableHead>Patient Name</TableHead> <TableHead>Patient Name</TableHead>
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
<TableHead>Claim Submitted on</TableHead> <TableHead>Service Date</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@@ -519,6 +524,14 @@ export default function PaymentsRecentTable({
const totalPaid = Number(payment.totalPaid || 0); const totalPaid = Number(payment.totalPaid || 0);
const totalDue = Number(payment.totalDue || 0); const totalDue = Number(payment.totalDue || 0);
const displayName = getName(payment);
const submittedOn =
payment.serviceLines?.[0]?.procedureDate ??
payment.claim?.createdAt ??
payment.createdAt ??
payment.serviceLineTransactions?.[0]?.receivedDate ??
null;
return ( return (
<TableRow key={payment.id}> <TableRow key={payment.id}>
{allowCheckbox && ( {allowCheckbox && (
@@ -547,13 +560,13 @@ export default function PaymentsRecentTable({
className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`} className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`}
> >
<AvatarFallback className="text-white"> <AvatarFallback className="text-white">
{getInitials(payment.patientName)} {getInitials(displayName)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{payment.patientName} {displayName}
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
PID-{payment.patientId?.toString().padStart(4, "0")} PID-{payment.patientId?.toString().padStart(4, "0")}
@@ -585,7 +598,7 @@ export default function PaymentsRecentTable({
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{formatDateToHumanReadable(payment.paymentDate)} {formatDateToHumanReadable(submittedOn)}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -1,3 +1,3 @@
GOOGLE_APPLICATION_CREDENTIALS=google_credentials.json GOOGLE_APPLICATION_CREDENTIALS=google_credentials.json
HOST="0.0.0.0" HOST=0.0.0.0
PORT="5003" PORT=5003

View File

@@ -52,7 +52,7 @@ export interface InputServiceLine {
export type ClaimWithServiceLines = Claim & { export type ClaimWithServiceLines = Claim & {
serviceLines: { serviceLines: {
id: number; id: number;
claimId: number; claimId: number | null;
procedureCode: string; procedureCode: string;
procedureDate: Date; procedureDate: Date;
oralCavityArea: string | null; oralCavityArea: string | null;

View File

@@ -99,3 +99,18 @@ export const newTransactionPayloadSchema = z.object({
}); });
export type NewTransactionPayload = z.infer<typeof newTransactionPayloadSchema>; export type NewTransactionPayload = z.infer<typeof newTransactionPayloadSchema>;
// OCR Payment - row
export interface OcrRow {
patientName: string;
insuranceId: string | number;
icn?: string | null;
procedureCode: string;
toothNumber?: string | null;
toothSurface?: string | null;
procedureDate: string | null;
totalBilled: number;
totalAllowed?: number;
totalPaid: number;
sourceFile?: string | null;
}