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
+27
View File
@@ -0,0 +1,27 @@
# polimed-mock
In-memory мок МИС Полимед для разработки Цифровой рецепции. Заменяется на реальный SDK Полимед в более поздних фазах — нужно поменять только `POLIMED_BASE_URL` в `apps/api`.
Порт: **4100** (см. `.env`).
## Эндпоинты
| Метод | Путь | Описание |
|---|---|---|
| `GET` | `/health` | Health-чек |
| `GET` | `/patients/search?q=&limit=20` | Поиск пациентов по ФИО / № карты / телефону |
| `GET` | `/appointments?date=YYYY-MM-DD` | Журнал записей. Без `date` — все. |
| `GET` | `/appointments/:id` | Детали записи |
| `GET` | `/appointments/:id/events` | События визита (отправленные через POST) |
| `POST` | `/visits/:appointmentId/events` | Write-back события визита (`arrived` / `service_started` / `service_ended` / `left_without_service`) |
## Данные
Пациенты и шаблоны записей лежат в `seeds/*.json`. На старте записи пересчитываются на сегодняшнюю дату (`hourOffset` + `minuteOffset` относительно 08:00) — журнал «на сегодня» всегда живой.
## Запуск
```bash
pnpm --filter=@reception/polimed-mock dev
# curl http://localhost:4100/appointments?date=$(date +%F)
```
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
{
"include": "../seeds/**/*.json",
"outDir": "dist/seeds",
"watchAssets": true
}
],
"watchAssets": true
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@reception/polimed-mock",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
+22
View File
@@ -0,0 +1,22 @@
[
{ "id": "pol-a-001", "patientId": "pol-p-001", "patientFullName": "Иванов Иван Иванович", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 0, "minuteOffset": 30 },
{ "id": "pol-a-002", "patientId": "pol-p-002", "patientFullName": "Петрова Анна Сергеевна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 1, "minuteOffset": 0 },
{ "id": "pol-a-003", "patientId": "pol-p-003", "patientFullName": "Смирнов Олег Петрович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 1, "minuteOffset": 30 },
{ "id": "pol-a-004", "patientId": "pol-p-004", "patientFullName": "Кузнецова Мария Андреевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 0 },
{ "id": "pol-a-005", "patientId": "pol-p-005", "patientFullName": "Васильев Дмитрий Николаевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 30 },
{ "id": "pol-a-006", "patientId": "pol-p-006", "patientFullName": "Соколова Елена Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 3, "minuteOffset": 0 },
{ "id": "pol-a-007", "patientId": "pol-p-007", "patientFullName": "Михайлов Сергей Александрович", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 3, "minuteOffset": 30 },
{ "id": "pol-a-008", "patientId": "pol-p-008", "patientFullName": "Новикова Ольга Дмитриевна", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 0 },
{ "id": "pol-a-009", "patientId": "pol-p-009", "patientFullName": "Фёдоров Александр Юрьевич", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 30 },
{ "id": "pol-a-010", "patientId": "pol-p-010", "patientFullName": "Морозова Татьяна Игоревна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 5, "minuteOffset": 0 },
{ "id": "pol-a-011", "patientId": "pol-p-011", "patientFullName": "Волков Артём Сергеевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 5, "minuteOffset": 30 },
{ "id": "pol-a-012", "patientId": "pol-p-012", "patientFullName": "Алексеева Наталья Павловна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 6, "minuteOffset": 0 },
{ "id": "pol-a-013", "patientId": "pol-p-013", "patientFullName": "Лебедев Григорий Михайлович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 6, "minuteOffset": 30 },
{ "id": "pol-a-014", "patientId": "pol-p-014", "patientFullName": "Семёнова Екатерина Олеговна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 7, "minuteOffset": 0 },
{ "id": "pol-a-015", "patientId": "pol-p-015", "patientFullName": "Егоров Виталий Андреевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 7, "minuteOffset": 30 },
{ "id": "pol-a-016", "patientId": "pol-p-016", "patientFullName": "Павлова Дарья Ивановна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 8, "minuteOffset": 0 },
{ "id": "pol-a-017", "patientId": "pol-p-017", "patientFullName": "Степанов Николай Романович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 8, "minuteOffset": 30 },
{ "id": "pol-a-018", "patientId": "pol-p-018", "patientFullName": "Никитина Юлия Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 9, "minuteOffset": 0 },
{ "id": "pol-a-019", "patientId": "pol-p-019", "patientFullName": "Орлов Максим Денисович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 9, "minuteOffset": 30 },
{ "id": "pol-a-020", "patientId": "pol-p-020", "patientFullName": "Андреева Светлана Геннадьевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 10, "minuteOffset": 0 }
]
+22
View File
@@ -0,0 +1,22 @@
[
{ "id": "pol-p-001", "fullName": "Иванов Иван Иванович", "birthDate": "1980-03-12", "phone": "+79001234501", "cardNumber": "K-001" },
{ "id": "pol-p-002", "fullName": "Петрова Анна Сергеевна", "birthDate": "1992-07-23", "phone": "+79001234502", "cardNumber": "K-002" },
{ "id": "pol-p-003", "fullName": "Смирнов Олег Петрович", "birthDate": "1975-11-04", "phone": "+79001234503", "cardNumber": "K-003" },
{ "id": "pol-p-004", "fullName": "Кузнецова Мария Андреевна", "birthDate": "1988-01-18", "phone": "+79001234504", "cardNumber": "K-004" },
{ "id": "pol-p-005", "fullName": "Васильев Дмитрий Николаевич", "birthDate": "1965-09-30", "phone": "+79001234505", "cardNumber": "K-005" },
{ "id": "pol-p-006", "fullName": "Соколова Елена Викторовна", "birthDate": "1990-04-14", "phone": "+79001234506", "cardNumber": "K-006" },
{ "id": "pol-p-007", "fullName": "Михайлов Сергей Александрович", "birthDate": "1983-12-08", "phone": "+79001234507", "cardNumber": "K-007" },
{ "id": "pol-p-008", "fullName": "Новикова Ольга Дмитриевна", "birthDate": "1978-06-27", "phone": "+79001234508", "cardNumber": "K-008" },
{ "id": "pol-p-009", "fullName": "Фёдоров Александр Юрьевич", "birthDate": "1995-02-11", "phone": "+79001234509", "cardNumber": "K-009" },
{ "id": "pol-p-010", "fullName": "Морозова Татьяна Игоревна", "birthDate": "1972-08-19", "phone": "+79001234510", "cardNumber": "K-010" },
{ "id": "pol-p-011", "fullName": "Волков Артём Сергеевич", "birthDate": "1986-05-03", "phone": "+79001234511", "cardNumber": "K-011" },
{ "id": "pol-p-012", "fullName": "Алексеева Наталья Павловна", "birthDate": "1969-10-25", "phone": "+79001234512", "cardNumber": "K-012" },
{ "id": "pol-p-013", "fullName": "Лебедев Григорий Михайлович", "birthDate": "1991-12-15", "phone": "+79001234513", "cardNumber": "K-013" },
{ "id": "pol-p-014", "fullName": "Семёнова Екатерина Олеговна", "birthDate": "1984-03-07", "phone": "+79001234514", "cardNumber": "K-014" },
{ "id": "pol-p-015", "fullName": "Егоров Виталий Андреевич", "birthDate": "1977-07-29", "phone": "+79001234515", "cardNumber": "K-015" },
{ "id": "pol-p-016", "fullName": "Павлова Дарья Ивановна", "birthDate": "1996-09-12", "phone": "+79001234516", "cardNumber": "K-016" },
{ "id": "pol-p-017", "fullName": "Степанов Николай Романович", "birthDate": "1962-11-21", "phone": "+79001234517", "cardNumber": "K-017" },
{ "id": "pol-p-018", "fullName": "Никитина Юлия Викторовна", "birthDate": "1989-04-06", "phone": "+79001234518", "cardNumber": "K-018" },
{ "id": "pol-p-019", "fullName": "Орлов Максим Денисович", "birthDate": "1993-01-30", "phone": "+79001234519", "cardNumber": "K-019" },
{ "id": "pol-p-020", "fullName": "Андреева Светлана Геннадьевна", "birthDate": "1981-08-09", "phone": "+79001234520", "cardNumber": "K-020" }
]
+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'
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@reception/tsconfig/nest.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
File diff suppressed because one or more lines are too long