Версия цифровой рецепции с резализованным механизмом отслеживания трека пациента по зонам
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

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