From c5edac2ab86f93792421a790d0dcf468e79f15e6 Mon Sep 17 00:00:00 2001 From: Potenz Date: Sat, 30 Aug 2025 01:11:59 +0530 Subject: [PATCH] feat(ocr payment page) - added backend routes on app --- apps/Backend/src/routes/index.ts | 6 +- ...Extraction.ts => patientDataExtraction.ts} | 6 +- .../src/routes/paymentOcrExtraction.ts | 50 ++++++ ...ient.ts => patientDataExtractorService.ts} | 2 +- .../Backend/src/services/paymentOCRService.ts | 34 ++++ apps/Frontend/src/hooks/use-extractPdfData.ts | 2 +- apps/PaymentOCRService/.env.example | 3 + apps/PaymentOCRService/.gitignore | 1 + .../complete_pipeline.cpython-313.pyc | Bin 0 -> 46058 bytes .../complete_pipeline_adapter.cpython-313.pyc | Bin 0 -> 3469 bytes apps/PaymentOCRService/app/init.py | 0 apps/PaymentOCRService/app/main.py | 81 --------- ...plete-pipeline.py => complete_pipeline.py} | 2 + ...daptor.py => complete_pipeline_adapter.py} | 5 +- apps/PaymentOCRService/main.py | 168 ++++++++++++++++++ apps/PaymentOCRService/package.json | 2 +- apps/PaymentOCRService/requirements.txt | 36 ++-- package-lock.json | 21 ++- package.json | 4 +- 19 files changed, 314 insertions(+), 109 deletions(-) rename apps/Backend/src/routes/{pdfExtraction.ts => patientDataExtraction.ts} (62%) create mode 100644 apps/Backend/src/routes/paymentOcrExtraction.ts rename apps/Backend/src/services/{pdfClient.ts => patientDataExtractorService.ts} (89%) create mode 100644 apps/Backend/src/services/paymentOCRService.ts create mode 100644 apps/PaymentOCRService/.gitignore create mode 100644 apps/PaymentOCRService/__pycache__/complete_pipeline.cpython-313.pyc create mode 100644 apps/PaymentOCRService/__pycache__/complete_pipeline_adapter.cpython-313.pyc delete mode 100644 apps/PaymentOCRService/app/init.py delete mode 100644 apps/PaymentOCRService/app/main.py rename apps/PaymentOCRService/{complete-pipeline.py => complete_pipeline.py} (99%) rename apps/PaymentOCRService/{app/pipeline-adaptor.py => complete_pipeline_adapter.py} (96%) create mode 100644 apps/PaymentOCRService/main.py diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index e177dfb..ffe1e93 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -3,14 +3,15 @@ import patientsRoutes from "./patients"; import appointmentsRoutes from "./appointments"; import usersRoutes from "./users"; import staffsRoutes from "./staffs"; -import pdfExtractionRoutes from "./pdfExtraction"; import claimsRoutes from "./claims"; +import patientDataExtractionRoutes from "./patientdataExtraction"; import insuranceCredsRoutes from "./insuranceCreds"; import documentsRoutes from "./documents"; import insuranceEligibilityRoutes from "./insuranceEligibility"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; +import paymentOcrRoutes from "./paymentOcrExtraction"; const router = Router(); @@ -18,7 +19,7 @@ router.use("/patients", patientsRoutes); router.use("/appointments", appointmentsRoutes); router.use("/users", usersRoutes); router.use("/staffs", staffsRoutes); -router.use("/pdfExtraction", pdfExtractionRoutes); +router.use("/patientDataExtraction", patientDataExtractionRoutes); router.use("/claims", claimsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/documents", documentsRoutes); @@ -26,5 +27,6 @@ router.use("/insuranceEligibility", insuranceEligibilityRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); +router.use("/payment-ocr", paymentOcrRoutes); export default router; diff --git a/apps/Backend/src/routes/pdfExtraction.ts b/apps/Backend/src/routes/patientDataExtraction.ts similarity index 62% rename from apps/Backend/src/routes/pdfExtraction.ts rename to apps/Backend/src/routes/patientDataExtraction.ts index 8b01282..92fc227 100644 --- a/apps/Backend/src/routes/pdfExtraction.ts +++ b/apps/Backend/src/routes/patientDataExtraction.ts @@ -2,17 +2,17 @@ import { Router } from "express"; import type { Request, Response } from "express"; const router = Router(); import multer from "multer"; -import forwardToPdfService from "../services/pdfClient"; +import forwardToPatientDataExtractorService from "../services/patientDataExtractorService"; const upload = multer({ storage: multer.memoryStorage() }); -router.post("/extract", upload.single("pdf"), async (req: Request, res: Response): Promise=> { +router.post("/patientdataextract", upload.single("pdf"), async (req: Request, res: Response): Promise=> { if (!req.file) { return res.status(400).json({ error: "No PDF file uploaded." }); } try { - const result = await forwardToPdfService(req.file); + const result = await forwardToPatientDataExtractorService(req.file); res.json(result); } catch (err) { console.error(err); diff --git a/apps/Backend/src/routes/paymentOcrExtraction.ts b/apps/Backend/src/routes/paymentOcrExtraction.ts new file mode 100644 index 0000000..34e2244 --- /dev/null +++ b/apps/Backend/src/routes/paymentOcrExtraction.ts @@ -0,0 +1,50 @@ +import { Router, Request, Response } from "express"; +import multer from "multer"; +import { forwardToPaymentOCRService } from "../services/paymentOCRService"; + +const router = Router(); + +// keep files in memory; FastAPI accepts them as multipart bytes +const upload = multer({ storage: multer.memoryStorage() }); + +// POST /payment-ocr/extract (field name: "files") +router.post( + "/extract", + upload.array("files"), // allow multiple images + async (req: Request, res: Response): Promise => { + try { + const files = req.files as Express.Multer.File[] | undefined; + + if (!files || files.length === 0) { + return res + .status(400) + .json({ error: "No image files uploaded. Use field name 'files'." }); + } + + // (optional) basic client-side MIME guard + const allowed = new Set([ + "image/jpeg", + "image/png", + "image/tiff", + "image/bmp", + "image/jpg", + ]); + const bad = files.filter((f) => !allowed.has(f.mimetype.toLowerCase())); + if (bad.length) { + return res.status(415).json({ + error: `Unsupported file types: ${bad + .map((b) => b.originalname) + .join(", ")}`, + }); + } + + const rows = await forwardToPaymentOCRService(files); + return res.json({ rows }); + } catch (err) { + console.error(err); + return res.status(500).json({ error: "Payment OCR extraction failed" }); + } + } +); + +export default router; diff --git a/apps/Backend/src/services/pdfClient.ts b/apps/Backend/src/services/patientDataExtractorService.ts similarity index 89% rename from apps/Backend/src/services/pdfClient.ts rename to apps/Backend/src/services/patientDataExtractorService.ts index 101239b..a99c270 100644 --- a/apps/Backend/src/services/pdfClient.ts +++ b/apps/Backend/src/services/patientDataExtractorService.ts @@ -9,7 +9,7 @@ export interface ExtractedData { [key: string]: any; } -export default async function forwardToPdfService( +export default async function forwardToPatientDataExtractorService( file: Express.Multer.File ): Promise { const form = new FormData(); diff --git a/apps/Backend/src/services/paymentOCRService.ts b/apps/Backend/src/services/paymentOCRService.ts new file mode 100644 index 0000000..e8608b7 --- /dev/null +++ b/apps/Backend/src/services/paymentOCRService.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import FormData from "form-data"; + +export async function forwardToPaymentOCRService( + files: Express.Multer.File | Express.Multer.File[] +): Promise { + const arr = Array.isArray(files) ? files : [files]; + + const form = new FormData(); + for (const f of arr) { + form.append("files", f.buffer, { + filename: f.originalname, + contentType: f.mimetype, // image/jpeg, image/png, image/tiff, etc. + knownLength: f.size, + }); + } + + const url = `http://localhost:5003/extract/json`; + + try { + const resp = await axios.post<{ rows: any }>(url, form, { + headers: form.getHeaders(), + maxBodyLength: Infinity, + maxContentLength: Infinity, + timeout: 120000, // OCR can be heavy; adjust as needed + }); + return resp.data?.rows ?? []; + } catch (err: any) { + // Bubble up a useful error message + const status = err?.response?.status; + const detail = err?.response?.data?.detail || err?.message || "Unknown error"; + throw new Error(`Payment OCR request failed${status ? ` (${status})` : ""}: ${detail}`); + } +} diff --git a/apps/Frontend/src/hooks/use-extractPdfData.ts b/apps/Frontend/src/hooks/use-extractPdfData.ts index 02d07b9..17d8a1e 100644 --- a/apps/Frontend/src/hooks/use-extractPdfData.ts +++ b/apps/Frontend/src/hooks/use-extractPdfData.ts @@ -16,7 +16,7 @@ export default function useExtractPdfData() { const formData = new FormData(); formData.append("pdf", pdfFile); - const res = await apiRequest("POST", "/api/pdfExtraction/extract", formData); + const res = await apiRequest("POST", "/api/patientDataExtraction/patientdataextract", formData); if (!res.ok) throw new Error("Failed to extract PDF"); return res.json(); }, diff --git a/apps/PaymentOCRService/.env.example b/apps/PaymentOCRService/.env.example index e69de29..2b436fd 100644 --- a/apps/PaymentOCRService/.env.example +++ b/apps/PaymentOCRService/.env.example @@ -0,0 +1,3 @@ +GOOGLE_APPLICATION_CREDENTIALS=google-credentials.json +HOST="0.0.0.0" +PORT="5003" \ No newline at end of file diff --git a/apps/PaymentOCRService/.gitignore b/apps/PaymentOCRService/.gitignore new file mode 100644 index 0000000..a79e064 --- /dev/null +++ b/apps/PaymentOCRService/.gitignore @@ -0,0 +1 @@ +google_credentials.json \ No newline at end of file diff --git a/apps/PaymentOCRService/__pycache__/complete_pipeline.cpython-313.pyc b/apps/PaymentOCRService/__pycache__/complete_pipeline.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6440d43ff5f1e6a8a730da28e1293638f2490da6 GIT binary patch literal 46058 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 CY+Zo> literal 0 HcmV?d00001 diff --git a/apps/PaymentOCRService/__pycache__/complete_pipeline_adapter.cpython-313.pyc b/apps/PaymentOCRService/__pycache__/complete_pipeline_adapter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10d6eb8d4a3439c1a5aa499cfb7d9cd9e86f546b GIT binary patch literal 3469 zcmb7GO>h&*74G?!G}2hce;8w6Y`m-$gPjFCU^Z;6ZP|cXY@)G@6T3k5`tFs$3I;84^aQ46R-KUh|NVHZXy+_%sGV1BZC>3v**}B4s(M% z=9MyEz#{OubG|`8_74Vd0QmfbqzV&3Rh-}^cmZ^EimI;xUFC5|^#f0$JT(AR(4}6L zlUzAj4SIV)i`2=hA(vk<$wcIDsF_0%*5N-gi0$xEBWc5IH_2+)PNa15u|AcUGVCjf zjA81;*{@IASWDUoEt5$kGlp*3B$38ec59u#oUtxDp_D;#8EqzEYFXV8v2N$FnPll% zTde{{4*r{qz`cpaJwA%PDz(Lp(JybGq3#s1_wBP>TBw1r;3`D8iA>WH9X^*Elgn$b9kC$ zFwFrDH>um0!rc+I99&Gw5%VT^`C}*0Kb_Qb)X$LsrnwpWX(v-zzYWfEPv(Gfc*9Iv zxRS2$)b|d934jPGUUPolcXA?5bWA2LShj8!CZdohZ7T<(X=|DDnyF3dcmhlS32i0| z+~K~Ms^e=$QlCg#*&MyPgckw{Ev4mb9k=IZoV|%0wy0YY1fnFPCyXrUO1R;NDW0h% zM$Nno+{%gTc#0k|)YQTx7>`@es70@du>M2uYf_sW_Q2jln8 zeQ(?M?L0>;3%!?p{+}^aOwJI78_ZQorE)f@OjJf@df#@IX5PErY}f(AcHnNnR^{ zG*;c>cNK`62ogLyPT}pgF~&>%kjk47%0o%Xb&gZ{@_MJY4#k9hYiSgA67KnfTj`%| zK@p|^z|LRJ7)hn)!dV4ULkbd`GN2Kg*B-bvvwx3iokf{_at+)|bPJq((_ncI+ znw`8tGnxB+3P48Ls`^TY@=A75l2#_4HKhpWa0Mgh2C*Xox&fXM5z|V5BhWd1`lO(M zE90|~F0?>LD4@&O!-uF~od*%uGLo>Z^0szZ{4w2d9J*Ol3ZcgRvuB>v?OA;1tGc#% zX^wvq{?T&hM))Ule9>RI5S|Y&@?ZJ)Jr&UI!zDk&#q97VY%L#~+`bXuP!rr6Q-J%w zo-0uDwjT^8ly8;!8Dp|>wi=CcgzZ2Cs54M1tz&T}#?u2|Z|}pDK4Q+-K%wR?u;-x| z(h^QslY9G)*EGZP1T?engQcz(c$vCd;K}7_IRYn`{8$YQM3VuXG$X#zRCqphGclTP z-e@ZC#a9d1RJJSq3KbCwgHoSmUemF?Q|IW-(gND2j&4=cRvI(#b~I@y(4{0)!F*O% zG@?M6)h=iBNV|mTV`m`3ID%oO^yz|-x6?;XL^Y&WIyIJS53A zh%qw>1uG)BQRJ|xwBv&Zt%3EF!z3LZ%JdsdK7G3 z54JAv-Uz;TpMUSWZz261`|d&4B|=|Vzpd^S`9F!!t%d@Mno$UHS4RY&expWF{v2i$JV;8KL;tEqYe2Q=frCk*If10&Y z`HcPn?gbtN3n9=!HfET|DDW7C|AF>BM%6Dw#Edc}{vD=vNiQK7R*t#D-Oe?B_2e_k zDGf0}re>*SDSx}Ign(RWSs{Okxa1nYHu5*=8ReE5Waj8%bgAaogCzvya@W7;;2QN` D1N$n8 literal 0 HcmV?d00001 diff --git a/apps/PaymentOCRService/app/init.py b/apps/PaymentOCRService/app/init.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/PaymentOCRService/app/main.py b/apps/PaymentOCRService/app/main.py deleted file mode 100644 index 044937d..0000000 --- a/apps/PaymentOCRService/app/main.py +++ /dev/null @@ -1,81 +0,0 @@ -from fastapi import FastAPI, UploadFile, File, HTTPException -from fastapi.responses import StreamingResponse, JSONResponse, PlainTextResponse -from typing import List, Optional -import io -import os - -from app.pipeline_adapter import ( - process_images_to_rows, - rows_to_csv_bytes, -) - -app = FastAPI( - title="Medical Billing OCR API", - description="FastAPI wrapper around the complete OCR pipeline (Google Vision + deskew + line clustering + extraction).", - version="1.0.0", -) - -ALLOWED_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"} - -@app.get("/health", response_class=PlainTextResponse) -def health(): - # Simple sanity check (also ensures GCP creds var visibility) - creds = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") - return f"OK | GOOGLE_APPLICATION_CREDENTIALS set: {bool(creds)}" - -@app.post("/extract/json") -async def extract_json(files: List[UploadFile] = File(...)): - if not files: - raise HTTPException(status_code=400, detail="No files provided.") - - # Validate extensions early (not bulletproof, but helpful) - bad = [f.filename for f in files if os.path.splitext(f.filename or "")[1].lower() not in ALLOWED_EXTS] - if bad: - raise HTTPException( - status_code=415, - detail=f"Unsupported file types: {', '.join(bad)}. Allowed: {', '.join(sorted(ALLOWED_EXTS))}" - ) - - # Read blobs in-memory - blobs = [] - filenames = [] - for f in files: - blobs.append(await f.read()) - filenames.append(f.filename or "upload.bin") - - try: - rows = process_images_to_rows(blobs, filenames) - # rows is a list[dict] where each dict contains the columns you already emit (Patient Name, etc.) - return JSONResponse(content={"rows": rows}) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Processing error: {e}") - -@app.post("/extract/csv") -async def extract_csv(files: List[UploadFile] = File(...), filename: Optional[str] = None): - if not files: - raise HTTPException(status_code=400, detail="No files provided.") - - bad = [f.filename for f in files if os.path.splitext(f.filename or "")[1].lower() not in ALLOWED_EXTS] - if bad: - raise HTTPException( - status_code=415, - detail=f"Unsupported file types: {', '.join(bad)}. Allowed: {', '.join(sorted(ALLOWED_EXTS))}" - ) - - blobs = [] - filenames = [] - for f in files: - blobs.append(await f.read()) - filenames.append(f.filename or "upload.bin") - - try: - rows = process_images_to_rows(blobs, filenames) - csv_bytes = rows_to_csv_bytes(rows) - out_name = filename or "medical_billing_extract.csv" - return StreamingResponse( - io.BytesIO(csv_bytes), - media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="{out_name}"'} - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Processing error: {e}") diff --git a/apps/PaymentOCRService/complete-pipeline.py b/apps/PaymentOCRService/complete_pipeline.py similarity index 99% rename from apps/PaymentOCRService/complete-pipeline.py rename to apps/PaymentOCRService/complete_pipeline.py index d713127..63036ef 100644 --- a/apps/PaymentOCRService/complete-pipeline.py +++ b/apps/PaymentOCRService/complete_pipeline.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- """ +ALL IS GENERATED BY REPLIT: + End-to-end local pipeline (single script) - One Google Vision pass per image (DOCUMENT_TEXT_DETECTION) diff --git a/apps/PaymentOCRService/app/pipeline-adaptor.py b/apps/PaymentOCRService/complete_pipeline_adapter.py similarity index 96% rename from apps/PaymentOCRService/app/pipeline-adaptor.py rename to apps/PaymentOCRService/complete_pipeline_adapter.py index c52fb04..252f74c 100644 --- a/apps/PaymentOCRService/app/pipeline-adaptor.py +++ b/apps/PaymentOCRService/complete_pipeline_adapter.py @@ -4,10 +4,7 @@ from typing import List, Dict import pandas as pd # Import your existing functions directly from complete_pipeline.py -from complete_pipeline import ( - smart_deskew_with_lines, - extract_all_clients_from_lines, -) +from complete_pipeline import smart_deskew_with_lines, extract_all_clients_from_lines def _process_single_image_bytes(blob: bytes, display_name: str) -> List[Dict]: """ diff --git a/apps/PaymentOCRService/main.py b/apps/PaymentOCRService/main.py new file mode 100644 index 0000000..502232e --- /dev/null +++ b/apps/PaymentOCRService/main.py @@ -0,0 +1,168 @@ +from fastapi import FastAPI, UploadFile, File, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, JSONResponse, PlainTextResponse +from typing import List, Optional +import io +import os +import asyncio + +from dotenv import load_dotenv +load_dotenv() # loads .env (GOOGLE_APPLICATION_CREDENTIALS, HOST, PORT, etc.) + +# Your adapter that calls the pipeline +from complete_pipeline_adapter import process_images_to_rows,rows_to_csv_bytes + +# ------------------------------------------------- +# App + concurrency controls (similar to your other app) +# ------------------------------------------------- +app = FastAPI( + title="Payment OCR Services API", + description="FastAPI wrapper around the OCR pipeline (Google Vision + deskew + line grouping + extraction).", + version="1.0.0", +) + +# Concurrency/semaphore (optional but useful for OCR) +MAX_CONCURRENCY = int(os.getenv("MAX_CONCURRENCY", "2")) +semaphore = asyncio.Semaphore(MAX_CONCURRENCY) + +active_jobs = 0 +waiting_jobs = 0 +lock = asyncio.Lock() + +# CORS +cors_origins = os.getenv("CORS_ORIGINS", "*") +allow_origins = ["*"] if cors_origins.strip() == "*" else [o.strip() for o in cors_origins.split(",") if o.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_methods=["*"], + allow_headers=["*"], +) + +ALLOWED_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"} + +# ------------------------------------------------- +# Health + status +# ------------------------------------------------- +@app.get("/health", response_class=PlainTextResponse) +def health(): + creds = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") + return f"OK | GOOGLE_APPLICATION_CREDENTIALS set: {bool(creds)}" + +@app.get("/status") +async def get_status(): + async with lock: + return { + "active_jobs": active_jobs, + "queued_jobs": waiting_jobs, + "max_concurrency": MAX_CONCURRENCY, + "status": "busy" if active_jobs > 0 or waiting_jobs > 0 else "idle", + } + +# ------------------------------------------------- +# Helpers +# ------------------------------------------------- +def _validate_files(files: List[UploadFile]): + if not files: + raise HTTPException(status_code=400, detail="No files provided.") + bad = [f.filename for f in files if os.path.splitext(f.filename or "")[1].lower() not in ALLOWED_EXTS] + if bad: + raise HTTPException( + status_code=415, + detail=f"Unsupported file types: {', '.join(bad)}. Allowed: {', '.join(sorted(ALLOWED_EXTS))}" + ) + +# ------------------------------------------------- +# Endpoints +# ------------------------------------------------- +@app.post("/extract/json") +async def extract_json(files: List[UploadFile] = File(...)): + _validate_files(files) + + async with lock: + global waiting_jobs + waiting_jobs += 1 + + async with semaphore: + async with lock: + waiting_jobs -= 1 + global active_jobs + active_jobs += 1 + + try: + blobs = [await f.read() for f in files] + names = [f.filename or "upload.bin" for f in files] + rows = process_images_to_rows(blobs, names) # calls your pipeline + return JSONResponse(content={"rows": rows}) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Processing error: {e}") + finally: + async with lock: + active_jobs -= 1 + +@app.post("/extract/csvtext", response_class=PlainTextResponse) +async def extract_csvtext(files: List[UploadFile] = File(...)): + _validate_files(files) + + async with lock: + global waiting_jobs + waiting_jobs += 1 + + async with semaphore: + async with lock: + waiting_jobs -= 1 + global active_jobs + active_jobs += 1 + + try: + blobs = [await f.read() for f in files] + names = [f.filename or "upload.bin" for f in files] + rows = process_images_to_rows(blobs, names) + csv_bytes = rows_to_csv_bytes(rows) + return PlainTextResponse(csv_bytes.decode("utf-8"), media_type="text/csv") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Processing error: {e}") + finally: + async with lock: + active_jobs -= 1 + +@app.post("/extract/csv") +async def extract_csv(files: List[UploadFile] = File(...), filename: Optional[str] = None): + _validate_files(files) + + async with lock: + global waiting_jobs + waiting_jobs += 1 + + async with semaphore: + async with lock: + waiting_jobs -= 1 + global active_jobs + active_jobs += 1 + + try: + blobs = [await f.read() for f in files] + names = [f.filename or "upload.bin" for f in files] + rows = process_images_to_rows(blobs, names) + csv_bytes = rows_to_csv_bytes(rows) + out_name = filename or "medical_billing_extract.csv" + return StreamingResponse( + io.BytesIO(csv_bytes), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{out_name}"'} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Processing error: {e}") + finally: + async with lock: + active_jobs -= 1 + +# ------------------------------------------------- +# Entrypoint (same pattern as your selenium app) +# ------------------------------------------------- +if __name__ == "__main__": + import uvicorn + host = os.getenv("HOST") + port = int(os.getenv("PORT")) + reload_flag = os.getenv("RELOAD", "false").lower() == "true" + uvicorn.run(app, host=host, port=port, reload=reload_flag) diff --git a/apps/PaymentOCRService/package.json b/apps/PaymentOCRService/package.json index a4416d1..4674a63 100644 --- a/apps/PaymentOCRService/package.json +++ b/apps/PaymentOCRService/package.json @@ -1,5 +1,5 @@ { - "name": "pdfservice", + "name": "paymentocrservice", "private": true, "scripts": { "postinstall": "pip install -r requirements.txt", diff --git a/apps/PaymentOCRService/requirements.txt b/apps/PaymentOCRService/requirements.txt index 7320dce..9c33a3f 100644 --- a/apps/PaymentOCRService/requirements.txt +++ b/apps/PaymentOCRService/requirements.txt @@ -1,10 +1,26 @@ -fastapi -uvicorn[standard] -google-cloud-vision -opencv-python-headless -pytesseract -pillow -pandas -openpyxl -numpy -python-multipart +annotated-types==0.7.0 +anyio==4.10.0 +click==8.2.1 +colorama==0.4.6 +et_xmlfile==2.0.0 +fastapi==0.116.1 +h11==0.16.0 +idna==3.10 +numpy==2.2.6 +google-cloud-vision>=3.10.2 +opencv-python==4.12.0.88 +openpyxl==3.1.5 +pandas==2.3.2 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +pytz==2025.2 +six==1.17.0 +sniffio==1.3.1 +starlette==0.47.3 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +tzdata==2025.2 +uvicorn==0.35.0 +python-multipart==0.0.20 diff --git a/package-lock.json b/package-lock.json index f653d58..43e67c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -163,8 +163,17 @@ "vite": "^6.3.5" } }, + "apps/PatientDataExtractorService": { + "name": "patientdataextractorservice", + "hasInstallScript": true + }, + "apps/PaymentOCRService": { + "name": "paymentocrservice", + "hasInstallScript": true + }, "apps/PdfService": { "name": "pdfservice", + "extraneous": true, "hasInstallScript": true }, "apps/SeleniumService": { @@ -9983,11 +9992,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/patientdataextractorservice": { + "resolved": "apps/PatientDataExtractorService", + "link": true + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/paymentocrservice": { + "resolved": "apps/PaymentOCRService", + "link": true + }, "node_modules/pdfjs-dist": { "version": "3.11.174", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", @@ -10001,10 +10018,6 @@ "path2d-polyfill": "^2.0.1" } }, - "node_modules/pdfservice": { - "resolved": "apps/PdfService", - "link": true - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", diff --git a/package.json b/package.json index d1b5783..7735671 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "db:generate": "prisma generate --schema=packages/db/prisma/schema.prisma && ts-node packages/db/scripts/patch-zod-buffer.ts", "db:migrate": "dotenv -e packages/db/.env -- prisma migrate dev --schema=packages/db/prisma/schema.prisma", "db:seed": "prisma db seed --schema=packages/db/prisma/schema.prisma", - "setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env", - "postinstall": "cd apps/PdfService && npm run postinstall" + "setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env", + "postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall" }, "prisma": { "seed": "ts-node packages/db/prisma/seed.ts"