From 27ea1fd4a6da76e2305b739b82f94d4da908ab38 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 18:15:10 -0600 Subject: [PATCH] [CL-006] feat: Implement laboratory module Add comprehensive laboratory module for managing lab tests, orders, and results: Entities: - LabTest: Catalog of available tests with reference ranges and categories - LabOrder: Lab orders linked to patients and consultations - LabResult: Results with values, status, and abnormal value detection Services: - LabTestService: CRUD operations for test catalog - LabOrderService: Order lifecycle (create, collect sample, process, complete, cancel) - LabResultService: Record results, verify, amend with history tracking Features: - Multi-tenant support (tenantId in all queries) - Automatic abnormal/critical value flagging - Result amendment with audit trail - Order number auto-generation - Reference range support by gender and age REST Endpoints: - /api/laboratory/tests/* - Test catalog management - /api/laboratory/orders/* - Order lifecycle management - /api/laboratory/results/* - Result recording and verification Co-Authored-By: Claude Opus 4.5 --- src/modules/laboratory/controllers/index.ts | 1 + .../controllers/laboratory.controller.ts | 501 ++++++++++++++++++ src/modules/laboratory/dto/index.ts | 384 ++++++++++++++ src/modules/laboratory/entities/index.ts | 3 + .../laboratory/entities/lab-order.entity.ts | 74 +++ .../laboratory/entities/lab-result.entity.ts | 100 ++++ .../laboratory/entities/lab-test.entity.ts | 82 +++ src/modules/laboratory/index.ts | 33 ++ src/modules/laboratory/laboratory.module.ts | 19 + src/modules/laboratory/services/index.ts | 3 + .../laboratory/services/lab-order.service.ts | 262 +++++++++ .../laboratory/services/lab-result.service.ts | 229 ++++++++ .../laboratory/services/lab-test.service.ts | 111 ++++ 13 files changed, 1802 insertions(+) create mode 100644 src/modules/laboratory/controllers/index.ts create mode 100644 src/modules/laboratory/controllers/laboratory.controller.ts create mode 100644 src/modules/laboratory/dto/index.ts create mode 100644 src/modules/laboratory/entities/index.ts create mode 100644 src/modules/laboratory/entities/lab-order.entity.ts create mode 100644 src/modules/laboratory/entities/lab-result.entity.ts create mode 100644 src/modules/laboratory/entities/lab-test.entity.ts create mode 100644 src/modules/laboratory/index.ts create mode 100644 src/modules/laboratory/laboratory.module.ts create mode 100644 src/modules/laboratory/services/index.ts create mode 100644 src/modules/laboratory/services/lab-order.service.ts create mode 100644 src/modules/laboratory/services/lab-result.service.ts create mode 100644 src/modules/laboratory/services/lab-test.service.ts diff --git a/src/modules/laboratory/controllers/index.ts b/src/modules/laboratory/controllers/index.ts new file mode 100644 index 0000000..0a5a017 --- /dev/null +++ b/src/modules/laboratory/controllers/index.ts @@ -0,0 +1 @@ +export { LaboratoryController } from './laboratory.controller'; diff --git a/src/modules/laboratory/controllers/laboratory.controller.ts b/src/modules/laboratory/controllers/laboratory.controller.ts new file mode 100644 index 0000000..5bbc10d --- /dev/null +++ b/src/modules/laboratory/controllers/laboratory.controller.ts @@ -0,0 +1,501 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { LabTestService, LabOrderService, LabResultService } from '../services'; +import { + CreateLabTestDto, + UpdateLabTestDto, + LabTestQueryDto, + CreateLabOrderDto, + UpdateLabOrderDto, + CollectSampleDto, + CancelLabOrderDto, + LabOrderQueryDto, + RecordLabResultDto, + VerifyLabResultDto, + AmendLabResultDto, + LabResultQueryDto, +} from '../dto'; + +export class LaboratoryController { + public router: Router; + private testService: LabTestService; + private orderService: LabOrderService; + private resultService: LabResultService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.testService = new LabTestService(dataSource); + this.orderService = new LabOrderService(dataSource); + this.resultService = new LabResultService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + const labPath = `${basePath}/laboratory`; + + // Lab Tests Routes + this.router.get(`${labPath}/tests`, this.findAllTests.bind(this)); + this.router.get(`${labPath}/tests/categories`, this.getTestCategories.bind(this)); + this.router.get(`${labPath}/tests/:id`, this.findTestById.bind(this)); + this.router.post(`${labPath}/tests`, this.createTest.bind(this)); + this.router.patch(`${labPath}/tests/:id`, this.updateTest.bind(this)); + this.router.delete(`${labPath}/tests/:id`, this.deleteTest.bind(this)); + + // Lab Orders Routes + this.router.get(`${labPath}/orders`, this.findAllOrders.bind(this)); + this.router.get(`${labPath}/orders/stats`, this.getOrderStats.bind(this)); + this.router.get(`${labPath}/orders/:id`, this.findOrderById.bind(this)); + this.router.get(`${labPath}/orders/patient/:patientId`, this.findOrdersByPatient.bind(this)); + this.router.get(`${labPath}/orders/consultation/:consultationId`, this.findOrdersByConsultation.bind(this)); + this.router.post(`${labPath}/orders`, this.createOrder.bind(this)); + this.router.patch(`${labPath}/orders/:id`, this.updateOrder.bind(this)); + this.router.post(`${labPath}/orders/:id/collect-sample`, this.collectSample.bind(this)); + this.router.post(`${labPath}/orders/:id/start-processing`, this.startProcessing.bind(this)); + this.router.post(`${labPath}/orders/:id/complete`, this.completeOrder.bind(this)); + this.router.post(`${labPath}/orders/:id/cancel`, this.cancelOrder.bind(this)); + + // Lab Results Routes + this.router.get(`${labPath}/results`, this.findAllResults.bind(this)); + this.router.get(`${labPath}/results/critical`, this.getCriticalResults.bind(this)); + this.router.get(`${labPath}/results/abnormal`, this.getAbnormalResults.bind(this)); + this.router.get(`${labPath}/results/order/:orderId`, this.findResultsByOrder.bind(this)); + this.router.get(`${labPath}/results/:id`, this.findResultById.bind(this)); + this.router.post(`${labPath}/results/:id/record`, this.recordResult.bind(this)); + this.router.post(`${labPath}/results/:id/verify`, this.verifyResult.bind(this)); + this.router.post(`${labPath}/results/:id/amend`, this.amendResult.bind(this)); + } + + private getTenantId(req: Request): string { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + throw new Error('x-tenant-id header is required'); + } + return tenantId; + } + + // Lab Tests Handlers + private async findAllTests(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: LabTestQueryDto = { + search: req.query.search as string, + category: req.query.category as any, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.testService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getTestCategories(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const categories = await this.testService.getCategories(tenantId); + res.json({ data: categories }); + } catch (error) { + next(error); + } + } + + private async findTestById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const test = await this.testService.findById(tenantId, id); + if (!test) { + res.status(404).json({ error: 'Lab test not found', code: 'LAB_TEST_NOT_FOUND' }); + return; + } + + res.json({ data: test }); + } catch (error) { + next(error); + } + } + + private async createTest(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateLabTestDto = req.body; + + const test = await this.testService.create(tenantId, dto); + res.status(201).json({ data: test }); + } catch (error) { + next(error); + } + } + + private async updateTest(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateLabTestDto = req.body; + + const test = await this.testService.update(tenantId, id, dto); + if (!test) { + res.status(404).json({ error: 'Lab test not found', code: 'LAB_TEST_NOT_FOUND' }); + return; + } + + res.json({ data: test }); + } catch (error) { + next(error); + } + } + + private async deleteTest(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.testService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Lab test not found', code: 'LAB_TEST_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Lab Orders Handlers + private async findAllOrders(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: LabOrderQueryDto = { + patientId: req.query.patientId as string, + doctorId: req.query.doctorId as string, + consultationId: req.query.consultationId as string, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + status: req.query.status as any, + priority: req.query.priority as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.orderService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getOrderStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const [pendingCount, statusBreakdown] = await Promise.all([ + this.orderService.getPendingOrdersCount(tenantId), + this.orderService.getOrdersByStatus(tenantId), + ]); + + res.json({ + data: { + pendingCount, + statusBreakdown, + }, + }); + } catch (error) { + next(error); + } + } + + private async findOrderById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await this.orderService.findById(tenantId, id); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async findOrdersByPatient(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + + const orders = await this.orderService.findByPatient(tenantId, patientId, limit); + res.json({ data: orders }); + } catch (error) { + next(error); + } + } + + private async findOrdersByConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { consultationId } = req.params; + + const orders = await this.orderService.findByConsultation(tenantId, consultationId); + res.json({ data: orders }); + } catch (error) { + next(error); + } + } + + private async createOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateLabOrderDto = req.body; + + const order = await this.orderService.create(tenantId, dto); + res.status(201).json({ data: order }); + } catch (error) { + next(error); + } + } + + private async updateOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateLabOrderDto = req.body; + + const order = await this.orderService.update(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async collectSample(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CollectSampleDto = req.body; + + const order = await this.orderService.collectSample(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async startProcessing(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await this.orderService.startProcessing(tenantId, id); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async completeOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await this.orderService.complete(tenantId, id); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async cancelOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CancelLabOrderDto = req.body; + + const order = await this.orderService.cancel(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Lab order not found', code: 'LAB_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + // Lab Results Handlers + private async findAllResults(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: LabResultQueryDto = { + labOrderId: req.query.labOrderId as string, + labTestId: req.query.labTestId as string, + status: req.query.status as any, + abnormalFlag: req.query.abnormalFlag as any, + isCritical: req.query.isCritical === 'true' ? true : req.query.isCritical === 'false' ? false : undefined, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.resultService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getCriticalResults(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const results = await this.resultService.getCriticalResults(tenantId); + res.json({ data: results }); + } catch (error) { + next(error); + } + } + + private async getAbnormalResults(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; + const results = await this.resultService.getAbnormalResults(tenantId, limit); + res.json({ data: results }); + } catch (error) { + next(error); + } + } + + private async findResultsByOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { orderId } = req.params; + + const results = await this.resultService.findByOrder(tenantId, orderId); + res.json({ data: results }); + } catch (error) { + next(error); + } + } + + private async findResultById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const result = await this.resultService.findById(tenantId, id); + if (!result) { + res.status(404).json({ error: 'Lab result not found', code: 'LAB_RESULT_NOT_FOUND' }); + return; + } + + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async recordResult(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: RecordLabResultDto = req.body; + + const result = await this.resultService.recordResult(tenantId, id, dto); + if (!result) { + res.status(404).json({ error: 'Lab result not found', code: 'LAB_RESULT_NOT_FOUND' }); + return; + } + + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async verifyResult(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: VerifyLabResultDto = req.body; + + const result = await this.resultService.verifyResult(tenantId, id, dto); + if (!result) { + res.status(404).json({ error: 'Lab result not found', code: 'LAB_RESULT_NOT_FOUND' }); + return; + } + + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async amendResult(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: AmendLabResultDto = req.body; + + const result = await this.resultService.amendResult(tenantId, id, dto); + if (!result) { + res.status(404).json({ error: 'Lab result not found', code: 'LAB_RESULT_NOT_FOUND' }); + return; + } + + res.json({ data: result }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/laboratory/dto/index.ts b/src/modules/laboratory/dto/index.ts new file mode 100644 index 0000000..47aedb7 --- /dev/null +++ b/src/modules/laboratory/dto/index.ts @@ -0,0 +1,384 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsArray, + ValidateNested, + MaxLength, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + LabTestCategory, + LabTestStatus, + ResultValueType, + LabOrderStatus, + LabOrderPriority, + LabResultStatus, + AbnormalFlag, +} from '../entities'; + +// Reference Range DTO +export class ReferenceRangeDto { + @IsOptional() + @IsNumber() + min?: number; + + @IsOptional() + @IsNumber() + max?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + normalText?: string; + + @IsOptional() + @IsEnum(['male', 'female', 'all']) + gender?: 'male' | 'female' | 'all'; + + @IsOptional() + @IsInt() + @Min(0) + ageMin?: number; + + @IsOptional() + @IsInt() + @Min(0) + ageMax?: number; +} + +// Lab Test DTOs +export class CreateLabTestDto { + @IsString() + @MaxLength(50) + code: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['hematology', 'chemistry', 'urinalysis', 'microbiology', 'immunology', 'hormones', 'tumor_markers', 'other']) + category?: LabTestCategory; + + @IsOptional() + @IsEnum(['numeric', 'text', 'boolean', 'range', 'qualitative']) + resultValueType?: ResultValueType; + + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReferenceRangeDto) + referenceRanges?: ReferenceRangeDto[]; + + @IsOptional() + @IsString() + @MaxLength(100) + sampleType?: string; + + @IsOptional() + @IsString() + preparationInstructions?: string; + + @IsOptional() + @IsInt() + @Min(1) + processingTimeHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class UpdateLabTestDto { + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['hematology', 'chemistry', 'urinalysis', 'microbiology', 'immunology', 'hormones', 'tumor_markers', 'other']) + category?: LabTestCategory; + + @IsOptional() + @IsEnum(['numeric', 'text', 'boolean', 'range', 'qualitative']) + resultValueType?: ResultValueType; + + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReferenceRangeDto) + referenceRanges?: ReferenceRangeDto[]; + + @IsOptional() + @IsString() + @MaxLength(100) + sampleType?: string; + + @IsOptional() + @IsString() + preparationInstructions?: string; + + @IsOptional() + @IsInt() + @Min(1) + processingTimeHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: LabTestStatus; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class LabTestQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['hematology', 'chemistry', 'urinalysis', 'microbiology', 'immunology', 'hormones', 'tumor_markers', 'other']) + category?: LabTestCategory; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: LabTestStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Lab Order DTOs +export class OrderTestItemDto { + @IsUUID() + labTestId: string; +} + +export class CreateLabOrderDto { + @IsUUID() + patientId: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsUUID() + orderingDoctorId: string; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: LabOrderPriority; + + @IsOptional() + @IsString() + clinicalNotes?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderTestItemDto) + tests: OrderTestItemDto[]; +} + +export class UpdateLabOrderDto { + @IsOptional() + @IsEnum(['pending', 'sample_collected', 'processing', 'completed', 'cancelled']) + status?: LabOrderStatus; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: LabOrderPriority; + + @IsOptional() + @IsString() + clinicalNotes?: string; +} + +export class CollectSampleDto { + @IsOptional() + @IsUUID() + collectedBy?: string; +} + +export class CancelLabOrderDto { + @IsString() + @MaxLength(500) + reason: string; +} + +export class LabOrderQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsEnum(['pending', 'sample_collected', 'processing', 'completed', 'cancelled']) + status?: LabOrderStatus; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: LabOrderPriority; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Lab Result DTOs +export class RecordLabResultDto { + @IsOptional() + @IsString() + @MaxLength(500) + resultValue?: string; + + @IsOptional() + @IsNumber() + numericValue?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @IsOptional() + @IsNumber() + referenceRangeMin?: number; + + @IsOptional() + @IsNumber() + referenceRangeMax?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + referenceRangeText?: string; + + @IsOptional() + @IsString() + comments?: string; + + @IsOptional() + @IsUUID() + performedBy?: string; +} + +export class VerifyLabResultDto { + @IsUUID() + verifiedBy: string; + + @IsOptional() + @IsString() + comments?: string; +} + +export class AmendLabResultDto { + @IsString() + @MaxLength(500) + newValue: string; + + @IsOptional() + @IsNumber() + newNumericValue?: number; + + @IsString() + @MaxLength(500) + reason: string; + + @IsUUID() + amendedBy: string; +} + +export class LabResultQueryDto { + @IsOptional() + @IsUUID() + labOrderId?: string; + + @IsOptional() + @IsUUID() + labTestId?: string; + + @IsOptional() + @IsEnum(['pending', 'in_progress', 'completed', 'verified', 'amended']) + status?: LabResultStatus; + + @IsOptional() + @IsEnum(['normal', 'low', 'high', 'critical_low', 'critical_high', 'abnormal']) + abnormalFlag?: AbnormalFlag; + + @IsOptional() + @IsBoolean() + isCritical?: boolean; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/src/modules/laboratory/entities/index.ts b/src/modules/laboratory/entities/index.ts new file mode 100644 index 0000000..ce34d5f --- /dev/null +++ b/src/modules/laboratory/entities/index.ts @@ -0,0 +1,3 @@ +export { LabTest, LabTestCategory, LabTestStatus, ResultValueType, ReferenceRange } from './lab-test.entity'; +export { LabOrder, LabOrderStatus, LabOrderPriority } from './lab-order.entity'; +export { LabResult, LabResultStatus, AbnormalFlag, ResultHistory } from './lab-result.entity'; diff --git a/src/modules/laboratory/entities/lab-order.entity.ts b/src/modules/laboratory/entities/lab-order.entity.ts new file mode 100644 index 0000000..1951964 --- /dev/null +++ b/src/modules/laboratory/entities/lab-order.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { LabResult } from './lab-result.entity'; + +export type LabOrderStatus = 'pending' | 'sample_collected' | 'processing' | 'completed' | 'cancelled'; +export type LabOrderPriority = 'routine' | 'urgent' | 'stat'; + +@Entity({ name: 'lab_orders', schema: 'clinica' }) +export class LabOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 50 }) + orderNumber: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Column({ name: 'consultation_id', type: 'uuid', nullable: true }) + consultationId?: string; + + @Index() + @Column({ name: 'ordering_doctor_id', type: 'uuid' }) + orderingDoctorId: string; + + @Column({ name: 'order_date', type: 'timestamptz', default: () => 'NOW()' }) + orderDate: Date; + + @Column({ type: 'enum', enum: ['pending', 'sample_collected', 'processing', 'completed', 'cancelled'], default: 'pending' }) + status: LabOrderStatus; + + @Column({ type: 'enum', enum: ['routine', 'urgent', 'stat'], default: 'routine' }) + priority: LabOrderPriority; + + @Column({ name: 'clinical_notes', type: 'text', nullable: true }) + clinicalNotes?: string; + + @Column({ name: 'sample_collected_at', type: 'timestamptz', nullable: true }) + sampleCollectedAt?: Date; + + @Column({ name: 'sample_collected_by', type: 'uuid', nullable: true }) + sampleCollectedBy?: string; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason?: string; + + @OneToMany(() => LabResult, (result) => result.labOrder) + results?: LabResult[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/laboratory/entities/lab-result.entity.ts b/src/modules/laboratory/entities/lab-result.entity.ts new file mode 100644 index 0000000..a7dfa18 --- /dev/null +++ b/src/modules/laboratory/entities/lab-result.entity.ts @@ -0,0 +1,100 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LabOrder } from './lab-order.entity'; + +export type LabResultStatus = 'pending' | 'in_progress' | 'completed' | 'verified' | 'amended'; +export type AbnormalFlag = 'normal' | 'low' | 'high' | 'critical_low' | 'critical_high' | 'abnormal'; + +export interface ResultHistory { + value: string; + amendedAt: Date; + amendedBy: string; + reason: string; +} + +@Entity({ name: 'lab_results', schema: 'clinica' }) +export class LabResult { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'lab_order_id', type: 'uuid' }) + labOrderId: string; + + @ManyToOne(() => LabOrder, (order) => order.results) + @JoinColumn({ name: 'lab_order_id' }) + labOrder: LabOrder; + + @Index() + @Column({ name: 'lab_test_id', type: 'uuid' }) + labTestId: string; + + @Column({ name: 'test_code', type: 'varchar', length: 50 }) + testCode: string; + + @Column({ name: 'test_name', type: 'varchar', length: 200 }) + testName: string; + + @Column({ type: 'enum', enum: ['pending', 'in_progress', 'completed', 'verified', 'amended'], default: 'pending' }) + status: LabResultStatus; + + @Column({ name: 'result_value', type: 'varchar', length: 500, nullable: true }) + resultValue?: string; + + @Column({ name: 'numeric_value', type: 'decimal', precision: 15, scale: 5, nullable: true }) + numericValue?: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + unit?: string; + + @Column({ name: 'reference_range_min', type: 'decimal', precision: 15, scale: 5, nullable: true }) + referenceRangeMin?: number; + + @Column({ name: 'reference_range_max', type: 'decimal', precision: 15, scale: 5, nullable: true }) + referenceRangeMax?: number; + + @Column({ name: 'reference_range_text', type: 'varchar', length: 200, nullable: true }) + referenceRangeText?: string; + + @Column({ name: 'abnormal_flag', type: 'enum', enum: ['normal', 'low', 'high', 'critical_low', 'critical_high', 'abnormal'], default: 'normal' }) + abnormalFlag: AbnormalFlag; + + @Column({ name: 'is_critical', type: 'boolean', default: false }) + isCritical: boolean; + + @Column({ type: 'text', nullable: true }) + comments?: string; + + @Column({ name: 'performed_by', type: 'uuid', nullable: true }) + performedBy?: string; + + @Column({ name: 'performed_at', type: 'timestamptz', nullable: true }) + performedAt?: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy?: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt?: Date; + + @Column({ name: 'result_history', type: 'jsonb', nullable: true }) + resultHistory?: ResultHistory[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/laboratory/entities/lab-test.entity.ts b/src/modules/laboratory/entities/lab-test.entity.ts new file mode 100644 index 0000000..d3e11ab --- /dev/null +++ b/src/modules/laboratory/entities/lab-test.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type LabTestCategory = 'hematology' | 'chemistry' | 'urinalysis' | 'microbiology' | 'immunology' | 'hormones' | 'tumor_markers' | 'other'; +export type LabTestStatus = 'active' | 'inactive' | 'discontinued'; +export type ResultValueType = 'numeric' | 'text' | 'boolean' | 'range' | 'qualitative'; + +export interface ReferenceRange { + min?: number; + max?: number; + unit?: string; + normalText?: string; + gender?: 'male' | 'female' | 'all'; + ageMin?: number; + ageMax?: number; +} + +@Entity({ name: 'lab_tests', schema: 'clinica' }) +export class LabTest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50, unique: false }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'enum', enum: ['hematology', 'chemistry', 'urinalysis', 'microbiology', 'immunology', 'hormones', 'tumor_markers', 'other'], default: 'other' }) + category: LabTestCategory; + + @Column({ name: 'result_value_type', type: 'enum', enum: ['numeric', 'text', 'boolean', 'range', 'qualitative'], default: 'numeric' }) + resultValueType: ResultValueType; + + @Column({ type: 'varchar', length: 50, nullable: true }) + unit?: string; + + @Column({ name: 'reference_ranges', type: 'jsonb', nullable: true }) + referenceRanges?: ReferenceRange[]; + + @Column({ name: 'sample_type', type: 'varchar', length: 100, nullable: true }) + sampleType?: string; + + @Column({ name: 'preparation_instructions', type: 'text', nullable: true }) + preparationInstructions?: string; + + @Column({ name: 'processing_time_hours', type: 'int', nullable: true }) + processingTimeHours?: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + price?: number; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' }) + status: LabTestStatus; + + @Column({ name: 'display_order', type: 'int', default: 0 }) + displayOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/laboratory/index.ts b/src/modules/laboratory/index.ts new file mode 100644 index 0000000..e281407 --- /dev/null +++ b/src/modules/laboratory/index.ts @@ -0,0 +1,33 @@ +export { LaboratoryModule, LaboratoryModuleOptions } from './laboratory.module'; +export { + LabTest, + LabTestCategory, + LabTestStatus, + ResultValueType, + ReferenceRange, + LabOrder, + LabOrderStatus, + LabOrderPriority, + LabResult, + LabResultStatus, + AbnormalFlag, + ResultHistory, +} from './entities'; +export { LabTestService, LabOrderService, LabResultService } from './services'; +export { LaboratoryController } from './controllers'; +export { + CreateLabTestDto, + UpdateLabTestDto, + LabTestQueryDto, + CreateLabOrderDto, + UpdateLabOrderDto, + CollectSampleDto, + CancelLabOrderDto, + LabOrderQueryDto, + RecordLabResultDto, + VerifyLabResultDto, + AmendLabResultDto, + LabResultQueryDto, + ReferenceRangeDto, + OrderTestItemDto, +} from './dto'; diff --git a/src/modules/laboratory/laboratory.module.ts b/src/modules/laboratory/laboratory.module.ts new file mode 100644 index 0000000..7546624 --- /dev/null +++ b/src/modules/laboratory/laboratory.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { LaboratoryController } from './controllers'; + +export interface LaboratoryModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class LaboratoryModule { + public router: Router; + private controller: LaboratoryController; + + constructor(options: LaboratoryModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new LaboratoryController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/laboratory/services/index.ts b/src/modules/laboratory/services/index.ts new file mode 100644 index 0000000..c838f3a --- /dev/null +++ b/src/modules/laboratory/services/index.ts @@ -0,0 +1,3 @@ +export { LabTestService } from './lab-test.service'; +export { LabOrderService } from './lab-order.service'; +export { LabResultService } from './lab-result.service'; diff --git a/src/modules/laboratory/services/lab-order.service.ts b/src/modules/laboratory/services/lab-order.service.ts new file mode 100644 index 0000000..84a8552 --- /dev/null +++ b/src/modules/laboratory/services/lab-order.service.ts @@ -0,0 +1,262 @@ +import { DataSource, Repository } from 'typeorm'; +import { LabOrder, LabResult, LabTest } from '../entities'; +import { + CreateLabOrderDto, + UpdateLabOrderDto, + CollectSampleDto, + CancelLabOrderDto, + LabOrderQueryDto, +} from '../dto'; + +export class LabOrderService { + private repository: Repository; + private resultRepository: Repository; + private testRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(LabOrder); + this.resultRepository = dataSource.getRepository(LabResult); + this.testRepository = dataSource.getRepository(LabTest); + } + + async findAll(tenantId: string, query: LabOrderQueryDto): Promise<{ data: LabOrder[]; total: number }> { + const { patientId, doctorId, consultationId, dateFrom, dateTo, status, priority, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('order') + .leftJoinAndSelect('order.results', 'results') + .where('order.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('order.patient_id = :patientId', { patientId }); + } + + if (doctorId) { + queryBuilder.andWhere('order.ordering_doctor_id = :doctorId', { doctorId }); + } + + if (consultationId) { + queryBuilder.andWhere('order.consultation_id = :consultationId', { consultationId }); + } + + if (dateFrom) { + queryBuilder.andWhere('order.order_date >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('order.order_date <= :dateTo', { dateTo }); + } + + if (status) { + queryBuilder.andWhere('order.status = :status', { status }); + } + + if (priority) { + queryBuilder.andWhere('order.priority = :priority', { priority }); + } + + queryBuilder + .orderBy('order.order_date', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['results'], + }); + } + + async findByOrderNumber(tenantId: string, orderNumber: string): Promise { + return this.repository.findOne({ + where: { orderNumber, tenantId }, + relations: ['results'], + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['results'], + order: { orderDate: 'DESC' }, + take: limit, + }); + } + + async findByConsultation(tenantId: string, consultationId: string): Promise { + return this.repository.find({ + where: { tenantId, consultationId }, + relations: ['results'], + order: { orderDate: 'DESC' }, + }); + } + + async create(tenantId: string, dto: CreateLabOrderDto): Promise { + const orderNumber = await this.generateOrderNumber(tenantId); + + const tests = await this.testRepository.createQueryBuilder('test') + .where('test.tenant_id = :tenantId', { tenantId }) + .andWhere('test.id IN (:...ids)', { ids: dto.tests.map(t => t.labTestId) }) + .andWhere('test.status = :status', { status: 'active' }) + .getMany(); + + if (tests.length === 0) { + throw new Error('No valid active tests found'); + } + + const order = this.repository.create({ + tenantId, + orderNumber, + patientId: dto.patientId, + consultationId: dto.consultationId, + orderingDoctorId: dto.orderingDoctorId, + priority: dto.priority || 'routine', + clinicalNotes: dto.clinicalNotes, + status: 'pending', + }); + + const savedOrder = await this.repository.save(order); + + const results = tests.map(test => { + const referenceRange = test.referenceRanges && test.referenceRanges.length > 0 + ? test.referenceRanges.find(r => r.gender === 'all') || test.referenceRanges[0] + : null; + + return this.resultRepository.create({ + tenantId, + labOrderId: savedOrder.id, + labTestId: test.id, + testCode: test.code, + testName: test.name, + unit: test.unit, + referenceRangeMin: referenceRange?.min, + referenceRangeMax: referenceRange?.max, + referenceRangeText: referenceRange?.normalText, + status: 'pending', + abnormalFlag: 'normal', + isCritical: false, + }); + }); + + await this.resultRepository.save(results); + + return this.findById(tenantId, savedOrder.id) as Promise; + } + + async update(tenantId: string, id: string, dto: UpdateLabOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status === 'completed' || order.status === 'cancelled') { + throw new Error('Cannot update completed or cancelled order'); + } + + Object.assign(order, dto); + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async collectSample(tenantId: string, id: string, dto: CollectSampleDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'pending') { + throw new Error('Sample can only be collected for pending orders'); + } + + order.status = 'sample_collected'; + order.sampleCollectedAt = new Date(); + order.sampleCollectedBy = dto.collectedBy; + + await this.repository.save(order); + + await this.resultRepository.createQueryBuilder() + .update(LabResult) + .set({ status: 'in_progress' }) + .where('lab_order_id = :orderId', { orderId: id }) + .andWhere('tenant_id = :tenantId', { tenantId }) + .execute(); + + return this.findById(tenantId, id); + } + + async startProcessing(tenantId: string, id: string): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'sample_collected') { + throw new Error('Can only start processing after sample collection'); + } + + order.status = 'processing'; + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async complete(tenantId: string, id: string): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + const pendingResults = order.results?.filter(r => r.status !== 'completed' && r.status !== 'verified'); + if (pendingResults && pendingResults.length > 0) { + throw new Error('Cannot complete order with pending results'); + } + + order.status = 'completed'; + order.completedAt = new Date(); + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async cancel(tenantId: string, id: string, dto: CancelLabOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status === 'completed') { + throw new Error('Cannot cancel completed order'); + } + + order.status = 'cancelled'; + order.cancelledAt = new Date(); + order.cancellationReason = dto.reason; + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async getPendingOrdersCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, status: 'pending' }, + }); + } + + async getOrdersByStatus(tenantId: string): Promise<{ status: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('order') + .select('order.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('order.tenant_id = :tenantId', { tenantId }) + .groupBy('order.status') + .getRawMany(); + + return result.map(r => ({ status: r.status, count: parseInt(r.count, 10) })); + } + + private async generateOrderNumber(tenantId: string): Promise { + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('DATE(order.created_at) = CURRENT_DATE') + .getCount(); + + const sequence = (count + 1).toString().padStart(4, '0'); + return `LAB-${dateStr}-${sequence}`; + } +} diff --git a/src/modules/laboratory/services/lab-result.service.ts b/src/modules/laboratory/services/lab-result.service.ts new file mode 100644 index 0000000..ce89f63 --- /dev/null +++ b/src/modules/laboratory/services/lab-result.service.ts @@ -0,0 +1,229 @@ +import { DataSource, Repository } from 'typeorm'; +import { LabResult, LabOrder, AbnormalFlag } from '../entities'; +import { + RecordLabResultDto, + VerifyLabResultDto, + AmendLabResultDto, + LabResultQueryDto, +} from '../dto'; + +export class LabResultService { + private repository: Repository; + private orderRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(LabResult); + this.orderRepository = dataSource.getRepository(LabOrder); + } + + async findAll(tenantId: string, query: LabResultQueryDto): Promise<{ data: LabResult[]; total: number }> { + const { labOrderId, labTestId, status, abnormalFlag, isCritical, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('result') + .where('result.tenant_id = :tenantId', { tenantId }); + + if (labOrderId) { + queryBuilder.andWhere('result.lab_order_id = :labOrderId', { labOrderId }); + } + + if (labTestId) { + queryBuilder.andWhere('result.lab_test_id = :labTestId', { labTestId }); + } + + if (status) { + queryBuilder.andWhere('result.status = :status', { status }); + } + + if (abnormalFlag) { + queryBuilder.andWhere('result.abnormal_flag = :abnormalFlag', { abnormalFlag }); + } + + if (isCritical !== undefined) { + queryBuilder.andWhere('result.is_critical = :isCritical', { isCritical }); + } + + queryBuilder + .orderBy('result.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByOrder(tenantId: string, labOrderId: string): Promise { + return this.repository.find({ + where: { tenantId, labOrderId }, + order: { testName: 'ASC' }, + }); + } + + async recordResult(tenantId: string, id: string, dto: RecordLabResultDto): Promise { + const result = await this.findById(tenantId, id); + if (!result) return null; + + if (result.status === 'verified') { + throw new Error('Cannot modify verified result. Use amend instead.'); + } + + result.resultValue = dto.resultValue; + result.numericValue = dto.numericValue; + result.comments = dto.comments; + result.performedBy = dto.performedBy; + result.performedAt = new Date(); + result.status = 'completed'; + + if (dto.unit) result.unit = dto.unit; + if (dto.referenceRangeMin !== undefined) result.referenceRangeMin = dto.referenceRangeMin; + if (dto.referenceRangeMax !== undefined) result.referenceRangeMax = dto.referenceRangeMax; + if (dto.referenceRangeText) result.referenceRangeText = dto.referenceRangeText; + + const { abnormalFlag, isCritical } = this.evaluateResult(result); + result.abnormalFlag = abnormalFlag; + result.isCritical = isCritical; + + return this.repository.save(result); + } + + async verifyResult(tenantId: string, id: string, dto: VerifyLabResultDto): Promise { + const result = await this.findById(tenantId, id); + if (!result) return null; + + if (result.status !== 'completed') { + throw new Error('Can only verify completed results'); + } + + result.status = 'verified'; + result.verifiedBy = dto.verifiedBy; + result.verifiedAt = new Date(); + + if (dto.comments) { + result.comments = result.comments + ? `${result.comments}\n\nVerification note: ${dto.comments}` + : `Verification note: ${dto.comments}`; + } + + const saved = await this.repository.save(result); + + await this.checkAndCompleteOrder(tenantId, result.labOrderId); + + return saved; + } + + async amendResult(tenantId: string, id: string, dto: AmendLabResultDto): Promise { + const result = await this.findById(tenantId, id); + if (!result) return null; + + if (result.status !== 'verified' && result.status !== 'completed') { + throw new Error('Can only amend completed or verified results'); + } + + const historyEntry = { + value: result.resultValue || '', + amendedAt: new Date(), + amendedBy: dto.amendedBy, + reason: dto.reason, + }; + + result.resultHistory = result.resultHistory || []; + result.resultHistory.push(historyEntry); + + result.resultValue = dto.newValue; + if (dto.newNumericValue !== undefined) { + result.numericValue = dto.newNumericValue; + } + + result.status = 'amended'; + result.comments = result.comments + ? `${result.comments}\n\nAmendment: ${dto.reason}` + : `Amendment: ${dto.reason}`; + + const { abnormalFlag, isCritical } = this.evaluateResult(result); + result.abnormalFlag = abnormalFlag; + result.isCritical = isCritical; + + return this.repository.save(result); + } + + async getCriticalResults(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, isCritical: true }, + order: { createdAt: 'DESC' }, + take: 50, + }); + } + + async getAbnormalResults(tenantId: string, limit: number = 50): Promise { + return this.repository.createQueryBuilder('result') + .where('result.tenant_id = :tenantId', { tenantId }) + .andWhere('result.abnormal_flag != :normal', { normal: 'normal' }) + .orderBy('result.is_critical', 'DESC') + .addOrderBy('result.created_at', 'DESC') + .take(limit) + .getMany(); + } + + async getPendingResultsCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, status: 'pending' }, + }); + } + + private evaluateResult(result: LabResult): { abnormalFlag: AbnormalFlag; isCritical: boolean } { + if (result.numericValue === null || result.numericValue === undefined) { + return { abnormalFlag: 'normal', isCritical: false }; + } + + const value = result.numericValue; + const min = result.referenceRangeMin; + const max = result.referenceRangeMax; + + if (min === null && max === null) { + return { abnormalFlag: 'normal', isCritical: false }; + } + + let abnormalFlag: AbnormalFlag = 'normal'; + let isCritical = false; + + if (min !== null && min !== undefined && value < min) { + const percentBelow = ((min - value) / min) * 100; + if (percentBelow > 30) { + abnormalFlag = 'critical_low'; + isCritical = true; + } else { + abnormalFlag = 'low'; + } + } else if (max !== null && max !== undefined && value > max) { + const percentAbove = ((value - max) / max) * 100; + if (percentAbove > 30) { + abnormalFlag = 'critical_high'; + isCritical = true; + } else { + abnormalFlag = 'high'; + } + } + + return { abnormalFlag, isCritical }; + } + + private async checkAndCompleteOrder(tenantId: string, labOrderId: string): Promise { + const results = await this.repository.find({ + where: { tenantId, labOrderId }, + }); + + const allVerified = results.every(r => r.status === 'verified' || r.status === 'amended'); + + if (allVerified) { + await this.orderRepository.update( + { id: labOrderId, tenantId }, + { status: 'completed', completedAt: new Date() } + ); + } + } +} diff --git a/src/modules/laboratory/services/lab-test.service.ts b/src/modules/laboratory/services/lab-test.service.ts new file mode 100644 index 0000000..a0dd570 --- /dev/null +++ b/src/modules/laboratory/services/lab-test.service.ts @@ -0,0 +1,111 @@ +import { DataSource, Repository } from 'typeorm'; +import { LabTest } from '../entities'; +import { CreateLabTestDto, UpdateLabTestDto, LabTestQueryDto } from '../dto'; + +export class LabTestService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(LabTest); + } + + async findAll(tenantId: string, query: LabTestQueryDto): Promise<{ data: LabTest[]; total: number }> { + const { search, category, status, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('test') + .where('test.tenant_id = :tenantId', { tenantId }) + .andWhere('test.deleted_at IS NULL'); + + if (category) { + queryBuilder.andWhere('test.category = :category', { category }); + } + + if (status) { + queryBuilder.andWhere('test.status = :status', { status }); + } + + if (search) { + queryBuilder.andWhere( + '(test.code ILIKE :search OR test.name ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder + .orderBy('test.category', 'ASC') + .addOrderBy('test.display_order', 'ASC') + .addOrderBy('test.name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findByIds(tenantId: string, ids: string[]): Promise { + if (ids.length === 0) return []; + + return this.repository.createQueryBuilder('test') + .where('test.tenant_id = :tenantId', { tenantId }) + .andWhere('test.id IN (:...ids)', { ids }) + .andWhere('test.deleted_at IS NULL') + .getMany(); + } + + async findActiveByCategory(tenantId: string, category: string): Promise { + return this.repository.find({ + where: { tenantId, category: category as any, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateLabTestDto): Promise { + const test = this.repository.create({ + ...dto, + tenantId, + }); + return this.repository.save(test); + } + + async update(tenantId: string, id: string, dto: UpdateLabTestDto): Promise { + const test = await this.findById(tenantId, id); + if (!test) return null; + + Object.assign(test, dto); + return this.repository.save(test); + } + + async softDelete(tenantId: string, id: string): Promise { + const test = await this.findById(tenantId, id); + if (!test) return false; + + await this.repository.softDelete(id); + return true; + } + + async getCategories(tenantId: string): Promise<{ category: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('test') + .select('test.category', 'category') + .addSelect('COUNT(*)', 'count') + .where('test.tenant_id = :tenantId', { tenantId }) + .andWhere('test.deleted_at IS NULL') + .andWhere('test.status = :status', { status: 'active' }) + .groupBy('test.category') + .orderBy('test.category', 'ASC') + .getRawMany(); + + return result.map(r => ({ category: r.category, count: parseInt(r.count, 10) })); + } +}