[CL-009] feat: Implement imaging module for radiology studies
Add complete imaging module with: - Entities: ImagingStudy (catalog), ImagingOrder (workflow), DicomInstance (DICOM tracking) - Services: Study management, Order workflow (ordered->scheduled->performed->reported), DICOM file management - Controller: REST endpoints for studies, orders, and DICOM instances - Features: Multi-tenant support, study types (xray, ct, mri, ultrasound, mammography, pet, nuclear), order scheduling, radiologist assignment, critical findings flagging, DICOM viewer URLs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
50a409bf17
commit
56ded676ae
685
src/modules/imaging/controllers/imaging.controller.ts
Normal file
685
src/modules/imaging/controllers/imaging.controller.ts
Normal file
@ -0,0 +1,685 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ImagingStudyService, ImagingOrderService, DicomService } from '../services';
|
||||
import {
|
||||
CreateImagingStudyDto,
|
||||
UpdateImagingStudyDto,
|
||||
ImagingStudyQueryDto,
|
||||
CreateImagingOrderDto,
|
||||
UpdateImagingOrderDto,
|
||||
ScheduleImagingOrderDto,
|
||||
PerformImagingOrderDto,
|
||||
AssignRadiologistDto,
|
||||
ReportImagingOrderDto,
|
||||
SignReportDto,
|
||||
NotifyCriticalFindingDto,
|
||||
CancelImagingOrderDto,
|
||||
ImagingOrderQueryDto,
|
||||
CreateDicomInstanceDto,
|
||||
UpdateDicomInstanceDto,
|
||||
DicomInstanceQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export class ImagingController {
|
||||
public router: Router;
|
||||
private studyService: ImagingStudyService;
|
||||
private orderService: ImagingOrderService;
|
||||
private dicomService: DicomService;
|
||||
|
||||
constructor(dataSource: DataSource, basePath: string = '/api') {
|
||||
this.router = Router();
|
||||
this.studyService = new ImagingStudyService(dataSource);
|
||||
this.orderService = new ImagingOrderService(dataSource);
|
||||
this.dicomService = new DicomService(dataSource);
|
||||
this.setupRoutes(basePath);
|
||||
}
|
||||
|
||||
private setupRoutes(basePath: string): void {
|
||||
const imagingPath = `${basePath}/imaging`;
|
||||
|
||||
// Imaging Studies Routes
|
||||
this.router.get(`${imagingPath}/studies`, this.findAllStudies.bind(this));
|
||||
this.router.get(`${imagingPath}/studies/types`, this.getStudyTypes.bind(this));
|
||||
this.router.get(`${imagingPath}/studies/body-regions`, this.getBodyRegions.bind(this));
|
||||
this.router.get(`${imagingPath}/studies/:id`, this.findStudyById.bind(this));
|
||||
this.router.post(`${imagingPath}/studies`, this.createStudy.bind(this));
|
||||
this.router.patch(`${imagingPath}/studies/:id`, this.updateStudy.bind(this));
|
||||
this.router.delete(`${imagingPath}/studies/:id`, this.deleteStudy.bind(this));
|
||||
|
||||
// Imaging Orders Routes
|
||||
this.router.get(`${imagingPath}/orders`, this.findAllOrders.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/stats`, this.getOrderStats.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/critical-findings`, this.getCriticalFindings.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/scheduled/:date`, this.getScheduledOrders.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/radiologist/:radiologistId/worklist`, this.getRadiologistWorklist.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/:id`, this.findOrderById.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/patient/:patientId`, this.findOrdersByPatient.bind(this));
|
||||
this.router.get(`${imagingPath}/orders/consultation/:consultationId`, this.findOrdersByConsultation.bind(this));
|
||||
this.router.post(`${imagingPath}/orders`, this.createOrder.bind(this));
|
||||
this.router.patch(`${imagingPath}/orders/:id`, this.updateOrder.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/schedule`, this.scheduleOrder.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/start`, this.startPerformance.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/perform`, this.performOrder.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/assign-radiologist`, this.assignRadiologist.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/report`, this.reportOrder.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/sign`, this.signReport.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/notify-critical`, this.notifyCriticalFinding.bind(this));
|
||||
this.router.post(`${imagingPath}/orders/:id/cancel`, this.cancelOrder.bind(this));
|
||||
|
||||
// DICOM Instance Routes
|
||||
this.router.get(`${imagingPath}/dicom`, this.findAllDicomInstances.bind(this));
|
||||
this.router.get(`${imagingPath}/dicom/order/:orderId`, this.findDicomByOrder.bind(this));
|
||||
this.router.get(`${imagingPath}/dicom/order/:orderId/viewer-url`, this.getViewerUrl.bind(this));
|
||||
this.router.get(`${imagingPath}/dicom/order/:orderId/series-summary`, this.getSeriesSummary.bind(this));
|
||||
this.router.get(`${imagingPath}/dicom/:id`, this.findDicomById.bind(this));
|
||||
this.router.post(`${imagingPath}/dicom`, this.createDicomInstance.bind(this));
|
||||
this.router.post(`${imagingPath}/dicom/bulk`, this.createDicomInstancesBulk.bind(this));
|
||||
this.router.patch(`${imagingPath}/dicom/:id`, this.updateDicomInstance.bind(this));
|
||||
this.router.post(`${imagingPath}/dicom/:id/mark-available`, this.markDicomAvailable.bind(this));
|
||||
this.router.post(`${imagingPath}/dicom/:id/mark-error`, this.markDicomError.bind(this));
|
||||
this.router.post(`${imagingPath}/dicom/:id/archive`, this.archiveDicom.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;
|
||||
}
|
||||
|
||||
// Imaging Studies Handlers
|
||||
private async findAllStudies(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: ImagingStudyQueryDto = {
|
||||
search: req.query.search as string,
|
||||
studyType: req.query.studyType as any,
|
||||
bodyRegion: req.query.bodyRegion 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.studyService.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 getStudyTypes(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const types = await this.studyService.getStudyTypes(tenantId);
|
||||
res.json({ data: types });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBodyRegions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const regions = await this.studyService.getBodyRegions(tenantId);
|
||||
res.json({ data: regions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findStudyById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const study = await this.studyService.findById(tenantId, id);
|
||||
if (!study) {
|
||||
res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: study });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createStudy(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreateImagingStudyDto = req.body;
|
||||
|
||||
const study = await this.studyService.create(tenantId, dto);
|
||||
res.status(201).json({ data: study });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateStudy(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateImagingStudyDto = req.body;
|
||||
|
||||
const study = await this.studyService.update(tenantId, id, dto);
|
||||
if (!study) {
|
||||
res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: study });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteStudy(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.studyService.softDelete(tenantId, id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Imaging Orders Handlers
|
||||
private async findAllOrders(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: ImagingOrderQueryDto = {
|
||||
patientId: req.query.patientId as string,
|
||||
doctorId: req.query.doctorId as string,
|
||||
radiologistId: req.query.radiologistId 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,
|
||||
isCriticalFinding: req.query.isCriticalFinding === 'true' ? true : req.query.isCriticalFinding === 'false' ? false : undefined,
|
||||
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 getCriticalFindings(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const unnotifiedOnly = req.query.unnotifiedOnly === 'true';
|
||||
const findings = await this.orderService.getCriticalFindings(tenantId, unnotifiedOnly);
|
||||
res.json({ data: findings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScheduledOrders(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { date } = req.params;
|
||||
const orders = await this.orderService.getScheduledOrders(tenantId, date);
|
||||
res.json({ data: orders });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRadiologistWorklist(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { radiologistId } = req.params;
|
||||
const worklist = await this.orderService.getWorklistForRadiologist(tenantId, radiologistId);
|
||||
res.json({ data: worklist });
|
||||
} 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: 'Imaging order not found', code: 'IMAGING_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: CreateImagingOrderDto = 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: UpdateImagingOrderDto = req.body;
|
||||
|
||||
const order = await this.orderService.update(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async scheduleOrder(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: ScheduleImagingOrderDto = req.body;
|
||||
|
||||
const order = await this.orderService.schedule(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async startPerformance(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const order = await this.orderService.startPerformance(tenantId, id);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async performOrder(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: PerformImagingOrderDto = req.body;
|
||||
|
||||
const order = await this.orderService.perform(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async assignRadiologist(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: AssignRadiologistDto = req.body;
|
||||
|
||||
const order = await this.orderService.assignRadiologist(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async reportOrder(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: ReportImagingOrderDto = req.body;
|
||||
|
||||
const order = await this.orderService.report(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async signReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: SignReportDto = req.body;
|
||||
|
||||
const order = await this.orderService.signReport(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyCriticalFinding(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: NotifyCriticalFindingDto = req.body;
|
||||
|
||||
const order = await this.orderService.notifyCriticalFinding(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_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: CancelImagingOrderDto = req.body;
|
||||
|
||||
const order = await this.orderService.cancel(tenantId, id, dto);
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// DICOM Instance Handlers
|
||||
private async findAllDicomInstances(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: DicomInstanceQueryDto = {
|
||||
imagingOrderId: req.query.imagingOrderId as string,
|
||||
studyInstanceUID: req.query.studyInstanceUID as string,
|
||||
seriesInstanceUID: req.query.seriesInstanceUID as string,
|
||||
status: req.query.status as any,
|
||||
modality: req.query.modality as string,
|
||||
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.dicomService.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 findDicomByOrder(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { orderId } = req.params;
|
||||
|
||||
const instances = await this.dicomService.findByOrder(tenantId, orderId);
|
||||
res.json({ data: instances });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getViewerUrl(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { orderId } = req.params;
|
||||
|
||||
const urlResult = await this.dicomService.getViewerUrl(tenantId, orderId);
|
||||
if (!urlResult) {
|
||||
res.status(404).json({ error: 'No viewer URL available', code: 'VIEWER_URL_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: urlResult });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSeriesSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { orderId } = req.params;
|
||||
|
||||
const summary = await this.dicomService.getSeriesSummary(tenantId, orderId);
|
||||
res.json({ data: summary });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findDicomById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const instance = await this.dicomService.findById(tenantId, id);
|
||||
if (!instance) {
|
||||
res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createDicomInstance(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreateDicomInstanceDto = req.body;
|
||||
|
||||
const instance = await this.dicomService.create(tenantId, dto);
|
||||
res.status(201).json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createDicomInstancesBulk(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dtos: CreateDicomInstanceDto[] = req.body;
|
||||
|
||||
const instances = await this.dicomService.createBulk(tenantId, dtos);
|
||||
res.status(201).json({ data: instances });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDicomInstance(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateDicomInstanceDto = req.body;
|
||||
|
||||
const instance = await this.dicomService.update(tenantId, id, dto);
|
||||
if (!instance) {
|
||||
res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async markDicomAvailable(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const urls = req.body;
|
||||
|
||||
const instance = await this.dicomService.markAsAvailable(tenantId, id, urls);
|
||||
if (!instance) {
|
||||
res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async markDicomError(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const { errorMessage } = req.body;
|
||||
|
||||
const instance = await this.dicomService.markAsError(tenantId, id, errorMessage);
|
||||
if (!instance) {
|
||||
res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveDicom(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const { archivePath } = req.body;
|
||||
|
||||
const instance = await this.dicomService.archive(tenantId, id, archivePath);
|
||||
if (!instance) {
|
||||
res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: instance });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/modules/imaging/controllers/index.ts
Normal file
1
src/modules/imaging/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ImagingController } from './imaging.controller';
|
||||
511
src/modules/imaging/dto/index.ts
Normal file
511
src/modules/imaging/dto/index.ts
Normal file
@ -0,0 +1,511 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ImagingStudyType,
|
||||
ImagingStudyStatus,
|
||||
BodyRegion,
|
||||
ImagingOrderStatus,
|
||||
ImagingOrderPriority,
|
||||
DicomInstanceStatus,
|
||||
} from '../entities';
|
||||
|
||||
// Study Protocol DTO
|
||||
export class StudyProtocolDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
contrastRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
contrastType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
estimatedDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
patientPreparation?: string;
|
||||
}
|
||||
|
||||
// Imaging Study DTOs
|
||||
export class CreateImagingStudyDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear'])
|
||||
studyType?: ImagingStudyType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other'])
|
||||
bodyRegion?: BodyRegion;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StudyProtocolDto)
|
||||
protocols?: StudyProtocolDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
contrastRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
preparationInstructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
estimatedDurationMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
cptCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresRadiologist?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateImagingStudyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear'])
|
||||
studyType?: ImagingStudyType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other'])
|
||||
bodyRegion?: BodyRegion;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StudyProtocolDto)
|
||||
protocols?: StudyProtocolDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
contrastRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
preparationInstructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
estimatedDurationMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
cptCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'discontinued'])
|
||||
status?: ImagingStudyStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresRadiologist?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export class ImagingStudyQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear'])
|
||||
studyType?: ImagingStudyType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other'])
|
||||
bodyRegion?: BodyRegion;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'discontinued'])
|
||||
status?: ImagingStudyStatus;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Imaging Order DTOs
|
||||
export class CreateImagingOrderDto {
|
||||
@IsUUID()
|
||||
patientId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
consultationId?: string;
|
||||
|
||||
@IsUUID()
|
||||
orderingDoctorId: string;
|
||||
|
||||
@IsUUID()
|
||||
imagingStudyId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: ImagingOrderPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalIndication?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalHistory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateImagingOrderDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: ImagingOrderPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalIndication?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalHistory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ScheduleImagingOrderDto {
|
||||
@IsDateString()
|
||||
scheduledDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
scheduledTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
room?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
equipment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
technologistId?: string;
|
||||
}
|
||||
|
||||
export class PerformImagingOrderDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
performedBy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
accessionNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
contrastUsed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
contrastType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
contrastAmount?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
radiationDose?: string;
|
||||
}
|
||||
|
||||
export class AssignRadiologistDto {
|
||||
@IsUUID()
|
||||
radiologistId: string;
|
||||
}
|
||||
|
||||
export class ReportImagingOrderDto {
|
||||
@IsString()
|
||||
findings: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
impression?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recommendations?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isCriticalFinding?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
reportedBy?: string;
|
||||
}
|
||||
|
||||
export class SignReportDto {
|
||||
@IsUUID()
|
||||
signedBy: string;
|
||||
}
|
||||
|
||||
export class NotifyCriticalFindingDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
notifiedTo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export class CancelImagingOrderDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class ImagingOrderQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
patientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
doctorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
radiologistId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
consultationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['ordered', 'scheduled', 'in_progress', 'performed', 'reported', 'cancelled'])
|
||||
status?: ImagingOrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: ImagingOrderPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isCriticalFinding?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// DICOM Instance DTOs
|
||||
export class CreateDicomInstanceDto {
|
||||
@IsUUID()
|
||||
imagingOrderId: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
studyInstanceUID: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
seriesInstanceUID: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
sopInstanceUID: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
sopClassUID: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
seriesNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
instanceNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
seriesDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
modality?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
transferSyntaxUID?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
filePath?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
fileSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateDicomInstanceDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['received', 'processing', 'available', 'archived', 'error'])
|
||||
status?: DicomInstanceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
storagePath?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
viewerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
thumbnailUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
wadoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class DicomInstanceQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
imagingOrderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
studyInstanceUID?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
seriesInstanceUID?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['received', 'processing', 'available', 'archived', 'error'])
|
||||
status?: DicomInstanceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
modality?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
121
src/modules/imaging/entities/dicom-instance.entity.ts
Normal file
121
src/modules/imaging/entities/dicom-instance.entity.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ImagingOrder } from './imaging-order.entity';
|
||||
|
||||
export type DicomInstanceStatus = 'received' | 'processing' | 'available' | 'archived' | 'error';
|
||||
|
||||
export interface DicomMetadata {
|
||||
manufacturer?: string;
|
||||
stationName?: string;
|
||||
institutionName?: string;
|
||||
acquisitionDate?: string;
|
||||
acquisitionTime?: string;
|
||||
bodyPartExamined?: string;
|
||||
patientPosition?: string;
|
||||
sliceThickness?: number;
|
||||
pixelSpacing?: number[];
|
||||
windowCenter?: number;
|
||||
windowWidth?: number;
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
bitsAllocated?: number;
|
||||
photometricInterpretation?: string;
|
||||
}
|
||||
|
||||
@Entity({ name: 'dicom_instances', schema: 'clinica' })
|
||||
export class DicomInstance {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'imaging_order_id', type: 'uuid' })
|
||||
imagingOrderId: string;
|
||||
|
||||
@ManyToOne(() => ImagingOrder, (order) => order.dicomInstances)
|
||||
@JoinColumn({ name: 'imaging_order_id' })
|
||||
imagingOrder: ImagingOrder;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'study_instance_uid', type: 'varchar', length: 128 })
|
||||
studyInstanceUID: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'series_instance_uid', type: 'varchar', length: 128 })
|
||||
seriesInstanceUID: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'sop_instance_uid', type: 'varchar', length: 128 })
|
||||
sopInstanceUID: string;
|
||||
|
||||
@Column({ name: 'sop_class_uid', type: 'varchar', length: 128 })
|
||||
sopClassUID: string;
|
||||
|
||||
@Column({ name: 'series_number', type: 'int', nullable: true })
|
||||
seriesNumber?: number;
|
||||
|
||||
@Column({ name: 'instance_number', type: 'int', nullable: true })
|
||||
instanceNumber?: number;
|
||||
|
||||
@Column({ name: 'series_description', type: 'varchar', length: 200, nullable: true })
|
||||
seriesDescription?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ['received', 'processing', 'available', 'archived', 'error'], default: 'received' })
|
||||
status: DicomInstanceStatus;
|
||||
|
||||
@Column({ name: 'modality', type: 'varchar', length: 16, nullable: true })
|
||||
modality?: string;
|
||||
|
||||
@Column({ name: 'transfer_syntax_uid', type: 'varchar', length: 128, nullable: true })
|
||||
transferSyntaxUID?: string;
|
||||
|
||||
@Column({ name: 'file_path', type: 'varchar', length: 500, nullable: true })
|
||||
filePath?: string;
|
||||
|
||||
@Column({ name: 'file_size', type: 'bigint', nullable: true })
|
||||
fileSize?: number;
|
||||
|
||||
@Column({ name: 'storage_path', type: 'varchar', length: 500, nullable: true })
|
||||
storagePath?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: DicomMetadata;
|
||||
|
||||
@Column({ name: 'viewer_url', type: 'varchar', length: 500, nullable: true })
|
||||
viewerUrl?: string;
|
||||
|
||||
@Column({ name: 'thumbnail_url', type: 'varchar', length: 500, nullable: true })
|
||||
thumbnailUrl?: string;
|
||||
|
||||
@Column({ name: 'wado_url', type: 'varchar', length: 500, nullable: true })
|
||||
wadoUrl?: string;
|
||||
|
||||
@Column({ name: 'received_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
receivedAt: Date;
|
||||
|
||||
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
|
||||
processedAt?: Date;
|
||||
|
||||
@Column({ name: 'archived_at', type: 'timestamptz', nullable: true })
|
||||
archivedAt?: Date;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
147
src/modules/imaging/entities/imaging-order.entity.ts
Normal file
147
src/modules/imaging/entities/imaging-order.entity.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { DicomInstance } from './dicom-instance.entity';
|
||||
|
||||
export type ImagingOrderStatus = 'ordered' | 'scheduled' | 'in_progress' | 'performed' | 'reported' | 'cancelled';
|
||||
export type ImagingOrderPriority = 'routine' | 'urgent' | 'stat';
|
||||
|
||||
export interface ScheduleInfo {
|
||||
scheduledDate: Date;
|
||||
scheduledTime?: string;
|
||||
room?: string;
|
||||
equipment?: string;
|
||||
technologist?: string;
|
||||
}
|
||||
|
||||
export interface ReportInfo {
|
||||
findings?: string;
|
||||
impression?: string;
|
||||
recommendations?: string;
|
||||
reportedAt?: Date;
|
||||
reportedBy?: string;
|
||||
signedAt?: Date;
|
||||
signedBy?: string;
|
||||
}
|
||||
|
||||
@Entity({ name: 'imaging_orders', schema: 'clinica' })
|
||||
export class ImagingOrder {
|
||||
@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;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'imaging_study_id', type: 'uuid' })
|
||||
imagingStudyId: string;
|
||||
|
||||
@Column({ name: 'study_code', type: 'varchar', length: 50 })
|
||||
studyCode: string;
|
||||
|
||||
@Column({ name: 'study_name', type: 'varchar', length: 200 })
|
||||
studyName: string;
|
||||
|
||||
@Column({ name: 'order_date', type: 'timestamptz', default: () => 'NOW()' })
|
||||
orderDate: Date;
|
||||
|
||||
@Column({ type: 'enum', enum: ['ordered', 'scheduled', 'in_progress', 'performed', 'reported', 'cancelled'], default: 'ordered' })
|
||||
status: ImagingOrderStatus;
|
||||
|
||||
@Column({ type: 'enum', enum: ['routine', 'urgent', 'stat'], default: 'routine' })
|
||||
priority: ImagingOrderPriority;
|
||||
|
||||
@Column({ name: 'clinical_indication', type: 'text', nullable: true })
|
||||
clinicalIndication?: string;
|
||||
|
||||
@Column({ name: 'clinical_history', type: 'text', nullable: true })
|
||||
clinicalHistory?: string;
|
||||
|
||||
@Column({ name: 'schedule_info', type: 'jsonb', nullable: true })
|
||||
scheduleInfo?: ScheduleInfo;
|
||||
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date;
|
||||
|
||||
@Column({ name: 'scheduled_room', type: 'varchar', length: 50, nullable: true })
|
||||
scheduledRoom?: string;
|
||||
|
||||
@Column({ name: 'performed_at', type: 'timestamptz', nullable: true })
|
||||
performedAt?: Date;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
|
||||
performedBy?: string;
|
||||
|
||||
@Column({ name: 'radiologist_id', type: 'uuid', nullable: true })
|
||||
radiologistId?: string;
|
||||
|
||||
@Column({ name: 'report_info', type: 'jsonb', nullable: true })
|
||||
reportInfo?: ReportInfo;
|
||||
|
||||
@Column({ name: 'is_critical_finding', type: 'boolean', default: false })
|
||||
isCriticalFinding: boolean;
|
||||
|
||||
@Column({ name: 'critical_finding_notified', type: 'boolean', default: false })
|
||||
criticalFindingNotified: boolean;
|
||||
|
||||
@Column({ name: 'critical_finding_notified_at', type: 'timestamptz', nullable: true })
|
||||
criticalFindingNotifiedAt?: Date;
|
||||
|
||||
@Column({ name: 'critical_finding_notified_to', type: 'varchar', length: 200, nullable: true })
|
||||
criticalFindingNotifiedTo?: string;
|
||||
|
||||
@Column({ name: 'accession_number', type: 'varchar', length: 50, nullable: true })
|
||||
accessionNumber?: string;
|
||||
|
||||
@Column({ name: 'contrast_used', type: 'boolean', default: false })
|
||||
contrastUsed: boolean;
|
||||
|
||||
@Column({ name: 'contrast_type', type: 'varchar', length: 100, nullable: true })
|
||||
contrastType?: string;
|
||||
|
||||
@Column({ name: 'contrast_amount', type: 'varchar', length: 50, nullable: true })
|
||||
contrastAmount?: string;
|
||||
|
||||
@Column({ name: 'radiation_dose', type: 'varchar', length: 50, nullable: true })
|
||||
radiationDose?: string;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date;
|
||||
|
||||
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
|
||||
cancellationReason?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@OneToMany(() => DicomInstance, (instance) => instance.imagingOrder)
|
||||
dicomInstances?: DicomInstance[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
84
src/modules/imaging/entities/imaging-study.entity.ts
Normal file
84
src/modules/imaging/entities/imaging-study.entity.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export type ImagingStudyType = 'xray' | 'ct' | 'mri' | 'ultrasound' | 'mammography' | 'pet' | 'nuclear';
|
||||
export type ImagingStudyStatus = 'active' | 'inactive' | 'discontinued';
|
||||
export type BodyRegion = 'head' | 'neck' | 'chest' | 'abdomen' | 'pelvis' | 'spine' | 'upper_extremity' | 'lower_extremity' | 'whole_body' | 'other';
|
||||
|
||||
export interface StudyProtocol {
|
||||
name: string;
|
||||
description?: string;
|
||||
contrastRequired?: boolean;
|
||||
contrastType?: string;
|
||||
estimatedDuration?: number;
|
||||
patientPreparation?: string;
|
||||
}
|
||||
|
||||
@Entity({ name: 'imaging_studies', schema: 'clinica' })
|
||||
export class ImagingStudy {
|
||||
@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({ name: 'study_type', type: 'enum', enum: ['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear'], default: 'xray' })
|
||||
studyType: ImagingStudyType;
|
||||
|
||||
@Column({ name: 'body_region', type: 'enum', enum: ['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other'], default: 'other' })
|
||||
bodyRegion: BodyRegion;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
protocols?: StudyProtocol[];
|
||||
|
||||
@Column({ name: 'contrast_required', type: 'boolean', default: false })
|
||||
contrastRequired: boolean;
|
||||
|
||||
@Column({ name: 'preparation_instructions', type: 'text', nullable: true })
|
||||
preparationInstructions?: string;
|
||||
|
||||
@Column({ name: 'estimated_duration_minutes', type: 'int', nullable: true })
|
||||
estimatedDurationMinutes?: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
price?: number;
|
||||
|
||||
@Column({ name: 'cpt_code', type: 'varchar', length: 20, nullable: true })
|
||||
cptCode?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' })
|
||||
status: ImagingStudyStatus;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 0 })
|
||||
displayOrder: number;
|
||||
|
||||
@Column({ name: 'requires_radiologist', type: 'boolean', default: true })
|
||||
requiresRadiologist: boolean;
|
||||
|
||||
@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;
|
||||
}
|
||||
3
src/modules/imaging/entities/index.ts
Normal file
3
src/modules/imaging/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ImagingStudy, ImagingStudyType, ImagingStudyStatus, BodyRegion, StudyProtocol } from './imaging-study.entity';
|
||||
export { ImagingOrder, ImagingOrderStatus, ImagingOrderPriority, ScheduleInfo, ReportInfo } from './imaging-order.entity';
|
||||
export { DicomInstance, DicomInstanceStatus, DicomMetadata } from './dicom-instance.entity';
|
||||
19
src/modules/imaging/imaging.module.ts
Normal file
19
src/modules/imaging/imaging.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ImagingController } from './controllers';
|
||||
|
||||
export interface ImagingModuleOptions {
|
||||
dataSource: DataSource;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export class ImagingModule {
|
||||
public router: Router;
|
||||
private controller: ImagingController;
|
||||
|
||||
constructor(options: ImagingModuleOptions) {
|
||||
const { dataSource, basePath = '/api' } = options;
|
||||
this.controller = new ImagingController(dataSource, basePath);
|
||||
this.router = this.controller.router;
|
||||
}
|
||||
}
|
||||
37
src/modules/imaging/index.ts
Normal file
37
src/modules/imaging/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export { ImagingModule, ImagingModuleOptions } from './imaging.module';
|
||||
export {
|
||||
ImagingStudy,
|
||||
ImagingStudyType,
|
||||
ImagingStudyStatus,
|
||||
BodyRegion,
|
||||
StudyProtocol,
|
||||
ImagingOrder,
|
||||
ImagingOrderStatus,
|
||||
ImagingOrderPriority,
|
||||
ScheduleInfo,
|
||||
ReportInfo,
|
||||
DicomInstance,
|
||||
DicomInstanceStatus,
|
||||
DicomMetadata,
|
||||
} from './entities';
|
||||
export { ImagingStudyService, ImagingOrderService, DicomService, ViewerUrlResult } from './services';
|
||||
export { ImagingController } from './controllers';
|
||||
export {
|
||||
StudyProtocolDto,
|
||||
CreateImagingStudyDto,
|
||||
UpdateImagingStudyDto,
|
||||
ImagingStudyQueryDto,
|
||||
CreateImagingOrderDto,
|
||||
UpdateImagingOrderDto,
|
||||
ScheduleImagingOrderDto,
|
||||
PerformImagingOrderDto,
|
||||
AssignRadiologistDto,
|
||||
ReportImagingOrderDto,
|
||||
SignReportDto,
|
||||
NotifyCriticalFindingDto,
|
||||
CancelImagingOrderDto,
|
||||
ImagingOrderQueryDto,
|
||||
CreateDicomInstanceDto,
|
||||
UpdateDicomInstanceDto,
|
||||
DicomInstanceQueryDto,
|
||||
} from './dto';
|
||||
271
src/modules/imaging/services/dicom.service.ts
Normal file
271
src/modules/imaging/services/dicom.service.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { DicomInstance, ImagingOrder } from '../entities';
|
||||
import {
|
||||
CreateDicomInstanceDto,
|
||||
UpdateDicomInstanceDto,
|
||||
DicomInstanceQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export interface ViewerUrlResult {
|
||||
viewerUrl: string;
|
||||
wadoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export class DicomService {
|
||||
private repository: Repository<DicomInstance>;
|
||||
private orderRepository: Repository<ImagingOrder>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(DicomInstance);
|
||||
this.orderRepository = dataSource.getRepository(ImagingOrder);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: DicomInstanceQueryDto): Promise<{ data: DicomInstance[]; total: number }> {
|
||||
const { imagingOrderId, studyInstanceUID, seriesInstanceUID, status, modality, page = 1, limit = 50 } = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('instance')
|
||||
.where('instance.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (imagingOrderId) {
|
||||
queryBuilder.andWhere('instance.imaging_order_id = :imagingOrderId', { imagingOrderId });
|
||||
}
|
||||
|
||||
if (studyInstanceUID) {
|
||||
queryBuilder.andWhere('instance.study_instance_uid = :studyInstanceUID', { studyInstanceUID });
|
||||
}
|
||||
|
||||
if (seriesInstanceUID) {
|
||||
queryBuilder.andWhere('instance.series_instance_uid = :seriesInstanceUID', { seriesInstanceUID });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('instance.status = :status', { status });
|
||||
}
|
||||
|
||||
if (modality) {
|
||||
queryBuilder.andWhere('instance.modality = :modality', { modality });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('instance.series_number', 'ASC')
|
||||
.addOrderBy('instance.instance_number', 'ASC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<DicomInstance | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByOrder(tenantId: string, imagingOrderId: string): Promise<DicomInstance[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, imagingOrderId },
|
||||
order: { seriesNumber: 'ASC', instanceNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByStudyInstanceUID(tenantId: string, studyInstanceUID: string): Promise<DicomInstance[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, studyInstanceUID },
|
||||
order: { seriesNumber: 'ASC', instanceNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findBySeriesInstanceUID(tenantId: string, seriesInstanceUID: string): Promise<DicomInstance[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, seriesInstanceUID },
|
||||
order: { instanceNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findBySopInstanceUID(tenantId: string, sopInstanceUID: string): Promise<DicomInstance | null> {
|
||||
return this.repository.findOne({
|
||||
where: { tenantId, sopInstanceUID },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateDicomInstanceDto): Promise<DicomInstance> {
|
||||
const order = await this.orderRepository.findOne({
|
||||
where: { id: dto.imagingOrderId, tenantId },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error('Imaging order not found');
|
||||
}
|
||||
|
||||
const instance = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
status: 'received',
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async createBulk(tenantId: string, dtos: CreateDicomInstanceDto[]): Promise<DicomInstance[]> {
|
||||
if (dtos.length === 0) return [];
|
||||
|
||||
const orderIds = [...new Set(dtos.map(d => d.imagingOrderId))];
|
||||
const orders = await this.orderRepository.createQueryBuilder('order')
|
||||
.where('order.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('order.id IN (:...orderIds)', { orderIds })
|
||||
.getMany();
|
||||
|
||||
if (orders.length !== orderIds.length) {
|
||||
throw new Error('One or more imaging orders not found');
|
||||
}
|
||||
|
||||
const instances = dtos.map(dto => this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
status: 'received',
|
||||
receivedAt: new Date(),
|
||||
}));
|
||||
|
||||
return this.repository.save(instances);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateDicomInstanceDto): Promise<DicomInstance | null> {
|
||||
const instance = await this.findById(tenantId, id);
|
||||
if (!instance) return null;
|
||||
|
||||
Object.assign(instance, dto);
|
||||
|
||||
if (dto.status === 'available' && !instance.processedAt) {
|
||||
instance.processedAt = new Date();
|
||||
}
|
||||
|
||||
if (dto.status === 'archived' && !instance.archivedAt) {
|
||||
instance.archivedAt = new Date();
|
||||
}
|
||||
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async markAsProcessing(tenantId: string, id: string): Promise<DicomInstance | null> {
|
||||
const instance = await this.findById(tenantId, id);
|
||||
if (!instance) return null;
|
||||
|
||||
instance.status = 'processing';
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async markAsAvailable(tenantId: string, id: string, urls?: { viewerUrl?: string; thumbnailUrl?: string; wadoUrl?: string }): Promise<DicomInstance | null> {
|
||||
const instance = await this.findById(tenantId, id);
|
||||
if (!instance) return null;
|
||||
|
||||
instance.status = 'available';
|
||||
instance.processedAt = new Date();
|
||||
|
||||
if (urls) {
|
||||
if (urls.viewerUrl) instance.viewerUrl = urls.viewerUrl;
|
||||
if (urls.thumbnailUrl) instance.thumbnailUrl = urls.thumbnailUrl;
|
||||
if (urls.wadoUrl) instance.wadoUrl = urls.wadoUrl;
|
||||
}
|
||||
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async markAsError(tenantId: string, id: string, errorMessage: string): Promise<DicomInstance | null> {
|
||||
const instance = await this.findById(tenantId, id);
|
||||
if (!instance) return null;
|
||||
|
||||
instance.status = 'error';
|
||||
instance.errorMessage = errorMessage;
|
||||
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async archive(tenantId: string, id: string, archivePath?: string): Promise<DicomInstance | null> {
|
||||
const instance = await this.findById(tenantId, id);
|
||||
if (!instance) return null;
|
||||
|
||||
instance.status = 'archived';
|
||||
instance.archivedAt = new Date();
|
||||
if (archivePath) {
|
||||
instance.storagePath = archivePath;
|
||||
}
|
||||
|
||||
return this.repository.save(instance);
|
||||
}
|
||||
|
||||
async getViewerUrl(tenantId: string, imagingOrderId: string): Promise<ViewerUrlResult | null> {
|
||||
const instances = await this.findByOrder(tenantId, imagingOrderId);
|
||||
if (instances.length === 0) return null;
|
||||
|
||||
const availableInstance = instances.find(i => i.status === 'available' && i.viewerUrl);
|
||||
if (!availableInstance) return null;
|
||||
|
||||
return {
|
||||
viewerUrl: availableInstance.viewerUrl!,
|
||||
wadoUrl: availableInstance.wadoUrl,
|
||||
thumbnailUrl: availableInstance.thumbnailUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async getSeriesSummary(tenantId: string, imagingOrderId: string): Promise<{ seriesInstanceUID: string; seriesNumber?: number; seriesDescription?: string; instanceCount: number; modality?: string }[]> {
|
||||
const result = await this.repository.createQueryBuilder('instance')
|
||||
.select('instance.series_instance_uid', 'seriesInstanceUID')
|
||||
.addSelect('instance.series_number', 'seriesNumber')
|
||||
.addSelect('instance.series_description', 'seriesDescription')
|
||||
.addSelect('instance.modality', 'modality')
|
||||
.addSelect('COUNT(*)', 'instanceCount')
|
||||
.where('instance.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('instance.imaging_order_id = :imagingOrderId', { imagingOrderId })
|
||||
.groupBy('instance.series_instance_uid')
|
||||
.addGroupBy('instance.series_number')
|
||||
.addGroupBy('instance.series_description')
|
||||
.addGroupBy('instance.modality')
|
||||
.orderBy('instance.series_number', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => ({
|
||||
seriesInstanceUID: r.seriesInstanceUID,
|
||||
seriesNumber: r.seriesNumber,
|
||||
seriesDescription: r.seriesDescription,
|
||||
modality: r.modality,
|
||||
instanceCount: parseInt(r.instanceCount, 10),
|
||||
}));
|
||||
}
|
||||
|
||||
async getInstanceCount(tenantId: string, imagingOrderId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { tenantId, imagingOrderId },
|
||||
});
|
||||
}
|
||||
|
||||
async getAvailableInstanceCount(tenantId: string, imagingOrderId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { tenantId, imagingOrderId, status: 'available' },
|
||||
});
|
||||
}
|
||||
|
||||
async getModalityCounts(tenantId: string): Promise<{ modality: string; count: number }[]> {
|
||||
const result = await this.repository.createQueryBuilder('instance')
|
||||
.select('instance.modality', 'modality')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('instance.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('instance.modality IS NOT NULL')
|
||||
.groupBy('instance.modality')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => ({ modality: r.modality, count: parseInt(r.count, 10) }));
|
||||
}
|
||||
|
||||
async deleteByOrder(tenantId: string, imagingOrderId: string): Promise<number> {
|
||||
const result = await this.repository.delete({
|
||||
tenantId,
|
||||
imagingOrderId,
|
||||
});
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
390
src/modules/imaging/services/imaging-order.service.ts
Normal file
390
src/modules/imaging/services/imaging-order.service.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ImagingOrder, ImagingStudy, DicomInstance } from '../entities';
|
||||
import {
|
||||
CreateImagingOrderDto,
|
||||
UpdateImagingOrderDto,
|
||||
ScheduleImagingOrderDto,
|
||||
PerformImagingOrderDto,
|
||||
AssignRadiologistDto,
|
||||
ReportImagingOrderDto,
|
||||
SignReportDto,
|
||||
NotifyCriticalFindingDto,
|
||||
CancelImagingOrderDto,
|
||||
ImagingOrderQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export class ImagingOrderService {
|
||||
private repository: Repository<ImagingOrder>;
|
||||
private studyRepository: Repository<ImagingStudy>;
|
||||
private dicomRepository: Repository<DicomInstance>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(ImagingOrder);
|
||||
this.studyRepository = dataSource.getRepository(ImagingStudy);
|
||||
this.dicomRepository = dataSource.getRepository(DicomInstance);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: ImagingOrderQueryDto): Promise<{ data: ImagingOrder[]; total: number }> {
|
||||
const {
|
||||
patientId,
|
||||
doctorId,
|
||||
radiologistId,
|
||||
consultationId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
status,
|
||||
priority,
|
||||
isCriticalFinding,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('order')
|
||||
.leftJoinAndSelect('order.dicomInstances', 'dicom')
|
||||
.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 (radiologistId) {
|
||||
queryBuilder.andWhere('order.radiologist_id = :radiologistId', { radiologistId });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (isCriticalFinding !== undefined) {
|
||||
queryBuilder.andWhere('order.is_critical_finding = :isCriticalFinding', { isCriticalFinding });
|
||||
}
|
||||
|
||||
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<ImagingOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['dicomInstances'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<ImagingOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { orderNumber, tenantId },
|
||||
relations: ['dicomInstances'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise<ImagingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, patientId },
|
||||
relations: ['dicomInstances'],
|
||||
order: { orderDate: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findByConsultation(tenantId: string, consultationId: string): Promise<ImagingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, consultationId },
|
||||
relations: ['dicomInstances'],
|
||||
order: { orderDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByRadiologist(tenantId: string, radiologistId: string, status?: string): Promise<ImagingOrder[]> {
|
||||
const query: any = { tenantId, radiologistId };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
return this.repository.find({
|
||||
where: query,
|
||||
relations: ['dicomInstances'],
|
||||
order: { orderDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateImagingOrderDto): Promise<ImagingOrder> {
|
||||
const study = await this.studyRepository.findOne({
|
||||
where: { id: dto.imagingStudyId, tenantId, status: 'active' },
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
throw new Error('Imaging study not found or not active');
|
||||
}
|
||||
|
||||
const orderNumber = await this.generateOrderNumber(tenantId);
|
||||
|
||||
const order = this.repository.create({
|
||||
tenantId,
|
||||
orderNumber,
|
||||
patientId: dto.patientId,
|
||||
consultationId: dto.consultationId,
|
||||
orderingDoctorId: dto.orderingDoctorId,
|
||||
imagingStudyId: dto.imagingStudyId,
|
||||
studyCode: study.code,
|
||||
studyName: study.name,
|
||||
priority: dto.priority || 'routine',
|
||||
clinicalIndication: dto.clinicalIndication,
|
||||
clinicalHistory: dto.clinicalHistory,
|
||||
notes: dto.notes,
|
||||
status: 'ordered',
|
||||
});
|
||||
|
||||
const savedOrder = await this.repository.save(order);
|
||||
return this.findById(tenantId, savedOrder.id) as Promise<ImagingOrder>;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateImagingOrderDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status === 'reported' || order.status === 'cancelled') {
|
||||
throw new Error('Cannot update reported or cancelled order');
|
||||
}
|
||||
|
||||
Object.assign(order, dto);
|
||||
await this.repository.save(order);
|
||||
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async schedule(tenantId: string, id: string, dto: ScheduleImagingOrderDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status !== 'ordered') {
|
||||
throw new Error('Can only schedule orders with status "ordered"');
|
||||
}
|
||||
|
||||
order.status = 'scheduled';
|
||||
order.scheduledAt = new Date(dto.scheduledDate);
|
||||
order.scheduledRoom = dto.room;
|
||||
order.scheduleInfo = {
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
scheduledTime: dto.scheduledTime,
|
||||
room: dto.room,
|
||||
equipment: dto.equipment,
|
||||
technologist: dto.technologistId,
|
||||
};
|
||||
|
||||
await this.repository.save(order);
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async startPerformance(tenantId: string, id: string): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status !== 'scheduled') {
|
||||
throw new Error('Can only start performance for scheduled orders');
|
||||
}
|
||||
|
||||
order.status = 'in_progress';
|
||||
await this.repository.save(order);
|
||||
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async perform(tenantId: string, id: string, dto: PerformImagingOrderDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status !== 'scheduled' && order.status !== 'in_progress') {
|
||||
throw new Error('Can only perform scheduled or in-progress orders');
|
||||
}
|
||||
|
||||
order.status = 'performed';
|
||||
order.performedAt = new Date();
|
||||
order.performedBy = dto.performedBy;
|
||||
order.accessionNumber = dto.accessionNumber;
|
||||
order.contrastUsed = dto.contrastUsed || false;
|
||||
order.contrastType = dto.contrastType;
|
||||
order.contrastAmount = dto.contrastAmount;
|
||||
order.radiationDose = dto.radiationDose;
|
||||
|
||||
await this.repository.save(order);
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async assignRadiologist(tenantId: string, id: string, dto: AssignRadiologistDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status === 'cancelled') {
|
||||
throw new Error('Cannot assign radiologist to cancelled order');
|
||||
}
|
||||
|
||||
order.radiologistId = dto.radiologistId;
|
||||
await this.repository.save(order);
|
||||
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async report(tenantId: string, id: string, dto: ReportImagingOrderDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status !== 'performed') {
|
||||
throw new Error('Can only report performed orders');
|
||||
}
|
||||
|
||||
order.status = 'reported';
|
||||
order.isCriticalFinding = dto.isCriticalFinding || false;
|
||||
order.reportInfo = {
|
||||
findings: dto.findings,
|
||||
impression: dto.impression,
|
||||
recommendations: dto.recommendations,
|
||||
reportedAt: new Date(),
|
||||
reportedBy: dto.reportedBy,
|
||||
};
|
||||
|
||||
await this.repository.save(order);
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async signReport(tenantId: string, id: string, dto: SignReportDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status !== 'reported') {
|
||||
throw new Error('Can only sign reported orders');
|
||||
}
|
||||
|
||||
if (!order.reportInfo) {
|
||||
throw new Error('Order has no report to sign');
|
||||
}
|
||||
|
||||
order.reportInfo = {
|
||||
...order.reportInfo,
|
||||
signedAt: new Date(),
|
||||
signedBy: dto.signedBy,
|
||||
};
|
||||
|
||||
await this.repository.save(order);
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async notifyCriticalFinding(tenantId: string, id: string, dto: NotifyCriticalFindingDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (!order.isCriticalFinding) {
|
||||
throw new Error('Order does not have a critical finding');
|
||||
}
|
||||
|
||||
order.criticalFindingNotified = true;
|
||||
order.criticalFindingNotifiedAt = new Date();
|
||||
order.criticalFindingNotifiedTo = dto.notifiedTo;
|
||||
|
||||
await this.repository.save(order);
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
async cancel(tenantId: string, id: string, dto: CancelImagingOrderDto): Promise<ImagingOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
if (order.status === 'reported') {
|
||||
throw new Error('Cannot cancel reported 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: 'ordered' },
|
||||
});
|
||||
}
|
||||
|
||||
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) }));
|
||||
}
|
||||
|
||||
async getCriticalFindings(tenantId: string, unnotifiedOnly: boolean = false): Promise<ImagingOrder[]> {
|
||||
const query: any = { tenantId, isCriticalFinding: true };
|
||||
if (unnotifiedOnly) {
|
||||
query.criticalFindingNotified = false;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where: query,
|
||||
relations: ['dicomInstances'],
|
||||
order: { orderDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getScheduledOrders(tenantId: string, date: string): Promise<ImagingOrder[]> {
|
||||
return this.repository.createQueryBuilder('order')
|
||||
.leftJoinAndSelect('order.dicomInstances', 'dicom')
|
||||
.where('order.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('order.status = :status', { status: 'scheduled' })
|
||||
.andWhere('DATE(order.scheduled_at) = :date', { date })
|
||||
.orderBy('order.scheduled_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getWorklistForRadiologist(tenantId: string, radiologistId: string): Promise<ImagingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ tenantId, radiologistId, status: 'performed' },
|
||||
{ tenantId, radiologistId, status: 'in_progress' },
|
||||
],
|
||||
relations: ['dicomInstances'],
|
||||
order: { priority: 'DESC', orderDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
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 `IMG-${dateStr}-${sequence}`;
|
||||
}
|
||||
}
|
||||
137
src/modules/imaging/services/imaging-study.service.ts
Normal file
137
src/modules/imaging/services/imaging-study.service.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ImagingStudy } from '../entities';
|
||||
import { CreateImagingStudyDto, UpdateImagingStudyDto, ImagingStudyQueryDto } from '../dto';
|
||||
|
||||
export class ImagingStudyService {
|
||||
private repository: Repository<ImagingStudy>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(ImagingStudy);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: ImagingStudyQueryDto): Promise<{ data: ImagingStudy[]; total: number }> {
|
||||
const { search, studyType, bodyRegion, status, page = 1, limit = 50 } = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('study')
|
||||
.where('study.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('study.deleted_at IS NULL');
|
||||
|
||||
if (studyType) {
|
||||
queryBuilder.andWhere('study.study_type = :studyType', { studyType });
|
||||
}
|
||||
|
||||
if (bodyRegion) {
|
||||
queryBuilder.andWhere('study.body_region = :bodyRegion', { bodyRegion });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('study.status = :status', { status });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(study.code ILIKE :search OR study.name ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('study.study_type', 'ASC')
|
||||
.addOrderBy('study.body_region', 'ASC')
|
||||
.addOrderBy('study.display_order', 'ASC')
|
||||
.addOrderBy('study.name', 'ASC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<ImagingStudy | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(tenantId: string, code: string): Promise<ImagingStudy | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByIds(tenantId: string, ids: string[]): Promise<ImagingStudy[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
return this.repository.createQueryBuilder('study')
|
||||
.where('study.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('study.id IN (:...ids)', { ids })
|
||||
.andWhere('study.deleted_at IS NULL')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findActiveByType(tenantId: string, studyType: string): Promise<ImagingStudy[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, studyType: studyType as any, status: 'active' },
|
||||
order: { displayOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveByBodyRegion(tenantId: string, bodyRegion: string): Promise<ImagingStudy[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, bodyRegion: bodyRegion as any, status: 'active' },
|
||||
order: { displayOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateImagingStudyDto): Promise<ImagingStudy> {
|
||||
const study = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
});
|
||||
return this.repository.save(study);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateImagingStudyDto): Promise<ImagingStudy | null> {
|
||||
const study = await this.findById(tenantId, id);
|
||||
if (!study) return null;
|
||||
|
||||
Object.assign(study, dto);
|
||||
return this.repository.save(study);
|
||||
}
|
||||
|
||||
async softDelete(tenantId: string, id: string): Promise<boolean> {
|
||||
const study = await this.findById(tenantId, id);
|
||||
if (!study) return false;
|
||||
|
||||
await this.repository.softDelete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getStudyTypes(tenantId: string): Promise<{ studyType: string; count: number }[]> {
|
||||
const result = await this.repository.createQueryBuilder('study')
|
||||
.select('study.study_type', 'studyType')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('study.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('study.deleted_at IS NULL')
|
||||
.andWhere('study.status = :status', { status: 'active' })
|
||||
.groupBy('study.study_type')
|
||||
.orderBy('study.study_type', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => ({ studyType: r.studyType, count: parseInt(r.count, 10) }));
|
||||
}
|
||||
|
||||
async getBodyRegions(tenantId: string): Promise<{ bodyRegion: string; count: number }[]> {
|
||||
const result = await this.repository.createQueryBuilder('study')
|
||||
.select('study.body_region', 'bodyRegion')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('study.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('study.deleted_at IS NULL')
|
||||
.andWhere('study.status = :status', { status: 'active' })
|
||||
.groupBy('study.body_region')
|
||||
.orderBy('study.body_region', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => ({ bodyRegion: r.bodyRegion, count: parseInt(r.count, 10) }));
|
||||
}
|
||||
}
|
||||
3
src/modules/imaging/services/index.ts
Normal file
3
src/modules/imaging/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ImagingStudyService } from './imaging-study.service';
|
||||
export { ImagingOrderService } from './imaging-order.service';
|
||||
export { DicomService, ViewerUrlResult } from './dicom.service';
|
||||
Loading…
Reference in New Issue
Block a user