Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user