[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:
Adrian Flores Cortes 2026-01-30 18:15:10 -06:00
parent d0420bc135
commit 27ea1fd4a6
13 changed files with 1802 additions and 0 deletions

View File

@ -0,0 +1 @@
export { LaboratoryController } from './laboratory.controller';

View 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);
}
}
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}
}

View File

@ -0,0 +1,3 @@
export { LabTestService } from './lab-test.service';
export { LabOrderService } from './lab-order.service';
export { LabResultService } from './lab-result.service';

View 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}`;
}
}

View 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() }
);
}
}
}

View 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) }));
}
}