Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user