[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