[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 <noreply@anthropic.com>
This commit is contained in:
parent
d0420bc135
commit
27ea1fd4a6
1
src/modules/laboratory/controllers/index.ts
Normal file
1
src/modules/laboratory/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { LaboratoryController } from './laboratory.controller';
|
||||
501
src/modules/laboratory/controllers/laboratory.controller.ts
Normal file
501
src/modules/laboratory/controllers/laboratory.controller.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/modules/laboratory/dto/index.ts
Normal file
384
src/modules/laboratory/dto/index.ts
Normal file
@ -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;
|
||||
}
|
||||
3
src/modules/laboratory/entities/index.ts
Normal file
3
src/modules/laboratory/entities/index.ts
Normal file
@ -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';
|
||||
74
src/modules/laboratory/entities/lab-order.entity.ts
Normal file
74
src/modules/laboratory/entities/lab-order.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
100
src/modules/laboratory/entities/lab-result.entity.ts
Normal file
100
src/modules/laboratory/entities/lab-result.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
82
src/modules/laboratory/entities/lab-test.entity.ts
Normal file
82
src/modules/laboratory/entities/lab-test.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
33
src/modules/laboratory/index.ts
Normal file
33
src/modules/laboratory/index.ts
Normal file
@ -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';
|
||||
19
src/modules/laboratory/laboratory.module.ts
Normal file
19
src/modules/laboratory/laboratory.module.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
3
src/modules/laboratory/services/index.ts
Normal file
3
src/modules/laboratory/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { LabTestService } from './lab-test.service';
|
||||
export { LabOrderService } from './lab-order.service';
|
||||
export { LabResultService } from './lab-result.service';
|
||||
262
src/modules/laboratory/services/lab-order.service.ts
Normal file
262
src/modules/laboratory/services/lab-order.service.ts
Normal file
@ -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<LabOrder>;
|
||||
private resultRepository: Repository<LabResult>;
|
||||
private testRepository: Repository<LabTest>;
|
||||
|
||||
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<LabOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['results'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<LabOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { orderNumber, tenantId },
|
||||
relations: ['results'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise<LabOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, patientId },
|
||||
relations: ['results'],
|
||||
order: { orderDate: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findByConsultation(tenantId: string, consultationId: string): Promise<LabOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, consultationId },
|
||||
relations: ['results'],
|
||||
order: { orderDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateLabOrderDto): Promise<LabOrder> {
|
||||
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<LabOrder>;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateLabOrderDto): Promise<LabOrder | null> {
|
||||
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<LabOrder | null> {
|
||||
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<LabOrder | null> {
|
||||
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<LabOrder | null> {
|
||||
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<LabOrder | null> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
229
src/modules/laboratory/services/lab-result.service.ts
Normal file
229
src/modules/laboratory/services/lab-result.service.ts
Normal file
@ -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<LabResult>;
|
||||
private orderRepository: Repository<LabOrder>;
|
||||
|
||||
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<LabResult | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByOrder(tenantId: string, labOrderId: string): Promise<LabResult[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, labOrderId },
|
||||
order: { testName: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async recordResult(tenantId: string, id: string, dto: RecordLabResultDto): Promise<LabResult | null> {
|
||||
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<LabResult | null> {
|
||||
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<LabResult | null> {
|
||||
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<LabResult[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, isCritical: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
async getAbnormalResults(tenantId: string, limit: number = 50): Promise<LabResult[]> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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() }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/modules/laboratory/services/lab-test.service.ts
Normal file
111
src/modules/laboratory/services/lab-test.service.ts
Normal file
@ -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<LabTest>;
|
||||
|
||||
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<LabTest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(tenantId: string, code: string): Promise<LabTest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByIds(tenantId: string, ids: string[]): Promise<LabTest[]> {
|
||||
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<LabTest[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, category: category as any, status: 'active' },
|
||||
order: { displayOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateLabTestDto): Promise<LabTest> {
|
||||
const test = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
});
|
||||
return this.repository.save(test);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateLabTestDto): Promise<LabTest | null> {
|
||||
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<boolean> {
|
||||
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) }));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user