190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|