Files
Danil Suhomlinov 33800292aa chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:45 +03:00

503 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# HRM Medpark — Полное описание системы
> HR-management система для **Medpark International Hospital** (Кишинёв, Молдова), заменяющая Excel/MS Forms процессы для отдела кадров и контроля качества медицинского персонала.
>
> UI — только на румынском (`ro`). Backend сообщения / валидации — также на румынском.
---
## 1. Архитектура
### Monorepo (pnpm workspace)
```
hrm-medpark/
├── apps/
│ ├── api/ ← NestJS + Prisma + PostgreSQL
│ └── web/ ← React + Vite + Mantine
├── docker-compose.yml ← Postgres, Redis, Keycloak, MinIO
└── package.json ← root scripts (api:dev, web:dev, db:migrate)
```
### Stack
| Слой | Технологии |
|------------|------------------------------------------------------------------------------------|
| Backend | NestJS 10 (Fastify), Prisma 5, PostgreSQL 16, BullMQ (Redis), nestjs-i18n, Passport-JWT (Keycloak JWKS) |
| Frontend | React 18, Vite, Mantine v7, React Router v6, TanStack Query v5, react-hook-form + Zod, dayjs |
| Auth | Keycloak (внешний IdP), JWT валидация через JWKS |
| Storage | MinIO (S3-совместимый) для DOCX-документов |
| Notif | n8n webhooks (cron-based reminders) |
| Документы | `docx` (npm) — рендер из TipTap-JSON шаблонов (`AnexaTemplate`), редактируются в `/admin/anexa-templates` |
### Глобальный API префикс
Все REST-эндпоинты под `/api/v1/...` (через `app.setGlobalPrefix('api/v1')`).
Vite proxy на dev: `/api → http://localhost:3001`.
---
## 2. Аутентификация
- Токены выдаёт Keycloak.
- `KeycloakStrategy` (passport-jwt) валидирует через JWKS.
- Роли извлекаются из `realm_access.roles` и `resource_access[clientId].roles`.
- Если в токене нет ни одной из 7 HRM-ролей — `UnauthorizedException`.
### Роли
| Role | Назначение |
|-------------------|------------------------------------------------------------|
| `hr_admin` | Полный CRUD по сотрудникам, документам, кампаниям, риск-картам |
| `hr_specialist` | CREATE/UPDATE по основным сущностям, без удаления |
| `manager` | Редактирование форм оценки своих подчинённых |
| `nursing_director`| Утверждение финальной категории при оценке |
| `quality_auditor` | Заполняет блоки A-D в формах оценки |
| `medic_familie` | Выдаёт вердикт по контролю медиц. (apt / inapt / etc.) |
| `employee` | Read-only доступ к собственным данным |
`@Roles(...)` декоратор + `RolesGuard` на каждом контроллере.
---
## 3. База данных (Prisma, 20 моделей)
Ключевые сущности:
```
Employee (1) ──┬── (N) IdentityDocument
├── (N) FamilyMember
├── (N) Education
├── (N) Qualification
├── (N) Training
├── (N) DisciplinarySanction
├── (N) EmploymentContract
├── (1) Benefit ← upsert (one-to-one)
├── (1) EmployeeMedicalProfile ← upsert
├── (N) MedicalCheckup
└── (N) PerformanceEvaluation
Department (self-referencing tree, parentId) — adjacency list
WorkplaceRiskCard (1) ── (N) EmployeeMedicalProfile ← FK
PerformanceCampaign (1) ── (N) PerformanceEvaluation
```
### Reference (read-only) таблицы:
- `DisabilityGrade`, `TaxExemption`, `WorkSchedule`
### Поля типа `@db.Date` — используются для дат без времени (рождение, экспирация).
### `@db.Decimal` — для денежных и физических величин (зарплата, дозы радиации).
### Audit log
- `AuditService.logRead(...)` и `logChange({ action: 'CREATE'|'UPDATE'|'DELETE' })`.
- `AuditModule` помечен `@Global()` — доступен везде без явного импорта.
- `PrismaModule` тоже `@Global()`.
---
## 4. Backend — модули
```
apps/api/src/modules/
├── auth/ ← KeycloakStrategy + AuthGuard
├── employees/ ← основной CRUD + 7 sub-resources
│ ├── employees.{controller,service,module}.ts
│ ├── dto/{create,query}-employee.dto.ts
│ └── sub-resources/
│ ├── identity-documents/
│ ├── family-members/
│ ├── educations/
│ ├── qualifications/
│ ├── trainings/
│ ├── disciplinary-sanctions/
│ ├── benefit/
│ ├── contracts/
│ └── sub-resources.service-factory.ts ← общая фабрика subCreate/subUpdate/subRemove
├── departments/ ← дерево отделов
├── reference/ ← read-only справочники, без аудита
├── evaluation/ ← Phase 4 — оценка персонала
│ ├── evaluation.{controller,service,module}.ts
│ ├── dto/{create-campaign,update-form,approve-form}.dto.ts
│ └── workers/evaluation-notifications.processor.ts ← BullMQ
├── medical/ ← Phase 5 — медицинский контроль
│ ├── medical.{controller,module}.ts
│ ├── dto/{risk-card,medical-profile,checkup}.dto.ts
│ └── services/
│ ├── risk-cards.service.ts
│ ├── medical-profile.service.ts
│ ├── checkup.service.ts
│ ├── bulk.service.ts
│ ├── document-generator.service.ts ← рендер из TipTap-JSON через tiptap-to-docx
│ ├── tiptap-to-docx.ts ← конвертер TipTap doc → docx, поддержка repeatRows + variableChip
│ └── storage.service.ts ← MinIO client
├── admin/
│ └── anexa-templates/ ← CRUD на AnexaTemplate (only hr_admin), drafts + версии
├── inventory/ ← Vestimentație / Echipament — InventoryItem CRUD + adjust-stock
├── contracts/ ← глобальный список CIM (отдельная страница)
├── notifications/ ← BullMQ daily-expiry cron (08:00 EE/Bucharest) → n8n webhook
│ └── workers/daily-expiry.processor.ts
└── dashboard/ ← агрегированная статистика
```
### Sub-resource pattern (employees)
Все вложенные ресурсы (документы, семья, образование...) идут под:
```
GET /employees/:employeeId/<resource>
POST /employees/:employeeId/<resource>
PATCH /employees/:employeeId/<resource>/:id
DELETE /employees/:employeeId/<resource>/:id
```
Сервисы используют общую фабрику `subCreate/subUpdate/subRemove` с проверкой принадлежности к employeeId. Все write-операции пишут в audit log.
Роли по умолчанию:
- `GET` — все HR роли
- `POST/PATCH``hr_admin`, `hr_specialist`
- `DELETE` — только `hr_admin`
### Особенности
- **DisciplinarySanctions**: `dataExpirarii = dataAplicarii + 6 месяцев` вычисляется на сервере, не принимается от клиента.
- **Benefit**: используется `prisma.benefit.upsert()` — у сотрудника всегда максимум одна запись.
- **EmployeeMedicalProfile**: `dozaTotalaMsv` — computed поле (`externa + interna`) в response.
---
## 5. Frontend — pages & роутинг
```
apps/web/src/
├── App.tsx ← AppShell + NAV_ITEMS + Routes
├── main.tsx ← Mantine theme (medpark teal #008286, Montserrat)
├── api/
│ ├── client.ts ← axios baseURL '/api/v1' + bearer token
│ └── types.ts ← все TS-интерфейсы синхронизированные с Prisma
├── pages/
│ ├── auth/LoginPage.tsx
│ ├── dashboard/DashboardPage.tsx
│ ├── employees/
│ │ ├── EmployeesPage.tsx ← список + поиск + фильтр
│ │ ├── EmployeeDetailPage.tsx ← header + 10 табов
│ │ ├── employeeSchema.ts ← Zod + IDNP checksum
│ │ ├── components/
│ │ │ ├── EmployeeHeader.tsx
│ │ │ └── EmployeeDrawer.tsx ← создание / редактирование
│ │ ├── tabs/
│ │ │ ├── PersonalTab.tsx
│ │ │ ├── DocumenteTab.tsx ← подсветка expirare < 30 дней (amber/red)
│ │ │ ├── FamilieTab.tsx
│ │ │ ├── StudiiTab.tsx
│ │ │ ├── CalificariTab.tsx
│ │ │ ├── TrainingTab.tsx
│ │ │ ├── SanctiuniTab.tsx ← активные (не stinsa) = красная строка
│ │ │ ├── BeneficiiTab.tsx
│ │ │ ├── ContracteTab.tsx
│ │ │ └── MedicalTab.tsx
│ │ └── drawers/
│ │ └── *Drawer.tsx ← по одному на каждый sub-resource
│ ├── departments/DepartmentsPage.tsx ← дерево с раскрытием
│ ├── evaluation/
│ │ ├── EvaluationPage.tsx ← список кампаний
│ │ ├── CampaignDetailPage.tsx ← список форм / категорий
│ │ ├── EvaluationFormPage.tsx ← заполнение блоков A-D
│ │ └── components/{ScoreInput,StatusBadge,CategoryBadge}.tsx
│ └── medical/
│ ├── RiskCardsPage.tsx ← карты риска NU-10-MS-2026
│ ├── MedicalControlPage.tsx ← bulk-select сотрудников + Generează documente
│ └── MedicalInboxPage.tsx ← inbox для medic_familie
└── styles/global.css ← CSS-переменные бренда
```
### Маршрутизация (фрагмент App.tsx)
```
/ → DashboardPage
/employees → EmployeesPage
/employees/:id → EmployeeDetailPage
/departments → DepartmentsPage
/evaluation → EvaluationPage
/evaluation/:id → CampaignDetailPage
/evaluation/form/:id → EvaluationFormPage
/risk-cards → RiskCardsPage
/medical → MedicalControlPage
/medic-inbox → MedicalInboxPage
```
> Маршруты `/admin/templates` и `/admin/templates/:type` удалены. Бэкенд-API `admin/anexa-templates` (CRUD + версии) сохранён.
### State management
- **TanStack Query** — fetch + кэш. Reference data: `staleTime: 300_000` (5 мин).
- Все табы сотрудника шарят один `useQuery(['employee', id])` — нет per-tab fetch'ей.
- Sub-resource записи делают `invalidateQueries(['employee', id])` → весь профиль рефетчится.
- Ключ кэша для медицины: `['medical-profile', employeeId]`, `['medical-checkups', employeeId]`, `['risk-cards']`.
---
## 6. Бизнес-логика — критичные алгоритмы
### IDNP (Moldovan ID number) — 13 цифр
Реализовано **дважды**: на бэке (custom validator в DTO) и на фронте (Zod) для live-фидбека.
```ts
function validateIdnp(idnp: string): boolean {
const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1];
const sum = weights.reduce((acc, w, i) => acc + w * +idnp[i], 0);
return sum % 10 === +idnp[12];
}
```
### Performance Evaluation — расчёт категории
```
function calculateCategory(form):
total = blockA + blockB + blockC + blockD + (testJci * 0.1)
if total >= 90 && expert_score >= threshold → 'superioara'
else if total >= 75 → 'cat_I'
else if total >= 50 → 'cat_II'
else → 'fara_categorie'
```
### Eligibility для оценки
Сотрудник попадает в кампанию если стаж в компании на cutoff > 6 месяцев (по `dataAngajarii` контракта).
### Anexa-документы (NU-10-MS-2026)
Генерируются в `document-generator.service.ts` через `docx` библиотеку:
| Документ | Когда генерируется |
|--------------------------|--------------------------------------------------------|
| Anexa 3 (Fișa solicitare) | при инициации контроля (bulk) |
| Anexa 4 (Fișa evaluare) | при инициации контроля (bulk) |
| Anexa 4B (Suplim. radiații) | если `expusRadiatiiIonizante = true` |
| Anexa 6 (Verdict) | после `medic_familie` ставит вердикт (`/checkups/:id/complete`) |
DOCX сохраняются в MinIO (`s3://hrm-docs/<key>`), URL пишется в `MedicalCheckup.documenteGenerate` (JSON массив `{name, url, type}`).
### Скачивание / удаление документов
- **Скачивание**: фронт извлекает key из `s3://bucket/key`, вызывает `GET /medical/documents/presign?key=...`, открывает presigned URL в новой вкладке.
- **Удаление одного**: `DELETE /medical/checkups/:id/documents?name=...` — удаляет из MinIO И из массива.
- **Удаление всех**: `DELETE /medical/checkups/:id/documents/all` — параллельно убирает все файлы из MinIO + чистит массив.
### Контроль медицинский — типы
```
la_angajare ← перед трудоустройством
periodic ← плановый по карте риска
la_reluarea_activitatii ← после длительного отсутствия
la_incetarea_expunerii ← при увольнении из вредной среды
suplimentar ← по запросу
```
При `complete` для типов `la_angajare/periodic/la_reluarea_activitatii` обновляется `EmployeeMedicalProfile.dataUltimControlMedical` через `updateMany`.
### Notifications (BullMQ)
`evaluation-notifications.processor.ts``@Process('campaign-reminder')`. Постит в n8n webhook с массивом сотрудников. Cron-планирование на 14 дней до ожидаемого срока.
---
## 7. Brand & UX
### Цвета (Medpark)
- **Teal** `#008286` — primary, акценты, ссылки
- **Charcoal** `#58595b` — основной текст
- **Amber** `#fbb034` — предупреждения (умеренно)
- **Red** `#b11116` — деструктив, истёкшие даты, активные санкции
### Шрифт
- **Montserrat**, weights 300/500/600/700 (импортируется в `index.html`)
### Mantine theme
```ts
const medparkTeal = ['#e6f4f4', ..., '#008286', ..., '#003d3f']; // 10 shades
createTheme({
fontFamily: "'Montserrat', Arial, sans-serif",
primaryColor: 'medpark',
colors: { medpark: medparkTeal },
});
```
### Конвенции
- Заголовки страниц: `<Title order={2}>` + teal underline (40×3 px, `borderRadius: 2`)
- Таблицы: `borderBottom: '2px solid teal'` на `<thead>`, `borderBottom: '1px solid #e9ecef'` на строках
- Кнопки primary: `background: teal`, `fontWeight: 500`, `height: 40`
- Drawers: `size="xl"`, секции с teal-left-border divider
- Badges по статусам: `medpark` (success), `red` (danger), `gray` (neutral), `orange` (warning)
- DateInput: `valueFormat="DD.MM.YYYY"`, на бэк отправляем `YYYY-MM-DD` (через `dayjs`)
### Подсветка дат
```ts
const days = dayjs(dataExpirarii).diff(dayjs(), 'day');
if (days < 0) return 'red'; // истёкло
if (days < 30) return 'amber'; // истекает скоро
```
---
## 8. Интернационализация
- Только румынский (`ro`).
- Backend: nestjs-i18n с файлом `apps/api/i18n/ro/translation.json`.
- Frontend: ключи захардкожены в компонентах (без i18next), либо в `apps/web/src/i18n/ro.json`.
> Языковой переключатель в UI **отсутствует** — было удалено по требованию.
---
## 9. Workflow разработки
### Запуск
```bash
# 1. Инфраструктура
docker compose up -d # postgres, redis, keycloak, minio
# 2. Миграции (первый раз)
pnpm db:migrate
# 3. Dev mode
pnpm dev # одновременно api (3001) + web (5173)
# или раздельно:
pnpm api:dev
pnpm web:dev
```
### Доступ
- Frontend: http://localhost:5173
- API: http://localhost:3001/api/v1
- Prisma Studio: `pnpm db:studio`
- Keycloak admin: http://localhost:8080
- MinIO console: http://localhost:9001 (minioadmin/minioadmin)
### При изменении схемы
```bash
# отредактировать apps/api/prisma/schema.prisma
pnpm --filter api prisma:migrate dev --name <migration_name>
# Prisma Client регенерится автоматически
```
### Линт / типчек
```bash
pnpm --filter api typecheck
pnpm --filter web typecheck
```
---
## 10. Соглашения по коду
### TypeScript
- `apps/web/src/api/types.ts` — единственный источник истины для интерфейсов на фронте; обновляется вручную при изменении Prisma-схемы.
- DTO бэкенда → используют `class-validator` декораторы.
### Auth в контроллерах
```ts
@Controller('...')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class ... {
@Get(...)
@Roles('hr_admin', 'hr_specialist')
...(@Request() req: AuthReq) {
return this.svc.method(..., req.user.id, req.user.role);
}
}
```
### Audit
Каждый write-метод в сервисе ОБЯЗАН вызвать `this.audit.logChange({ userId, userRole, action, entity, entityId })`.
Read-методы — `logRead({ userId, userRole, entity, entityId })` для чувствительных данных.
### React Query mutations
```ts
const mutation = useMutation({
mutationFn: (data) => apiClient.post(...),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['...'] });
notifications.show({ color: 'medpark', title: '...', message: '...' });
},
onError: (err) => {
const msg = (err as any)?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
```
---
## 11. Roadmap & статус
| Phase | Статус | Что входит |
|------------------------------|--------------|--------------------------------------------------------------|
| Phase 1: Employee master data | ✅ Done | CRUD + 8 sub-resources + drawer + детальная страница |
| Phase 2: Contracts | ⚠️ Частично | Schema + sub-resource controller, но нет специальной страницы |
| Phase 3: Departments | ✅ Done | Tree CRUD |
| Phase 4: Performance Evaluation | ✅ Done | Кампании, формы, утверждение, BullMQ напоминания |
| Phase 5: Medical Control | ✅ Done | Risk Cards, профили, checkups, bulk + DOCX, MinIO, inbox |
| Phase 5b: Anexa template editor | ✅ Done | TipTap v3 редактор шаблонов в `/admin/anexa-templates` (бэкенд + API), repeatRows, verdict.checkbox; **UI редактора удалён из фронтенда** |
| Phase 5c: Inventory | ✅ Done | InventoryItem CRUD, atomic stock $transaction в Benefit upsert |
| Phase 5d: Notifications | ✅ Done | BullMQ daily-expiry cron → n8n webhook (документы, calificări, contracte, medical, sancțiuni) |
| Phase 6: Polish | 🔲 Pending | Дашборды per-role, performance тесты, GDPR DPIA |
### Pending hardening (TODO)
-`POST /auth/dev-login` защищён `NODE_ENV === 'production'` guard (`ALLOW_DEV_LOGIN=true` для тестового продакшена)
- ✅ BullMQ daily cron → n8n webhook (docs/categories/contracts/medical expiry) — `notifications` модуль
- 🔲 Excel HR import (`POST /employees/import` через `exceljs`) — НЕ требование, отложено
- 🔲 Seed data для DisabilityGrade / TaxExemption (department seed готов)
- 🔲 Dev-mode Keycloak bypass (сейчас все API возвращают 401 без валидного JWT)
---
## 12. Где искать что
| Задача | Файл / директория |
|-------------------------------------|------------------------------------------------------------------|
| Поменять схему БД | `apps/api/prisma/schema.prisma` |
| Добавить эндпоинт | `apps/api/src/modules/<module>/*.controller.ts` |
| Изменить роли доступа | `@Roles(...)` в контроллере |
| Изменить тему / цвета | `apps/web/src/main.tsx` + `apps/web/src/styles/global.css` |
| Поменять навигацию | `apps/web/src/App.tsx``NAV_ITEMS` |
| Добавить TypeScript-тип | `apps/web/src/api/types.ts` |
| Добавить sub-resource сотрудника | `apps/api/src/modules/employees/sub-resources/<name>/` |
| Изменить DOCX-документ | `apps/api/src/modules/medical/services/document-generator.service.ts` (рендер) + `tiptap-to-docx.ts` (конвертер) + `/admin/anexa-templates` UI (контент) |
| Поправить cron-нотификации | `apps/api/src/modules/notifications/notifications.service.ts` (правила expiry) + `notifications.module.ts` (cron-расписание) |
| Управление складом | `apps/api/src/modules/inventory/` + `apps/web/src/pages/inventory/` |
| Аудит-логи | таблица `AuditLog`, доступ через `AuditService` |
| Reference-справочники | `apps/api/src/modules/reference/reference.controller.ts` |
---
## 13. Полезные команды
```bash
# Открыть Prisma Studio (визуальный БД-инспектор)
pnpm db:studio
# Создать миграцию после изменения schema.prisma
pnpm --filter api exec prisma migrate dev --name <name>
# Перегенерировать Prisma Client
pnpm --filter api exec prisma generate
# Type check всего монорепо
pnpm -r typecheck
# Сбросить БД (только в dev!)
pnpm --filter api exec prisma migrate reset
```