import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EvidenceService } from '../evidence/evidence.service'; export interface JourneySegment { zoneCode: string; cameraName: string; startedAt: Date; endedAt: Date; eventTypes: string[]; durationSec: number; } @Injectable() export class VisitsService { constructor( private readonly prisma: PrismaService, private readonly evidence: EvidenceService, ) {} async listForPatient(patientId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, include: { visits: { orderBy: { arrivedAt: 'desc' } } }, }); if (!patient) throw new NotFoundException(`Patient ${patientId} not found`); const [avatar, journey] = await Promise.all([ this.getPatientAvatar(patientId), this.getPatientJourney(patientId), ]); return { patient: { id: patient.id, fullName: patient.fullName, polimedPatientId: patient.polimedPatientId, consentReceivedAt: patient.consentReceivedAt, consentRevokedAt: patient.consentRevokedAt, pendingDeletionAt: patient.pendingDeletionAt, avatarUrl: avatar?.url ?? null, avatarBbox: avatar?.bbox ?? null, }, journey, visits: patient.visits.map((v) => ({ id: v.id, arrivedAt: v.arrivedAt, serviceStartedAt: v.serviceStartedAt, serviceEndedAt: v.serviceEndedAt, leftWithoutService: v.leftWithoutService, polimedAppointmentId: v.polimedAppointmentId, waitingSec: v.serviceStartedAt && v.arrivedAt ? Math.round((v.serviceStartedAt.getTime() - v.arrivedAt.getTime()) / 1000) : null, serviceSec: v.serviceEndedAt && v.serviceStartedAt ? Math.round((v.serviceEndedAt.getTime() - v.serviceStartedAt.getTime()) / 1000) : null, })), }; } async listPatients() { const patients = await this.prisma.patient.findMany({ orderBy: { updatedAt: 'desc' }, include: { _count: { select: { visits: true } } }, }); const rows = await Promise.all( patients.map(async (p) => { const avatar = await this.getPatientAvatar(p.id); return { id: p.id, fullName: p.fullName, polimedPatientId: p.polimedPatientId, consentReceivedAt: p.consentReceivedAt, consentRevokedAt: p.consentRevokedAt, pendingDeletionAt: p.pendingDeletionAt, visitsCount: p._count.visits, avatarUrl: avatar?.url ?? null, avatarBbox: avatar?.bbox ?? null, }; }), ); return rows; } /** * Маршрут пациента: события всех его треков, сгруппированные по непрерывным * пребываниям в одной зоне. Считаем длительность каждого сегмента и суммы по зонам. * Эвристика "потерян": последний сегмент в зоне D, прошло >15 мин, нет последующих событий. */ private async getPatientJourney(patientId: string) { const events = await this.prisma.$queryRaw< Array<{ track_id: string; type: string; occurred_at: Date; zone_code: string; camera_name: string; }> >` SELECT te.track_id::text AS track_id, te.type::text AS type, te.occurred_at, z.code::text AS zone_code, c.name AS camera_name FROM track_events te JOIN tracks t ON t.id = te.track_id JOIN zones z ON z.id = te.zone_id JOIN cameras c ON c.id = te.camera_id WHERE t.patient_id = ${patientId}::uuid ORDER BY te.occurred_at ASC `; const segments: JourneySegment[] = []; for (const e of events) { const last = segments[segments.length - 1]; if (last && last.zoneCode === e.zone_code && last.cameraName === e.camera_name) { last.endedAt = e.occurred_at; last.eventTypes.push(e.type); last.durationSec = Math.round((last.endedAt.getTime() - last.startedAt.getTime()) / 1000); } else { segments.push({ zoneCode: e.zone_code, cameraName: e.camera_name, startedAt: e.occurred_at, endedAt: e.occurred_at, eventTypes: [e.type], durationSec: 0, }); } } const byZone: Record = {}; for (const s of segments) { byZone[s.zoneCode] = (byZone[s.zoneCode] ?? 0) + s.durationSec; } const lastSegment = segments[segments.length - 1]; let lostInTransit = false; if (lastSegment && lastSegment.zoneCode === 'D') { const ageMs = Date.now() - lastSegment.endedAt.getTime(); if (ageMs > 15 * 60 * 1000) lostInTransit = true; } return { segments, timeInZoneSec: byZone, lostInTransit, totalEvents: events.length, firstSeenAt: events[0]?.occurred_at ?? null, lastSeenAt: events[events.length - 1]?.occurred_at ?? null, }; } /** * Аватар пациента — лучший кадр из одного из его треков. * Предпочитаем кадры с face_bbox (детектированное лицо). */ private async getPatientAvatar( patientId: string, ): Promise<{ url: string; bbox: { box: number[]; imgW: number; imgH: number } | null } | null> { const rows = await this.prisma.$queryRaw< Array<{ evidence_key: string; face_bbox: unknown }> >` SELECT te.evidence_key, te.face_bbox FROM track_events te JOIN tracks t ON t.id = te.track_id WHERE t.patient_id = ${patientId}::uuid AND te.evidence_key IS NOT NULL ORDER BY (te.face_bbox IS NULL) ASC, te.occurred_at ASC LIMIT 1 `; const row = rows[0]; if (!row) return null; const url = await this.evidence.getPresignedUrl(row.evidence_key); return { url, bbox: (row.face_bbox as { box: number[]; imgW: number; imgH: number } | null) ?? null, }; } }