You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
189 lines
6.1 KiB
189 lines
6.1 KiB
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<string, number> = {}; |
|
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, |
|
}; |
|
} |
|
}
|
|
|