Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PolimedController } from './polimed.controller';
import { PolimedStore } from './polimed.store';
@Module({
controllers: [PolimedController],
providers: [PolimedStore],
})
export class AppModule {}
+15
View File
@@ -0,0 +1,15 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.enableCors({ origin: true });
const port = Number(process.env.POLIMED_MOCK_PORT ?? 4100);
await app.listen(port);
Logger.log(`polimed-mock listening on http://localhost:${port}`, 'Bootstrap');
}
bootstrap();
@@ -0,0 +1,52 @@
import { Body, Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { IsEnum, IsISO8601, IsOptional, IsString } from 'class-validator';
import { PolimedStore } from './polimed.store';
import type { VisitEventType } from './polimed.types';
class PushVisitEventDto {
@IsEnum(['arrived', 'service_started', 'service_ended', 'left_without_service'])
type!: VisitEventType;
@IsISO8601()
occurredAt!: string;
@IsOptional()
@IsString()
source?: string;
}
@Controller()
export class PolimedController {
constructor(private readonly store: PolimedStore) {}
@Get('health')
health() {
return { status: 'ok', service: 'polimed-mock' };
}
@Get('patients/search')
searchPatients(@Query('q') q = '', @Query('limit') limit = '20') {
return this.store.searchPatients(q, Number(limit) || 20);
}
@Get('appointments')
listAppointments(@Query('date') date?: string) {
return this.store.listAppointments(date);
}
@Get('appointments/:id')
getAppointment(@Param('id') id: string) {
return this.store.getAppointment(id);
}
@Get('appointments/:id/events')
getAppointmentEvents(@Param('id') id: string) {
return this.store.getVisitEvents(id);
}
@Post('visits/:appointmentId/events')
@HttpCode(200)
pushVisitEvent(@Param('appointmentId') appointmentId: string, @Body() body: PushVisitEventDto) {
return this.store.pushVisitEvent(appointmentId, body);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { PolimedAppointment, PolimedPatient, VisitEvent } from './polimed.types';
// В dev (nest start) __dirname = .../apps/polimed-mock/src — поднимаемся в корень app.
// В prod (node dist/main.js) __dirname = .../apps/polimed-mock/dist — то же самое.
const SEEDS_DIR = join(__dirname, '..', 'seeds');
@Injectable()
export class PolimedStore implements OnModuleInit {
private readonly logger = new Logger(PolimedStore.name);
private patients: PolimedPatient[] = [];
private appointments: PolimedAppointment[] = [];
private visitEvents = new Map<string, VisitEvent[]>();
onModuleInit() {
this.patients = JSON.parse(readFileSync(join(SEEDS_DIR, 'patients.json'), 'utf-8'));
const appointmentsTemplate: Array<Omit<PolimedAppointment, 'scheduledFor'> & { hourOffset: number; minuteOffset: number }> =
JSON.parse(readFileSync(join(SEEDS_DIR, 'appointments.json'), 'utf-8'));
// Привязываем appointments к сегодняшней дате на старте — журнал «на сегодня» всегда живой.
const today = new Date();
today.setHours(8, 0, 0, 0);
this.appointments = appointmentsTemplate.map((a) => {
const dt = new Date(today);
dt.setHours(today.getHours() + a.hourOffset, today.getMinutes() + a.minuteOffset, 0, 0);
return {
id: a.id,
patientId: a.patientId,
patientFullName: a.patientFullName,
doctorFullName: a.doctorFullName,
specialty: a.specialty,
status: a.status,
scheduledFor: dt.toISOString(),
};
});
this.logger.log(
`Seeded ${this.patients.length} patients, ${this.appointments.length} appointments (today)`,
);
}
searchPatients(query: string, limit = 20): PolimedPatient[] {
const q = query.trim().toLowerCase();
if (!q) return this.patients.slice(0, limit);
return this.patients
.filter(
(p) =>
p.fullName.toLowerCase().includes(q) ||
p.cardNumber.toLowerCase().includes(q) ||
p.phone.includes(q),
)
.slice(0, limit);
}
listAppointments(dateIso?: string): PolimedAppointment[] {
if (!dateIso) return this.appointments;
return this.appointments.filter((a) => a.scheduledFor.startsWith(dateIso));
}
getAppointment(id: string): PolimedAppointment {
const ap = this.appointments.find((a) => a.id === id);
if (!ap) throw new NotFoundException(`Appointment ${id} not found`);
return ap;
}
pushVisitEvent(appointmentId: string, event: VisitEvent) {
this.getAppointment(appointmentId); // throws 404 if missing
const arr = this.visitEvents.get(appointmentId) ?? [];
arr.push(event);
this.visitEvents.set(appointmentId, arr);
this.logger.log(
`WRITE-BACK appointment=${appointmentId} event=${event.type} at=${event.occurredAt}`,
);
return { appointmentId, eventsTotal: arr.length };
}
getVisitEvents(appointmentId: string): VisitEvent[] {
return this.visitEvents.get(appointmentId) ?? [];
}
}
+25
View File
@@ -0,0 +1,25 @@
export interface PolimedPatient {
id: string;
fullName: string;
birthDate: string; // YYYY-MM-DD
phone: string;
cardNumber: string;
}
export interface PolimedAppointment {
id: string;
patientId: string;
patientFullName: string;
doctorFullName: string;
specialty: string;
scheduledFor: string; // ISO datetime
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
}
export type VisitEventType = 'arrived' | 'service_started' | 'service_ended' | 'left_without_service';
export interface VisitEvent {
type: VisitEventType;
occurredAt: string; // ISO datetime
source?: string; // например 'reception-video'
}