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>
This commit is contained in:
Danil Suhomlinov
2026-06-08 17:42:45 +03:00
commit 33800292aa
186 changed files with 30437 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
**/node_modules
**/dist
**/.vite
**/.turbo
.git
.gitignore
*.log
**/.env
**/.env.*
+26
View File
@@ -0,0 +1,26 @@
# PostgreSQL
DATABASE_URL="postgresql://hrm:hrm_password@localhost:5432/hrm_medpark?schema=public"
# Keycloak
KEYCLOAK_URL="http://localhost:8080"
KEYCLOAK_REALM="medpark"
KEYCLOAK_CLIENT_ID="hrm-api"
KEYCLOAK_CLIENT_SECRET="change-me"
# Redis (BullMQ)
REDIS_HOST="localhost"
REDIS_PORT=6379
# MinIO (S3)
MINIO_ENDPOINT="localhost"
MINIO_PORT=9000
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET="hrm-docs"
# n8n webhook base URL
N8N_WEBHOOK_BASE="http://localhost:5678/webhook"
# App
PORT=3001
NODE_ENV="development"
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings. Keep LF for files consumed inside Linux containers.
* text=auto
Dockerfile text eol=lf
*.sh text eol=lf
*.conf text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
+44
View File
@@ -0,0 +1,44 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
build/
apps/web/dist/
apps/api/dist/
*.tsbuildinfo
# Vite / caches
.vite/
**/.vite/
.turbo/
# Secrets — never commit. Keep only *.example
.env
.env.*
!.env.example
apps/api/.env
apps/api/.env.*
!apps/api/.env.example
apps/web/.env
apps/web/.env.*
!apps/web/.env.example
# Runtime data / uploads
storage/
apps/api/storage/
*.sqlite
*.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
+502
View File
@@ -0,0 +1,502 @@
# 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
```
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
# Build context = monorepo root (hrm-medpark/)
FROM node:20-bookworm-slim AS base
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable && apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /repo
# ---- install + build ----
FROM base AS build
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile
COPY apps/api apps/api
RUN pnpm --filter api exec prisma generate && pnpm --filter api build
# ---- runtime ----
FROM base AS runtime
ENV NODE_ENV=production
WORKDIR /repo
# pnpm keeps the package store at the repo-root node_modules/.pnpm; copy both trees so symlinks resolve
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/apps/api ./apps/api
WORKDIR /repo/apps/api
EXPOSE 3001
# apply pending migrations, then start; falls back to start if migrate has nothing to do
CMD ["sh", "-c", "pnpm exec prisma migrate deploy && node dist/main"]
+15
View File
@@ -0,0 +1,15 @@
{
"errors": {
"not_found": "Resource with id={id} not found",
"conflict_idnp": "IDNP {idnp} already exists in the system",
"idnp_invalid": "Invalid IDNP (13 digits, wrong check digit)",
"recomandare_sot": "Cannot select the employee's spouse as an internal recommendation",
"unauthorized": "Unauthorized",
"forbidden": "Insufficient permissions"
},
"validation": {
"required": "Required field",
"email": "Invalid email address",
"phone": "Invalid phone number"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"errors": {
"not_found": "Resursa cu id={id} nu a fost găsită",
"conflict_idnp": "IDNP {idnp} există deja în sistem",
"idnp_invalid": "IDNP invalid (13 cifre, cifra de control incorectă)",
"recomandare_sot": "Nu se poate selecta soțul/soția angajatului ca recomandare internă",
"unauthorized": "Acces neautorizat",
"forbidden": "Nu aveți permisiuni suficiente"
},
"validation": {
"required": "Câmp obligatoriu",
"email": "Adresă email invalidă",
"phone": "Număr de telefon invalid"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"errors": {
"not_found": "Ресурс с id={id} не найден",
"conflict_idnp": "IDNP {idnp} уже существует в системе",
"idnp_invalid": "Некорректный IDNP (13 цифр, неверная контрольная цифра)",
"recomandare_sot": "Нельзя выбрать супруга сотрудника в качестве внутренней рекомендации",
"unauthorized": "Не авторизован",
"forbidden": "Недостаточно прав"
},
"validation": {
"required": "Обязательное поле",
"email": "Некорректный email",
"phone": "Некорректный номер телефона"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "api",
"version": "0.1.0",
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts",
"docx:stubs": "ts-node scripts/generate-docx-stubs.ts",
"testdb:run": "ts-node scripts/test-db.ts run",
"testdb:verify": "ts-node scripts/verify-functionality.ts",
"testdb:drop": "ts-node scripts/test-db.ts drop"
},
"dependencies": {
"@nestjs/axios": "^3.0.2",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.0",
"@nestjs/throttler": "^5.1.1",
"@prisma/client": "^5.11.0",
"argon2": "^0.40.1",
"axios": "^1.6.8",
"bull": "^4.12.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"docx": "^9.6.1",
"docxtemplater": "^3.47.0",
"exceljs": "^4.4.0",
"jwks-rsa": "^3.2.2",
"minio": "^8.0.0",
"nestjs-i18n": "^10.4.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pizzip": "^3.1.4",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.1",
"prisma": "^5.11.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"
}
}
@@ -0,0 +1,578 @@
-- CreateEnum
CREATE TYPE "Sex" AS ENUM ('F', 'M');
-- CreateEnum
CREATE TYPE "MaritalStatus" AS ENUM ('casatorit', 'necasatorit', 'divortat', 'vaduv');
-- CreateEnum
CREATE TYPE "EmployeeStatus" AS ENUM ('activ', 'concediat', 'suspendat');
-- CreateEnum
CREATE TYPE "DocumentType" AS ENUM ('buletin_de_identitate', 'pasaport');
-- CreateEnum
CREATE TYPE "FamilyMemberType" AS ENUM ('contact_principal', 'sot', 'sotie', 'mama', 'tata', 'copil');
-- CreateEnum
CREATE TYPE "StudyType" AS ENUM ('superioare', 'medii_de_specialitate', 'secundare_tehnice', 'medii');
-- CreateEnum
CREATE TYPE "StudyLevel" AS ENUM ('de_baza', 'postuniversitar');
-- CreateEnum
CREATE TYPE "PostUniversityType" AS ENUM ('masterat', 'rezidentiat', 'secundariat', 'altele');
-- CreateEnum
CREATE TYPE "DiplomaStatus" AS ENUM ('confirmata', 'neconfirmata');
-- CreateEnum
CREATE TYPE "QualificationCategory" AS ENUM ('fara', 'cat_II', 'cat_I', 'superioara');
-- CreateEnum
CREATE TYPE "ScientificTitle" AS ENUM ('doctor', 'doctor_habilitat');
-- CreateEnum
CREATE TYPE "TrainingType" AS ENUM ('orientare', 'intern', 'extern_RM', 'extern_international');
-- CreateEnum
CREATE TYPE "DisciplinarySanctionType" AS ENUM ('avertisment', 'mustrare', 'mustrare_aspra');
-- CreateEnum
CREATE TYPE "ContractPeriod" AS ENUM ('determinata', 'nedeterminata', 'replasare_temporara');
-- CreateEnum
CREATE TYPE "ContractCategory" AS ENUM ('principal', 'secundar');
-- CreateEnum
CREATE TYPE "ContractType" AS ENUM ('de_baza', 'cumul');
-- CreateEnum
CREATE TYPE "SalaryType" AS ENUM ('fix', 'pe_ore', 'in_acord');
-- CreateEnum
CREATE TYPE "CampaignStatus" AS ENUM ('draft', 'scheduled', 'in_progress', 'closed');
-- CreateEnum
CREATE TYPE "EvaluationScore" AS ENUM ('slab', 'mediu', 'bine');
-- CreateEnum
CREATE TYPE "ProposedCategory" AS ENUM ('fara', 'cat_II', 'cat_I', 'superioara');
-- CreateEnum
CREATE TYPE "MedicalCheckupType" AS ENUM ('la_angajare', 'periodic', 'la_reluarea_activitatii', 'la_incetarea_expunerii', 'suplimentar');
-- CreateEnum
CREATE TYPE "MedicalVerdict" AS ENUM ('apt', 'apt_perioada_adaptare', 'apt_conditionat', 'inapt_temporar', 'inapt');
-- CreateTable
CREATE TABLE "disability_grades" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "disability_grades_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tax_exemptions" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "tax_exemptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "work_schedules" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"daysWork" INTEGER NOT NULL,
"daysRest" INTEGER NOT NULL,
"hoursPerDay" INTEGER NOT NULL,
CONSTRAINT "work_schedules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "departments" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"parentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "departments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employees" (
"id" TEXT NOT NULL,
"idnp" VARCHAR(13) NOT NULL,
"nume" TEXT NOT NULL,
"prenume" TEXT NOT NULL,
"patronimic" TEXT,
"numeAnterior" TEXT,
"dataNasterii" DATE NOT NULL,
"domiciliu" TEXT NOT NULL,
"adresaReala" TEXT,
"telefonPersonal" TEXT NOT NULL,
"telefonServiciu" TEXT,
"emailPersonal" TEXT,
"emailCorporativ" TEXT,
"sex" "Sex" NOT NULL,
"codCpas" TEXT,
"stareCivila" "MaritalStatus",
"titluStiintific" "ScientificTitle",
"titluUniversitar" TEXT,
"status" "EmployeeStatus" NOT NULL DEFAULT 'activ',
"gradDizabilitateId" TEXT,
"recomandareInternaId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employees_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "identity_documents" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"tipAct" "DocumentType" NOT NULL,
"seria" TEXT,
"nr" TEXT NOT NULL,
"dataEmiterii" DATE NOT NULL,
"autoritateEmitenta" TEXT NOT NULL,
"dataExpirarii" DATE NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "identity_documents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "family_members" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"tip" "FamilyMemberType" NOT NULL,
"numePrenume" TEXT NOT NULL,
"dataNasterii" DATE,
"idnp" VARCHAR(13),
"telefon" TEXT,
"tipScutireId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "family_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "educations" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"tipStudii" "StudyType" NOT NULL,
"institutia" TEXT NOT NULL,
"specialitatea" TEXT NOT NULL,
"dataAbsolvirii" DATE,
"nrSeriaDiploma" TEXT,
"dataEmiterii" DATE,
"nrInregistrare" TEXT,
"confirmare" "DiplomaStatus",
"nivel" "StudyLevel",
"tipPostuniversitar" "PostUniversityType",
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "educations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "qualifications" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"categorie" "QualificationCategory" NOT NULL,
"dataObtinerii" DATE,
"dataUltimeiConfirmari" DATE,
"dataExpirarii" DATE,
"specialitate" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "qualifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "trainings" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"denumire" TEXT NOT NULL,
"inceput" DATE NOT NULL,
"sfirsit" DATE,
"tip" "TrainingType" NOT NULL,
"tara" TEXT,
"nrOre" INTEGER,
"organizatia" TEXT,
"certificat" BOOLEAN NOT NULL DEFAULT false,
"cost" DECIMAL(10,2),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "trainings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "disciplinary_sanctions" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"tip" "DisciplinarySanctionType" NOT NULL,
"dataAplicarii" DATE NOT NULL,
"dataExpirarii" DATE NOT NULL,
"isStinsa" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "disciplinary_sanctions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "benefits" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"uniformaId" TEXT,
"halatId" TEXT,
"ciupiciId" TEXT,
"vestaId" TEXT,
"ticheteMasa" BOOLEAN NOT NULL DEFAULT false,
"valoareTichet" DECIMAL(10,2),
"alimentatiePersonal" BOOLEAN NOT NULL DEFAULT false,
"abonamentTel" DECIMAL(10,2),
"aparatTelefonId" TEXT,
"cardCompanie" TEXT,
"automobilServiciu" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "benefits_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employment_contracts" (
"id" TEXT NOT NULL,
"nrCim" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"categorie" "ContractCategory" NOT NULL,
"dataSemnarii" DATE NOT NULL,
"dataAngajarii" DATE NOT NULL,
"dataDemisiei" DATE,
"perioada" "ContractPeriod" NOT NULL,
"dataTerminarii" DATE,
"functiaClasificator" TEXT,
"codFunctie" TEXT,
"functiaOrganigrama" TEXT,
"tipCim" "ContractType" NOT NULL,
"departmentId" TEXT NOT NULL,
"regimMunca" TEXT,
"tipSalarizare" "SalaryType",
"salarizareDetails" JSONB,
"clausaAditionala" JSONB,
"workScheduleId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employment_contracts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cim_service_categories" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"categorieId" TEXT NOT NULL,
"tipRemunerare" TEXT NOT NULL,
"sumaNeta" DECIMAL(10,2),
"procent" DECIMAL(5,2),
CONSTRAINT "cim_service_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evaluation_campaigns" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"month" DATE NOT NULL,
"status" "CampaignStatus" NOT NULL DEFAULT 'draft',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "evaluation_campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evaluation_forms" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"abilitatiClinice" "EvaluationScore",
"judecataClinica" "EvaluationScore",
"manopere" "EvaluationScore",
"gestionareaSarcinilor" "EvaluationScore",
"constiintaProfesionala" "EvaluationScore",
"atitudineaPacienti" "EvaluationScore",
"atitudineaColegi" "EvaluationScore",
"atitudineaPersonalNonMed" "EvaluationScore",
"utilizareSmartphone" "EvaluationScore",
"respectareaProgramului" "EvaluationScore",
"respectareaDressCode" "EvaluationScore",
"testJci" JSONB,
"completareaDocMed" BOOLEAN,
"perfectioneazaCunostinte" BOOLEAN,
"membruComitetCalitate" BOOLEAN,
"functieDeMonitor" BOOLEAN,
"inlocuiesteSuperiorul" BOOLEAN,
"categorieCalculata" "ProposedCategory",
"categorieAprobata" "ProposedCategory",
"observatii" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "evaluation_forms_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workplace_risk_cards" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"riskFactors" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "workplace_risk_cards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_medical_profiles" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"ocupatieCorm" TEXT,
"workplaceRiskCardId" TEXT,
"dataUltimControlMedical" DATE,
"expusRadiatiiIonizante" BOOLEAN NOT NULL DEFAULT false,
"dataIntrarii" DATE,
"expunereAnterioaraPerioda" TEXT,
"expunereAnterioaraAni" INTEGER,
"dozaCumulataExternaMsv" DECIMAL(10,4),
"dozaCumulataInternaMsv" DECIMAL(10,4),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employee_medical_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "medical_checkups" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"tip" "MedicalCheckupType" NOT NULL,
"dataPlanificata" DATE NOT NULL,
"dataEfectuata" DATE,
"verdict" "MedicalVerdict",
"recomandari" TEXT,
"documenteGenerate" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "medical_checkups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" BIGSERIAL NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"userRole" VARCHAR(50) NOT NULL,
"ip" VARCHAR(45),
"action" VARCHAR(20) NOT NULL,
"entity" VARCHAR(50) NOT NULL,
"entityId" VARCHAR(50) NOT NULL,
"field" VARCHAR(100),
"oldValue" TEXT,
"newValue" TEXT,
"reason" TEXT,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "disability_grades_code_key" ON "disability_grades"("code");
-- CreateIndex
CREATE UNIQUE INDEX "tax_exemptions_code_key" ON "tax_exemptions"("code");
-- CreateIndex
CREATE UNIQUE INDEX "work_schedules_name_key" ON "work_schedules"("name");
-- CreateIndex
CREATE UNIQUE INDEX "departments_code_key" ON "departments"("code");
-- CreateIndex
CREATE INDEX "departments_parentId_idx" ON "departments"("parentId");
-- CreateIndex
CREATE UNIQUE INDEX "employees_idnp_key" ON "employees"("idnp");
-- CreateIndex
CREATE INDEX "employees_idnp_idx" ON "employees"("idnp");
-- CreateIndex
CREATE INDEX "employees_nume_prenume_idx" ON "employees"("nume", "prenume");
-- CreateIndex
CREATE INDEX "employees_status_idx" ON "employees"("status");
-- CreateIndex
CREATE INDEX "employees_dataNasterii_idx" ON "employees"("dataNasterii");
-- CreateIndex
CREATE INDEX "identity_documents_employeeId_idx" ON "identity_documents"("employeeId");
-- CreateIndex
CREATE INDEX "identity_documents_dataExpirarii_idx" ON "identity_documents"("dataExpirarii");
-- CreateIndex
CREATE INDEX "family_members_employeeId_idx" ON "family_members"("employeeId");
-- CreateIndex
CREATE INDEX "educations_employeeId_idx" ON "educations"("employeeId");
-- CreateIndex
CREATE INDEX "qualifications_employeeId_idx" ON "qualifications"("employeeId");
-- CreateIndex
CREATE INDEX "qualifications_dataExpirarii_idx" ON "qualifications"("dataExpirarii");
-- CreateIndex
CREATE INDEX "trainings_employeeId_idx" ON "trainings"("employeeId");
-- CreateIndex
CREATE INDEX "disciplinary_sanctions_employeeId_idx" ON "disciplinary_sanctions"("employeeId");
-- CreateIndex
CREATE INDEX "disciplinary_sanctions_dataExpirarii_idx" ON "disciplinary_sanctions"("dataExpirarii");
-- CreateIndex
CREATE UNIQUE INDEX "benefits_employeeId_key" ON "benefits"("employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "employment_contracts_nrCim_key" ON "employment_contracts"("nrCim");
-- CreateIndex
CREATE INDEX "employment_contracts_employeeId_idx" ON "employment_contracts"("employeeId");
-- CreateIndex
CREATE INDEX "employment_contracts_departmentId_idx" ON "employment_contracts"("departmentId");
-- CreateIndex
CREATE INDEX "employment_contracts_dataDemisiei_idx" ON "employment_contracts"("dataDemisiei");
-- CreateIndex
CREATE INDEX "cim_service_categories_contractId_idx" ON "cim_service_categories"("contractId");
-- CreateIndex
CREATE INDEX "evaluation_campaigns_departmentId_idx" ON "evaluation_campaigns"("departmentId");
-- CreateIndex
CREATE INDEX "evaluation_campaigns_month_idx" ON "evaluation_campaigns"("month");
-- CreateIndex
CREATE INDEX "evaluation_forms_campaignId_idx" ON "evaluation_forms"("campaignId");
-- CreateIndex
CREATE INDEX "evaluation_forms_employeeId_idx" ON "evaluation_forms"("employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "evaluation_forms_campaignId_employeeId_key" ON "evaluation_forms"("campaignId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "workplace_risk_cards_name_key" ON "workplace_risk_cards"("name");
-- CreateIndex
CREATE UNIQUE INDEX "employee_medical_profiles_employeeId_key" ON "employee_medical_profiles"("employeeId");
-- CreateIndex
CREATE INDEX "medical_checkups_employeeId_idx" ON "medical_checkups"("employeeId");
-- CreateIndex
CREATE INDEX "medical_checkups_dataPlanificata_idx" ON "medical_checkups"("dataPlanificata");
-- CreateIndex
CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId");
-- CreateIndex
CREATE INDEX "audit_logs_entity_entityId_idx" ON "audit_logs"("entity", "entityId");
-- CreateIndex
CREATE INDEX "audit_logs_ts_idx" ON "audit_logs"("ts");
-- AddForeignKey
ALTER TABLE "departments" ADD CONSTRAINT "departments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employees" ADD CONSTRAINT "employees_gradDizabilitateId_fkey" FOREIGN KEY ("gradDizabilitateId") REFERENCES "disability_grades"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employees" ADD CONSTRAINT "employees_recomandareInternaId_fkey" FOREIGN KEY ("recomandareInternaId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "identity_documents" ADD CONSTRAINT "identity_documents_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "family_members" ADD CONSTRAINT "family_members_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "family_members" ADD CONSTRAINT "family_members_tipScutireId_fkey" FOREIGN KEY ("tipScutireId") REFERENCES "tax_exemptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "educations" ADD CONSTRAINT "educations_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifications" ADD CONSTRAINT "qualifications_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "trainings" ADD CONSTRAINT "trainings_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "disciplinary_sanctions" ADD CONSTRAINT "disciplinary_sanctions_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_workScheduleId_fkey" FOREIGN KEY ("workScheduleId") REFERENCES "work_schedules"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cim_service_categories" ADD CONSTRAINT "cim_service_categories_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "employment_contracts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evaluation_campaigns" ADD CONSTRAINT "evaluation_campaigns_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evaluation_forms" ADD CONSTRAINT "evaluation_forms_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "evaluation_campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evaluation_forms" ADD CONSTRAINT "evaluation_forms_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_medical_profiles" ADD CONSTRAINT "employee_medical_profiles_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_medical_profiles" ADD CONSTRAINT "employee_medical_profiles_workplaceRiskCardId_fkey" FOREIGN KEY ("workplaceRiskCardId") REFERENCES "workplace_risk_cards"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "medical_checkups" ADD CONSTRAINT "medical_checkups_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "AnexaType" AS ENUM ('ANEXA_3', 'ANEXA_4', 'ANEXA_4B', 'ANEXA_6');
-- CreateTable
CREATE TABLE "anexa_templates" (
"id" TEXT NOT NULL,
"type" "AnexaType" NOT NULL,
"name" TEXT NOT NULL,
"contentJson" JSONB NOT NULL,
"updatedById" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "anexa_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "anexa_template_versions" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"contentJson" JSONB NOT NULL,
"savedById" TEXT NOT NULL,
"savedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"label" TEXT,
CONSTRAINT "anexa_template_versions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "anexa_templates_type_key" ON "anexa_templates"("type");
-- CreateIndex
CREATE INDEX "anexa_template_versions_templateId_idx" ON "anexa_template_versions"("templateId");
-- AddForeignKey
ALTER TABLE "anexa_template_versions" ADD CONSTRAINT "anexa_template_versions_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "anexa_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "InventoryItemType" AS ENUM ('uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte');
-- CreateTable
CREATE TABLE "inventory_items" (
"id" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "InventoryItemType" NOT NULL,
"size" TEXT,
"color" TEXT,
"pricePerUnit" DECIMAL(10,2),
"stockQty" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku");
-- CreateIndex
CREATE INDEX "inventory_items_type_active_idx" ON "inventory_items"("type", "active");
-- Reset existing free-text IDs in benefits (no FK referenced anything real before)
UPDATE "benefits" SET "uniformaId" = NULL WHERE "uniformaId" IS NOT NULL;
UPDATE "benefits" SET "halatId" = NULL WHERE "halatId" IS NOT NULL;
UPDATE "benefits" SET "ciupiciId" = NULL WHERE "ciupiciId" IS NOT NULL;
UPDATE "benefits" SET "vestaId" = NULL WHERE "vestaId" IS NOT NULL;
UPDATE "benefits" SET "aparatTelefonId" = NULL WHERE "aparatTelefonId" IS NOT NULL;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_uniformaId_fkey" FOREIGN KEY ("uniformaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_halatId_fkey" FOREIGN KEY ("halatId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_ciupiciId_fkey" FOREIGN KEY ("ciupiciId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_vestaId_fkey" FOREIGN KEY ("vestaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_aparatTelefonId_fkey" FOREIGN KEY ("aparatTelefonId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,52 @@
-- CreateEnum
CREATE TYPE "RiskExposureType" AS ENUM ('AGENT_CHIMIC', 'PULBERI', 'AGENT_BIOLOGIC', 'ZGOMOT', 'VIBRATII', 'CAMP_ELECTROMAGNETIC', 'RADIATII_OPTICE');
-- AlterTable
ALTER TABLE "workplace_risk_cards" ADD COLUMN "adresaFiliala" TEXT,
ADD COLUMN "anexeIgienicoSanitare" JSONB,
ADD COLUMN "caemDiviziune" TEXT,
ADD COLUMN "caemPrimeleDouaCifre" TEXT,
ADD COLUMN "clasaConditiilorDeMunca" TEXT,
ADD COLUMN "cormSubgrupaMajora" TEXT,
ADD COLUMN "directiaSectiaSectorul" TEXT,
ADD COLUMN "echipamentLucru" TEXT,
ADD COLUMN "evaluareDetalii" JSONB,
ADD COLUMN "filiala" TEXT,
ADD COLUMN "mijloaceProtectieColectiva" TEXT,
ADD COLUMN "mijloaceProtectieIndividuala" TEXT,
ADD COLUMN "numarLucratoriPosibili" INTEGER,
ADD COLUMN "numarulLoculuiDeMunca" TEXT,
ADD COLUMN "observatii" TEXT,
ADD COLUMN "radiatiiAparatura" TEXT,
ADD COLUMN "radiatiiGrupa" TEXT,
ADD COLUMN "radiatiiIonizante" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "radiatiiMasuriProtectie" TEXT,
ADD COLUMN "radiatiiSurse" TEXT,
ADD COLUMN "radiatiiTipExpunere" TEXT,
ALTER COLUMN "riskFactors" DROP NOT NULL;
-- CreateTable
CREATE TABLE "workplace_risk_exposures" (
"id" TEXT NOT NULL,
"cardId" TEXT NOT NULL,
"tip" "RiskExposureType" NOT NULL,
"denumire" TEXT NOT NULL,
"cas" TEXT,
"einecs" TEXT,
"clasificare" TEXT,
"zonaAfectata" TEXT,
"timpExpunere" TEXT,
"vep" TEXT,
"vlep" TEXT,
"caracteristici" TEXT,
"procesVerbal" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workplace_risk_exposures_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "workplace_risk_exposures_cardId_idx" ON "workplace_risk_exposures"("cardId");
-- AddForeignKey
ALTER TABLE "workplace_risk_exposures" ADD CONSTRAINT "workplace_risk_exposures_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "workplace_risk_cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- AlterEnum
ALTER TYPE "AnexaType" ADD VALUE 'ANEXA_4A';
-- CreateEnum
CREATE TYPE "OverexposureKind" AS ENUM ('EXCEPTIONALA', 'ACCIDENTALA');
-- AlterTable
ALTER TABLE "workplace_risk_cards" ADD COLUMN "tipFisa" TEXT NOT NULL DEFAULT 'STANDARD';
-- CreateTable
CREATE TABLE "radiation_overexposures" (
"id" TEXT NOT NULL,
"medicalProfileId" TEXT NOT NULL,
"fel" "OverexposureKind" NOT NULL,
"tipExpunere" TEXT,
"data" DATE,
"dozaMsv" DECIMAL(10,4),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "radiation_overexposures_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "radiation_overexposures_medicalProfileId_idx" ON "radiation_overexposures"("medicalProfileId");
-- AddForeignKey
ALTER TABLE "radiation_overexposures" ADD CONSTRAINT "radiation_overexposures_medicalProfileId_fkey" FOREIGN KEY ("medicalProfileId") REFERENCES "employee_medical_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
ALTER TABLE "workplace_risk_cards"
ADD COLUMN "telefonFiliala" TEXT;
ALTER TABLE "medical_checkups"
ADD COLUMN "valabilPanaLa" DATE,
ADD COLUMN "semnatDe" TEXT;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+829
View File
@@ -0,0 +1,829 @@
// HRM Medpark — Prisma Schema
// Phase 1: Employee Master Data + Department + AuditLog
// Phase 2 stubs: EmploymentContract
// Phase 4 stubs: EvaluationCampaign, EvaluationForm
// Phase 5 stubs: WorkplaceRiskCard, EmployeeMedicalProfile, MedicalCheckup
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ═══════════════════════════════════════════════════════════════
// ENUMS
// ═══════════════════════════════════════════════════════════════
enum Sex {
F
M
}
enum MaritalStatus {
casatorit
necasatorit
divortat
vaduv
}
enum EmployeeStatus {
activ
concediat
suspendat
}
enum DocumentType {
buletin_de_identitate
pasaport
}
enum FamilyMemberType {
contact_principal
sot
sotie
mama
tata
copil
}
enum StudyType {
superioare
medii_de_specialitate
secundare_tehnice
medii
}
enum StudyLevel {
de_baza
postuniversitar
}
enum PostUniversityType {
masterat
rezidentiat
secundariat
altele
}
enum DiplomaStatus {
confirmata
neconfirmata
}
enum QualificationCategory {
fara
cat_II
cat_I
superioara
}
enum ScientificTitle {
doctor
doctor_habilitat
}
enum TrainingType {
orientare
intern
extern_RM
extern_international
}
enum DisciplinarySanctionType {
avertisment
mustrare
mustrare_aspra
}
// Phase 2
enum ContractPeriod {
determinata
nedeterminata
replasare_temporara
}
enum ContractCategory {
principal
secundar
}
enum ContractType {
de_baza
cumul
}
enum SalaryType {
fix
pe_ore
in_acord
}
// Phase 4
enum CampaignStatus {
draft
scheduled
in_progress
closed
}
enum EvaluationScore {
slab
mediu
bine
}
enum ProposedCategory {
fara
cat_II
cat_I
superioara
}
// Phase 5
enum MedicalCheckupType {
la_angajare
periodic
la_reluarea_activitatii
la_incetarea_expunerii
suplimentar
}
enum MedicalVerdict {
apt
apt_perioada_adaptare
apt_conditionat
inapt_temporar
inapt
}
enum AnexaType {
ANEXA_3
ANEXA_4
ANEXA_4A
ANEXA_4B
ANEXA_6
}
// Tipuri de factori cu tabel de expunere în Anexa 4 (NU-10-MS-2026)
enum RiskExposureType {
AGENT_CHIMIC
PULBERI
AGENT_BIOLOGIC
ZGOMOT
VIBRATII
CAMP_ELECTROMAGNETIC
RADIATII_OPTICE
}
// Tipul supraexpunerii la radiații ionizante (Anexa 4B)
enum OverexposureKind {
EXCEPTIONALA
ACCIDENTALA
}
// ═══════════════════════════════════════════════════════════════
// СПРАВОЧНИКИ
// ═══════════════════════════════════════════════════════════════
model DisabilityGrade {
id String @id @default(uuid())
code String @unique
name String
employees Employee[]
@@map("disability_grades")
}
model TaxExemption {
id String @id @default(uuid())
code String @unique
description String
familyMembers FamilyMember[]
@@map("tax_exemptions")
}
model WorkSchedule {
id String @id @default(uuid())
name String @unique // "5/2 8h", "7/7 12h"
daysWork Int
daysRest Int
hoursPerDay Int
contracts EmploymentContract[]
@@map("work_schedules")
}
// ═══════════════════════════════════════════════════════════════
// DEPARTMENT — иерархия (adjacency list)
// ═══════════════════════════════════════════════════════════════
model Department {
id String @id @default(uuid())
name String
code String? @unique
parentId String?
parent Department? @relation("DeptTree", fields: [parentId], references: [id])
children Department[] @relation("DeptTree")
contracts EmploymentContract[]
campaigns EvaluationCampaign[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
@@map("departments")
}
// ═══════════════════════════════════════════════════════════════
// EMPLOYEE — ядро системы
// ═══════════════════════════════════════════════════════════════
model Employee {
id String @id @default(uuid())
// IDNP — 13 цифр, алгоритм контрольной суммы валидируется на app-уровне
idnp String @unique @db.VarChar(13)
// A. Личная информация
nume String
prenume String
patronimic String?
numeAnterior String?
dataNasterii DateTime @db.Date
domiciliu String
adresaReala String?
telefonPersonal String
telefonServiciu String?
emailPersonal String?
emailCorporativ String?
sex Sex
codCpas String?
stareCivila MaritalStatus?
// Научное/университетское звание (уровень Employee, не Qualification)
titluStiintific ScientificTitle?
titluUniversitar String?
status EmployeeStatus @default(activ)
gradDizabilitateId String?
gradDizabilitate DisabilityGrade? @relation(fields: [gradDizabilitateId], references: [id])
// Кто рекомендовал (самоссылка)
// Бизнес-правило: нельзя выбрать супруга текущего сотрудника — проверка на service-уровне
recomandareInternaId String?
recomandareInterna Employee? @relation("Recomandari", fields: [recomandareInternaId], references: [id])
recomandat Employee[] @relation("Recomandari")
// Связанные сущности
identityDocuments IdentityDocument[]
familyMembers FamilyMember[]
educations Education[]
qualifications Qualification[]
trainings Training[]
disciplinarySanctions DisciplinarySanction[]
contracts EmploymentContract[]
benefit Benefit?
evaluationForms EvaluationForm[]
medicalProfile EmployeeMedicalProfile?
medicalCheckups MedicalCheckup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([idnp])
@@index([nume, prenume])
@@index([status])
@@index([dataNasterii])
@@map("employees")
}
// ═══════════════════════════════════════════════════════════════
// B. IDENTITY DOCUMENT
// ═══════════════════════════════════════════════════════════════
model IdentityDocument {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
tipAct DocumentType
seria String?
nr String
dataEmiterii DateTime @db.Date
autoritateEmitenta String
dataExpirarii DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Cron-задача за 30 дней до dataExpirarii → HR Inbox
@@index([employeeId])
@@index([dataExpirarii])
@@map("identity_documents")
}
// ═══════════════════════════════════════════════════════════════
// C. FAMILY MEMBERS
// ═══════════════════════════════════════════════════════════════
model FamilyMember {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
tip FamilyMemberType
numePrenume String
dataNasterii DateTime? @db.Date
idnp String? @db.VarChar(13)
telefon String? // обязателен для contact_principal — проверка на service-уровне
// Скидки FISC (только для copil)
tipScutireId String?
tipScutire TaxExemption? @relation(fields: [tipScutireId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@map("family_members")
}
// ═══════════════════════════════════════════════════════════════
// D. EDUCATION
// ═══════════════════════════════════════════════════════════════
model Education {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
tipStudii StudyType
institutia String
specialitatea String
dataAbsolvirii DateTime? @db.Date
nrSeriaDiploma String?
dataEmiterii DateTime? @db.Date
nrInregistrare String?
confirmare DiplomaStatus?
nivel StudyLevel?
tipPostuniversitar PostUniversityType?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@map("educations")
}
// ═══════════════════════════════════════════════════════════════
// E. QUALIFICATIONS
// ═══════════════════════════════════════════════════════════════
model Qualification {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
categorie QualificationCategory
dataObtinerii DateTime? @db.Date
dataUltimeiConfirmari DateTime? @db.Date
dataExpirarii DateTime? @db.Date
specialitate String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Cron-задача за 90/30/7 дней до dataExpirarii → HR + manager
@@index([employeeId])
@@index([dataExpirarii])
@@map("qualifications")
}
// ═══════════════════════════════════════════════════════════════
// F. TRAINING
// ═══════════════════════════════════════════════════════════════
model Training {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
denumire String
inceput DateTime @db.Date
sfirsit DateTime? @db.Date
tip TrainingType
tara String?
nrOre Int?
organizatia String?
certificat Boolean @default(false)
cost Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@map("trainings")
}
// ═══════════════════════════════════════════════════════════════
// G. DISCIPLINARY SANCTIONS
// ═══════════════════════════════════════════════════════════════
model DisciplinarySanction {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
tip DisciplinarySanctionType
dataAplicarii DateTime @db.Date
// auto-calc: dataAplicarii + 6 months — вычисляется на service-уровне при создании
dataExpirarii DateTime @db.Date
// set true cron-ом после dataExpirarii; до этого — активна при расчёте performance
isStinsa Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([dataExpirarii])
@@map("disciplinary_sanctions")
}
// ═══════════════════════════════════════════════════════════════
// H. BENEFITS
// ═══════════════════════════════════════════════════════════════
model Benefit {
id String @id @default(uuid())
employeeId String @unique
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
uniformaId String?
uniforma InventoryItem? @relation("BenefitUniforma", fields: [uniformaId], references: [id])
halatId String?
halat InventoryItem? @relation("BenefitHalat", fields: [halatId], references: [id])
ciupiciId String?
ciupici InventoryItem? @relation("BenefitCiupici", fields: [ciupiciId], references: [id])
vestaId String?
vesta InventoryItem? @relation("BenefitVesta", fields: [vestaId], references: [id])
ticheteMasa Boolean @default(false)
valoareTichet Decimal? @db.Decimal(10, 2)
alimentatiePersonal Boolean @default(false)
abonamentTel Decimal? @db.Decimal(10, 2)
aparatTelefonId String?
aparatTelefon InventoryItem? @relation("BenefitAparatTel", fields: [aparatTelefonId], references: [id])
cardCompanie String?
automobilServiciu String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("benefits")
}
enum InventoryItemType {
uniforma
halat
ciupici
vesta
aparat_telefon
alte
}
model InventoryItem {
id String @id @default(uuid())
sku String @unique
name String
type InventoryItemType
size String?
color String?
pricePerUnit Decimal? @db.Decimal(10, 2)
stockQty Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
uniformaBenefits Benefit[] @relation("BenefitUniforma")
halatBenefits Benefit[] @relation("BenefitHalat")
ciupiciBenefits Benefit[] @relation("BenefitCiupici")
vestaBenefits Benefit[] @relation("BenefitVesta")
aparatTelBenefits Benefit[] @relation("BenefitAparatTel")
@@index([type, active])
@@map("inventory_items")
}
// ═══════════════════════════════════════════════════════════════
// PHASE 2 STUB: EMPLOYMENT CONTRACT
// ═══════════════════════════════════════════════════════════════
model EmploymentContract {
id String @id @default(uuid())
nrCim String @unique
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
categorie ContractCategory
dataSemnarii DateTime @db.Date
dataAngajarii DateTime @db.Date
dataDemisiei DateTime? @db.Date
perioada ContractPeriod
dataTerminarii DateTime? @db.Date
functiaClasificator String? // CORM код
codFunctie String?
functiaOrganigrama String?
tipCim ContractType
departmentId String
department Department @relation(fields: [departmentId], references: [id])
regimMunca String?
tipSalarizare SalaryType?
// Условные поля salariu_fix / pe_ore / in_acord хранятся как JSONB
salarizareDetails Json?
clausaAditionala Json?
workScheduleId String?
workSchedule WorkSchedule? @relation(fields: [workScheduleId], references: [id])
categoriiServicii CimServiceCategory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Бизнес-правило: zile_concediu = MAX среди всех CIM сотрудника — проверка на service-уровне
@@index([employeeId])
@@index([departmentId])
@@index([dataDemisiei])
@@map("employment_contracts")
}
model CimServiceCategory {
id String @id @default(uuid())
contractId String
contract EmploymentContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
categorieId String
tipRemunerare String // 'tarif' | 'procent'
sumaNeta Decimal? @db.Decimal(10, 2)
procent Decimal? @db.Decimal(5, 2)
@@index([contractId])
@@map("cim_service_categories")
}
// ═══════════════════════════════════════════════════════════════
// PHASE 4 STUB: PERFORMANCE EVALUATION
// ═══════════════════════════════════════════════════════════════
model EvaluationCampaign {
id String @id @default(uuid())
name String
departmentId String
department Department @relation(fields: [departmentId], references: [id])
month DateTime @db.Date // первый день месяца кампании
status CampaignStatus @default(draft)
forms EvaluationForm[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([departmentId])
@@index([month])
@@map("evaluation_campaigns")
}
model EvaluationForm {
id String @id @default(uuid())
campaignId String
campaign EvaluationCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
// A. Competente clinice (slab/mediu/bine)
abilitatiClinice EvaluationScore?
judecataClinica EvaluationScore?
manopere EvaluationScore?
gestionareaSarcinilor EvaluationScore?
// B. Comunicare si empatie
constiintaProfesionala EvaluationScore?
atitudineaPacienti EvaluationScore?
atitudineaColegi EvaluationScore?
atitudineaPersonalNonMed EvaluationScore?
// C. Disciplina
utilizareSmartphone EvaluationScore?
respectareaProgramului EvaluationScore?
respectareaDressCode EvaluationScore?
// D. Documentatie si complianta
testJci Json? // { score, max_score, percent, completed_at, source, external_id }
completareaDocMed Boolean?
perfectioneazaCunostinte Boolean?
// E. Candidat EXPERT (Da/Nu)
membruComitetCalitate Boolean?
functieDeMonitor Boolean?
inlocuiesteSuperiorul Boolean?
// F. Verdict final
categorieCalculata ProposedCategory?
categorieAprobata ProposedCategory? // override de nursing_director
observatii String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([campaignId, employeeId])
@@index([campaignId])
@@index([employeeId])
@@map("evaluation_forms")
}
// ═══════════════════════════════════════════════════════════════
// PHASE 5 STUB: MEDICAL CONTROL
// ═══════════════════════════════════════════════════════════════
model WorkplaceRiskCard {
id String @id @default(uuid())
name String @unique // "Medic profil chirurgical cu gărzi de noapte"
riskFactors Json? // legacy: { chimici, fizici, biologici, ergonomici, psihosociali }
profiles EmployeeMedicalProfile[]
// ── Anexa 4 — antet (Fișa de evaluare a riscurilor profesionale) ──
filiala String?
adresaFiliala String?
telefonFiliala String?
caemPrimeleDouaCifre String?
cormSubgrupaMajora String?
directiaSectiaSectorul String?
numarulLoculuiDeMunca String?
caemDiviziune String?
clasaConditiilorDeMunca String?
numarLucratoriPosibili Int?
// STANDARD (Anexa 4) | DISTANTA_DIGITAL (Anexa 4A — muncă la distanță/platforme digitale)
tipFisa String @default("STANDARD")
// ── Anexa 4 — bloc descriptiv (checkbox-uri / descrieri) ──
evaluareDetalii Json?
// ── Anexa 4 — radiații ionizante (per loc de muncă) ──
radiatiiIonizante Boolean @default(false)
radiatiiGrupa String? // A | B
radiatiiAparatura String?
radiatiiSurse String? // inchise | deschise
radiatiiTipExpunere String? // X externă | gamma externă | internă | externă și internă
radiatiiMasuriProtectie String?
// ── Anexa 4 — subsol ──
mijloaceProtectieColectiva String?
mijloaceProtectieIndividuala String?
echipamentLucru String?
observatii String?
anexeIgienicoSanitare Json? // { vestiar, chiuveta, wc, dus, salaMese, recreere }
exposures WorkplaceRiskExposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("workplace_risk_cards")
}
// Rând din tabelele factoriale ale Anexei 4
model WorkplaceRiskExposure {
id String @id @default(uuid())
cardId String
card WorkplaceRiskCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
tip RiskExposureType
denumire String
cas String? // doar chimic / pulberi
einecs String? // doar chimic / pulberi
clasificare String? // doar agent biologic
zonaAfectata String? // vibrații / câmp EM / radiații optice
timpExpunere String?
vep String? // valoarea de expunere profesională
vlep String? // valoarea-limită de expunere profesională obligatorie
caracteristici String?
procesVerbal String?
createdAt DateTime @default(now())
@@index([cardId])
@@map("workplace_risk_exposures")
}
model EmployeeMedicalProfile {
id String @id @default(uuid())
employeeId String @unique
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
ocupatieCorm String?
workplaceRiskCardId String?
workplaceRiskCard WorkplaceRiskCard? @relation(fields: [workplaceRiskCardId], references: [id])
dataUltimControlMedical DateTime? @db.Date
// Câmpuri radiații ionizante
expusRadiatiiIonizante Boolean @default(false)
dataIntrarii DateTime? @db.Date
expunereAnterioaraPerioda String? // se completează o singură dată la angajare
expunereAnterioaraAni Int?
dozaCumulataExternaMsv Decimal? @db.Decimal(10, 4)
dozaCumulataInternaMsv Decimal? @db.Decimal(10, 4)
// dozaTotalaMsv = externa + interna — câmp calculat, nu stocat
// Supraexpuneri excepționale/accidentale (Anexa 4B)
overexposures RadiationOverexposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("employee_medical_profiles")
}
// Supraexpunere la radiații ionizante — rând din Anexa 4B (per lucrător)
model RadiationOverexposure {
id String @id @default(uuid())
medicalProfileId String
medicalProfile EmployeeMedicalProfile @relation(fields: [medicalProfileId], references: [id], onDelete: Cascade)
fel OverexposureKind // EXCEPTIONALA | ACCIDENTALA
tipExpunere String? // X externă | gamma externă | internă | externă și internă
data DateTime? @db.Date
dozaMsv Decimal? @db.Decimal(10, 4)
createdAt DateTime @default(now())
@@index([medicalProfileId])
@@map("radiation_overexposures")
}
model MedicalCheckup {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
tip MedicalCheckupType
dataPlanificata DateTime @db.Date
dataEfectuata DateTime? @db.Date
verdict MedicalVerdict?
recomandari String?
valabilPanaLa DateTime? @db.Date
semnatDe String?
// Ссылки на S3-документы: [{ name, url, type }]
documenteGenerate Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([dataPlanificata])
@@map("medical_checkups")
}
// ═══════════════════════════════════════════════════════════════
// AUDIT LOG — append-only, 5 ani retentie
// ═══════════════════════════════════════════════════════════════
model AuditLog {
id BigInt @id @default(autoincrement())
ts DateTime @default(now())
userId String
userRole String @db.VarChar(50)
ip String? @db.VarChar(45) // IPv4 или IPv6
action String @db.VarChar(20) // READ | CREATE | UPDATE | DELETE | EXPORT
entity String @db.VarChar(50)
entityId String @db.VarChar(50)
field String? @db.VarChar(100)
// PII-значения шифруются на app-уровне (pgcrypto / KMS) перед записью
oldValue String?
newValue String?
reason String? // обязателен для READ медицинских данных (GDPR)
@@index([userId])
@@index([entity, entityId])
@@index([ts])
@@map("audit_logs")
}
// ═══════════════════════════════════════════════════════════════
// ANEXA TEMPLATE EDITOR
// ═══════════════════════════════════════════════════════════════
model AnexaTemplate {
id String @id @default(uuid())
type AnexaType @unique
name String
contentJson Json
updatedById String
updatedAt DateTime @updatedAt
versions AnexaTemplateVersion[]
@@map("anexa_templates")
}
model AnexaTemplateVersion {
id String @id @default(uuid())
templateId String
template AnexaTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
contentJson Json
savedById String
savedAt DateTime @default(now())
label String?
@@index([templateId])
@@map("anexa_template_versions")
}
+738
View File
@@ -0,0 +1,738 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding reference data...');
// ── Disability Grades (grade de dizabilitate MD) ─────────────────
await prisma.disabilityGrade.createMany({
data: [
{ code: 'GRAD_I', name: 'Grad I (sever)' },
{ code: 'GRAD_II', name: 'Grad II (accentuat)' },
{ code: 'GRAD_III', name: 'Grad III (mediu)' },
],
skipDuplicates: true,
});
console.log(' ✓ DisabilityGrade (3)');
// ── Tax Exemptions (scutiri Codul Fiscal RM) ─────────────────────
await prisma.taxExemption.createMany({
data: [
{ code: 'PE', description: 'Scutire personală (art. 33 CF)' },
{ code: 'PI', description: 'Scutire personală majorată (art. 33 alin. 2 CF)' },
{ code: 'SO', description: 'Scutire pentru soț/soție (art. 34 CF)' },
{ code: 'MP1', description: 'Scutire pentru 1 copil minor (art. 35 CF)' },
{ code: 'MP2', description: 'Scutire pentru 2 copii minori (art. 35 CF)' },
{ code: 'MP3', description: 'Scutire pentru 3+ copii minori (art. 35 CF)' },
{ code: 'INVALID', description: 'Scutire persoană cu dizabilitate (art. 33 alin. 2 lit. a CF)' },
],
skipDuplicates: true,
});
console.log(' ✓ TaxExemption (7)');
// ── Work Schedules ───────────────────────────────────────────────
await prisma.workSchedule.createMany({
data: [
{ name: '5/2 — 8h/zi', daysWork: 5, daysRest: 2, hoursPerDay: 8 },
{ name: '5/2 — 7h/zi', daysWork: 5, daysRest: 2, hoursPerDay: 7 },
{ name: 'Gărzi 24h (1/3)', daysWork: 1, daysRest: 3, hoursPerDay: 24 },
{ name: 'Gărzi 12h zi (1/1)', daysWork: 1, daysRest: 1, hoursPerDay: 12 },
{ name: 'Gărzi 12h noapte (1/1)',daysWork: 1, daysRest: 1, hoursPerDay: 12 },
{ name: '7/7 — 12h/zi', daysWork: 7, daysRest: 7, hoursPerDay: 12 },
{ name: 'Rotație 2/2 — 12h', daysWork: 2, daysRest: 2, hoursPerDay: 12 },
],
skipDuplicates: true,
});
console.log(' ✓ WorkSchedule (7)');
// ── Departments — Medpark International Hospital ─────────────────
// Level 0: hospital root
const root = await prisma.department.upsert({
where: { code: 'MEDPARK' },
update: {},
create: { name: 'Medpark International Hospital', code: 'MEDPARK' },
});
// Helper to upsert a department
const dept = async (name: string, code: string, parentId?: string) =>
prisma.department.upsert({
where: { code },
update: {},
create: { name, code, parentId: parentId ?? null },
});
// Administration
const admin = await dept('Administrare', 'ADMIN', root.id);
await dept('Resurse Umane', 'HR', admin.id);
await dept('Financiar-Contabil', 'FIN', admin.id);
await dept('Juridic', 'JUR', admin.id);
await dept('IT', 'IT', admin.id);
await dept('Achiziții', 'ACHIZ', admin.id);
// Medical divisions
const med = await dept('Bloc Medical', 'MED', root.id);
const terapie = await dept('Terapie și Medicină Internă', 'TERAP', med.id);
await dept('Cardiologie', 'CARDIO', terapie.id);
await dept('Gastroenterologie', 'GASTRO', terapie.id);
await dept('Endocrinologie', 'ENDO', terapie.id);
await dept('Neurologie', 'NEURO', terapie.id);
await dept('Pneumologie', 'PNEUMO', terapie.id);
await dept('Reumatologie', 'REUMA', terapie.id);
const chir = await dept('Chirurgie', 'CHIR', med.id);
await dept('Chirurgie Generală', 'CHIR_GEN', chir.id);
await dept('Chirurgie Vasculară', 'CHIR_VAS', chir.id);
await dept('Ortopedie și Traumatologie','ORTOPED', chir.id);
await dept('Urologie', 'UROL', chir.id);
await dept('ORL', 'ORL', chir.id);
await dept('Oftalmologie', 'OFTALMO', chir.id);
const ped = await dept('Pediatrie', 'PED', med.id);
await dept('Pediatrie Generală', 'PED_GEN', ped.id);
await dept('Neonatologie', 'NEONAT', ped.id);
const obst = await dept('Obstetrică-Ginecologie', 'OBG', med.id);
await dept('Obstetrică', 'OBSTET', obst.id);
await dept('Ginecologie', 'GINECO', obst.id);
await dept('Oncologie', 'ONCOL', med.id);
await dept('Hemodializă', 'HEMODIAL', med.id);
await dept('Psihiatrie', 'PSIHIAT', med.id);
await dept('Dermatologie', 'DERMA', med.id);
await dept('Medicină Sportivă și Reabilitare', 'REAB', med.id);
// Diagnostics
const diag = await dept('Diagnostic', 'DIAG', root.id);
await dept('Laborator Clinic', 'LAB', diag.id);
await dept('Imagistică Medicală (CT/RMN/Rx)', 'IMAG', diag.id);
await dept('Endoscopie', 'ENDOSC', diag.id);
await dept('Ecografie', 'ECO', diag.id);
await dept('Cardiologie Funcțională (ECG/Holter)', 'ECG', diag.id);
// Support
const suport = await dept('Servicii Suport', 'SUPORT', root.id);
await dept('Urgențe (UPU)', 'UPU', suport.id);
await dept('Anestezie și Terapie Intensivă (ATI)', 'ATI', suport.id);
await dept('Bloc Operator', 'BLOC_OP', suport.id);
await dept('Sterilizare', 'STERIL', suport.id);
await dept('Farmacie', 'FARMACIE', suport.id);
await dept('Nutriție și Dietetică', 'NUTRIT', suport.id);
await dept('Serviciu Social', 'SOC', suport.id);
await dept('Curățenie și Dezinfecție', 'CURATENIE',suport.id);
await dept('Securitate', 'SECUR', suport.id);
await dept('Tehnică Medicală', 'TEH_MED', suport.id);
// Ambulatory
const ambul = await dept('Centru Ambulator', 'AMBUL', root.id);
await dept('Medicină de Familie', 'MED_FAM', ambul.id);
await dept('Consultații Specializate', 'CONSULT', ambul.id);
await dept('Fizioterapie', 'FIZIOTER', ambul.id);
const deptCount = await prisma.department.count();
console.log(` ✓ Department (${deptCount})`);
// ── Anexa Templates — minimal seed ─────────────────────────
const heading = (text: string, level = 2) => ({
type: 'heading',
attrs: { level, textAlign: 'center' },
content: [{ type: 'text', text }],
});
const para = (content: object[], textAlign: string = 'left') => ({
type: 'paragraph',
attrs: { textAlign },
content,
});
const txt = (text: string, marks?: { type: string }[]) =>
marks ? { type: 'text', text, marks } : { type: 'text', text };
const chip = (key: string, label: string) => ({
type: 'variableChip',
attrs: { key, label },
});
const cell = (content: object[]) => ({ type: 'tableCell', content });
const row = (cells: object[]) => ({ type: 'tableRow', content: cells });
const headerRow = (labels: string[]) =>
row(labels.map((l) => cell([para([txt(l, [{ type: 'bold' }])], 'center')])));
// ── Anexa 3: Fișa de solicitare ─────────────────────────────────
const anexa3 = {
type: 'doc',
content: [
heading('FIȘA DE SOLICITARE A EXAMENULUI MEDICAL'),
para([txt('Unitatea economică: '), chip('company.name', 'Denumirea unității')]),
para([txt('IDNO: '), chip('company.idno', 'IDNO'), txt(' Adresa: '), chip('company.address', 'Adresa')]),
para([txt('Tipul examenului: '), chip('tipExamen', 'Tipul examenului')]),
para([txt('Departament: '), chip('department.name', 'Departament'), txt(' Carta de risc: '), chip('riskCard.name', 'Carta de risc')]),
para([txt('Data: '), chip('document.date', 'Data documentului'), txt(' Nr.: '), chip('document.number', 'Număr')]),
para([txt('Lista angajaților:', [{ type: 'bold' }])]),
{
type: 'table',
attrs: { repeatRows: true },
content: [
headerRow(['Nr.', 'Nume Prenume', 'IDNP', 'Anul nașterii', 'Ocupația', 'Tipul examenului']),
row([
cell([para([chip('row.index', 'Nr.')])]),
cell([para([chip('row.employeeName', 'Nume Prenume')])]),
cell([para([chip('row.idnp', 'IDNP')])]),
cell([para([chip('row.birthYear', 'Anul nașterii')])]),
cell([para([chip('row.occupation', 'Ocupația')])]),
cell([para([chip('row.tipExamen', 'Tipul examenului')])]),
]),
],
},
],
};
// ── Anexa 4: Fișa de evaluare a riscurilor profesionale (NU-10-MS-2026) ──
const cb = (key: string, label: string) => [chip(`a4.cb.${key}`, '☐'), txt(' ' + label)];
const factorTable = (rowsKey: string, cols: string[], rowChips: string[]) => ({
type: 'table',
attrs: { repeatRows: true, rowsKey },
content: [
headerRow(cols),
row(rowChips.map((k) => cell([para([chip(k, '—')])]))),
],
});
const anexa4 = {
type: 'doc',
content: [
// ── Antet ──
para([txt('Unitatea economică/instituția: '), chip('a4.unitatea', 'Denumirea unității')]),
para([txt('Adresa, telefon, fax, e-mail: '), chip('a4.adresa', 'Adresa')]),
para([txt('Filiala: '), chip('a4.filiala', '—'), txt(' Adresa filialei: '), chip('a4.adresaFiliala', '—'), txt(' CAEM (primele 2 cifre): '), chip('a4.caem2', '—')]),
heading('FIȘA de evaluare a riscurilor profesionale', 2),
para([txt('Ocupația (subgrupa majoră CORM): '), chip('a4.cormSubgrupa', '—')]),
para([txt('Direcția/secția/sectorul: '), chip('a4.directiaSectia', '—')]),
para([txt('Numărul locului de muncă: '), chip('a4.numarLoc', '—'), txt(' CAEM (nivel diviziune): '), chip('a4.caemDiviziune', '—')]),
para([txt('Numărul de lucrători care pot activa la acest loc de muncă: '), chip('a4.numarLucratori', '—'), txt(' Clasa condițiilor de muncă: '), chip('a4.clasa', '—')]),
// ── Descrierea activității ──
heading('Descrierea activității', 3),
para([txt('Lucrul în echipă: '), ...cb('echipa', 'da'), txt(' Nr. ore/zi: '), chip('a4.val.oreZi', '—'), txt(' Nr. schimburi: '), chip('a4.val.schimburi', '—')]),
para([...cb('schimbNoapte', 'schimb de noapte'), txt(' '), ...cb('pauzeOrganizate', 'pauze organizate')]),
para([txt('Riscuri: '), ...cb('riscInfectare', 'infectare'), txt(' '), ...cb('riscElectrocutare', 'electrocutare'), txt(' '), ...cb('riscTensiuneInalta', 'tensiune înaltă'), txt(' '), ...cb('riscInecare', 'înecare'), txt(' '), ...cb('riscAsfixiere', 'asfixiere')]),
para([...cb('riscStrivire', 'strivire'), txt(' '), ...cb('riscTaiere', 'tăiere'), txt(' '), ...cb('riscIntepare', 'înțepare'), txt(' '), ...cb('riscLovire', 'lovire'), txt(' '), ...cb('riscMuscatura', 'mușcătură'), txt(' '), ...cb('riscMicrotraumatisme', 'microtraumatisme repetate')]),
para([txt('Conduce mașina instituției: '), ...cb('conduceMasina', 'da'), txt(' categorie: '), chip('a4.val.conduceMasinaCategorie', '—'), txt(' '), ...cb('conduceUtilajeIntrauzinal', 'conduce utilaje numai intrauzinal')]),
// ── Spațiul de lucru ──
heading('Descrierea spațiului de lucru', 3),
para([txt('Dimensiunile încăperii: L '), chip('a4.val.spatiuL', '—'), txt(' l '), chip('a4.val.spatiul', '—'), txt(' H '), chip('a4.val.spatiuH', '—'), txt(' m')]),
para([txt('Suprafața de lucru: '), ...cb('suprafataVerticala', 'verticală'), txt(' '), ...cb('suprafataOrizontala', 'orizontală'), txt(' '), ...cb('suprafataOblica', 'oblică')]),
para([txt('Muncă: '), ...cb('muncaIzolare', 'în condiții de izolare'), txt(' '), ...cb('muncaInaltime', 'la înălțime'), txt(' '), ...cb('muncaInMiscare', 'în mișcare')]),
// ── Efort fizic ──
heading('Efort fizic', 3),
para([txt('Poziție preponderent: '), ...cb('pozitieOrtostatica', 'ortostatică'), txt(' '), ...cb('pozitieAsezat', 'așezat'), txt(' '), ...cb('pozitieAplecata', 'aplecată'), txt(' '), ...cb('pozitieMixta', 'mixtă'), txt(' '), ...cb('pozitieFortata', 'forțată/nefiziologică')]),
para([txt('Suprasolicitări musculo-articulare (coloană): '), ...cb('coloanaCervicala', 'cervicală'), txt(' '), ...cb('coloanaToracala', 'toracală'), txt(' '), ...cb('coloanaLombara', 'lombară')]),
para([txt('Manipulare manuală a maselor: '), ...cb('manipulareRidicare', 'ridicare'), txt(' '), ...cb('manipulareCoborare', 'coborâre'), txt(' '), ...cb('manipulareImpingere', 'împingere'), txt(' '), ...cb('manipulareTragere', 'tragere'), txt(' '), ...cb('manipularePurtare', 'purtare'), txt(' '), ...cb('manipulareDeplasare', 'deplasare')]),
para([txt('Greutate maximă manipulată manual: '), chip('a4.val.greutateMaxima', '—')]),
para([txt('Suprasolicitări: '), ...cb('suprasolicitariVizuale', 'vizuale'), txt(' '), ...cb('suprasolicitariAuditive', 'auditive'), txt(' '), ...cb('suprasolicitariNeuropsihice', 'neuropsihosenzoriale')]),
// ── Factori de risc cu tabel ──
heading('AGENȚI CHIMICI', 3),
para([...cb('chimici_da', 'da'), txt(' '), ...cb('chimici_nu', 'nu'), txt(' (se atașează Fișa cu date de securitate, în limba română)')]),
factorTable('chimici',
['Agentul chimic', 'CAS', 'EINECS', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.cas', 'row.einecs', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
heading('PULBERI', 3),
para([...cb('pulberi_da', 'da'), txt(' '), ...cb('pulberi_nu', 'nu')]),
factorTable('pulberi',
['Pulberi', 'CAS', 'EINECS', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.cas', 'row.einecs', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
heading('AGENȚI BIOLOGICI', 3),
para([...cb('biologici_da', 'da'), txt(' '), ...cb('biologici_nu', 'nu')]),
factorTable('biologici',
['Agent biologic', 'Clasificare', 'Note'],
['row.denumire', 'row.clasificare', 'row.caracteristici']),
heading('ZGOMOT PROFESIONAL', 3),
para([...cb('zgomot_da', 'da'), txt(' '), ...cb('zgomot_nu', 'nu')]),
factorTable('zgomot',
['Tipul', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
heading('VIBRAȚII MECANICE', 3),
para([...cb('vibratii_da', 'da'), txt(' '), ...cb('vibratii_nu', 'nu')]),
factorTable('vibratii',
['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
// ── Microclimat (descriptiv) ──
heading('MICROCLIMAT', 3),
para([...cb('microclimatInterior', 'lucrări interior'), txt(' '), ...cb('microclimatExterior', 'lucru exterior/sub cerul liber')]),
para([txt('Radiații calorice (perioada rece): '), ...cb('radiatiiCaloriceRece', 'da'), txt(' Radiații calorice (perioada caldă): '), ...cb('radiatiiCaloriceCalda', 'da')]),
// ── Radiații ionizante ──
heading('RADIAȚII IONIZANTE', 3),
para([...cb('radiatii_da', 'da'), txt(' '), ...cb('radiatii_nu', 'nu'), txt(' Grupa: '), chip('a4.rad.grupa', '—')]),
para([txt('Aparatură folosită: '), chip('a4.rad.aparatura', '—'), txt(' Surse: '), chip('a4.rad.surse', '—')]),
para([txt('Tip de expunere: '), chip('a4.rad.tipExpunere', '—'), txt(' Măsuri de protecție: '), chip('a4.rad.masuriProtectie', '—')]),
// ── Câmp electromagnetic ──
heading('CÂMP ELECTROMAGNETIC', 3),
para([...cb('campEM_da', 'da'), txt(' '), ...cb('campEM_nu', 'nu')]),
factorTable('campEM',
['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
// ── Iluminat ──
heading('ILUMINAT', 3),
para([...cb('iluminatSuficient', 'suficient'), txt(' '), ...cb('iluminatInsuficient', 'insuficient'), txt(' '), ...cb('iluminatNatural', 'natural'), txt(' '), ...cb('iluminatArtificial', 'artificial'), txt(' '), ...cb('iluminatMixt', 'mixt')]),
// ── Radiații optice artificiale ──
heading('RADIAȚII OPTICE ARTIFICIALE', 3),
para([...cb('optice_da', 'da'), txt(' '), ...cb('optice_nu', 'nu')]),
factorTable('optice',
['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'],
['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']),
// ── Subsol ──
heading('Protecție și dotări', 3),
para([txt('Mijloace de protecție colectivă: '), chip('a4.protectieColectiva', '—')]),
para([txt('Mijloace de protecție individuală: '), chip('a4.protectieIndividuala', '—')]),
para([txt('Echipament de lucru: '), chip('a4.echipament', '—')]),
para([txt('Anexe igienico-sanitare: '), ...cb('anexe.vestiar', 'vestiar'), txt(' '), ...cb('anexe.chiuveta', 'chiuvetă'), txt(' '), ...cb('anexe.wc', 'WC'), txt(' '), ...cb('anexe.dus', 'duș'), txt(' '), ...cb('anexe.salaMese', 'sală de mese'), txt(' '), ...cb('anexe.recreere', 'spațiu de recreere')]),
para([txt('Observații: '), chip('a4.observatii', '—')]),
para([txt('Data completării: '), chip('document.date', 'Data')]),
para([txt('Angajatorul (nume, prenume, semnătura): ____________________')]),
para([txt('Instrucțiuni de completare: răspuns afirmativ [☑]; răspuns negativ [☐].', [{ type: 'italic' }])]),
],
};
// ── Anexa 4B: Supliment radiații ionizante ───────────────────────
const anexa4b = {
type: 'doc',
content: [
heading('SUPLIMENT — EXPUNERE LA RADIAȚII IONIZANTE'),
para([txt('Unitatea economică: '), chip('company.name', 'Denumirea unității')]),
para([txt('Data: '), chip('document.date', 'Data documentului')]),
para([txt('Personal expus radiațiilor ionizante:', [{ type: 'bold' }])]),
{
type: 'table',
attrs: { repeatRows: true },
content: [
headerRow(['Nr.', 'Nume Prenume', 'IDNP', 'Data intrării', 'Perioada anterioară', 'Ani', 'Doza ext. (mSv)', 'Doza int. (mSv)', 'Total (mSv)']),
row([
cell([para([chip('row.index', 'Nr.')])]),
cell([para([chip('row.employeeName', 'Nume Prenume')])]),
cell([para([chip('row.idnp', 'IDNP')])]),
cell([para([chip('row.entryDate', 'Data intrării')])]),
cell([para([chip('row.priorPeriod', 'Perioada anterioară')])]),
cell([para([chip('row.priorYears', 'Ani')])]),
cell([para([chip('row.externalMsv', 'Doza ext.')])]),
cell([para([chip('row.internalMsv', 'Doza int.')])]),
cell([para([chip('row.totalMsv', 'Total')])]),
]),
],
},
],
};
// ── Anexa 6: Verdict medic de familie (per-employee) ─────────────
const anexa6 = {
type: 'doc',
content: [
heading('FIȘĂ DE APTITUDINE — VERDICTUL MEDICULUI DE FAMILIE'),
para([txt('Angajat: '), chip('employee.fullName', 'Nume Prenume')]),
para([txt('IDNP: '), chip('employee.idnp', 'IDNP'), txt(' Data nașterii: '), chip('employee.birthDate', 'Data nașterii')]),
para([txt('Ocupația: '), chip('employee.occupation', 'Ocupația'), txt(' Departament: '), chip('employee.department', 'Departament')]),
para([txt('Tipul examenului: '), chip('tipExamen', 'Tipul examenului')]),
para([txt('Data examinării: '), chip('document.date', 'Data')]),
para([txt('Verdict:', [{ type: 'bold' }])]),
para([chip('verdict.checkbox.apt', '☐'), txt(' Apt')]),
para([chip('verdict.checkbox.apt_perioada_adaptare', '☐'), txt(' Apt în perioada de adaptare')]),
para([chip('verdict.checkbox.apt_conditionat', '☐'), txt(' Apt condiționat')]),
para([chip('verdict.checkbox.inapt_temporar', '☐'), txt(' Inapt temporar')]),
para([chip('verdict.checkbox.inapt', '☐'), txt(' Inapt')]),
para([txt('Recomandări: '), chip('verdict.recomandari', 'Recomandări')]),
para([txt(' ')]),
para([txt('Semnătura medicului de familie: ____________________')]),
],
};
const SYS = '00000000-0000-0000-0000-000000000000';
const templates: Array<{ type: 'ANEXA_3' | 'ANEXA_4' | 'ANEXA_4B' | 'ANEXA_6'; name: string; doc: object }> = [
{ type: 'ANEXA_3', name: 'Fișa de solicitare a examenului medical', doc: anexa3 },
{ type: 'ANEXA_4', name: 'Fișa de evaluare a locului de muncă', doc: anexa4 },
{ type: 'ANEXA_4B', name: 'Supliment radiații ionizante', doc: anexa4b },
{ type: 'ANEXA_6', name: 'Verdict medic de familie', doc: anexa6 },
];
for (const t of templates) {
await prisma.anexaTemplate.upsert({
where: { type: t.type },
update: { name: t.name, contentJson: t.doc as never },
create: { type: t.type, name: t.name, contentJson: t.doc as never, updatedById: SYS },
});
}
console.log(' ✓ AnexaTemplate (4)');
// ── Inventory items (depozit Vestimentație + Echipament) ─────────
const inventory = [
{ sku: 'UN-CHIR-S-AL', name: 'Uniformă chirurgie S albastru', type: 'uniforma' as const, size: 'S', color: 'albastru', stockQty: 50 },
{ sku: 'UN-CHIR-M-AL', name: 'Uniformă chirurgie M albastru', type: 'uniforma' as const, size: 'M', color: 'albastru', stockQty: 50 },
{ sku: 'UN-CHIR-L-AL', name: 'Uniformă chirurgie L albastru', type: 'uniforma' as const, size: 'L', color: 'albastru', stockQty: 50 },
{ sku: 'UN-ATI-M-VE', name: 'Uniformă ATI M verde', type: 'uniforma' as const, size: 'M', color: 'verde', stockQty: 30 },
{ sku: 'HA-MED-M-AL', name: 'Halat medical M alb', type: 'halat' as const, size: 'M', color: 'alb', stockQty: 50 },
{ sku: 'HA-MED-L-AL', name: 'Halat medical L alb', type: 'halat' as const, size: 'L', color: 'alb', stockQty: 50 },
{ sku: 'HA-LAB-M-AL', name: 'Halat laborator M alb', type: 'halat' as const, size: 'M', color: 'alb', stockQty: 30 },
{ sku: 'HA-LAB-L-AL', name: 'Halat laborator L alb', type: 'halat' as const, size: 'L', color: 'alb', stockQty: 30 },
{ sku: 'CI-38-AL', name: 'Ciupici 38-40 albi', type: 'ciupici' as const, size: '38-40', color: 'alb', stockQty: 80 },
{ sku: 'CI-41-AL', name: 'Ciupici 41-43 albi', type: 'ciupici' as const, size: '41-43', color: 'alb', stockQty: 80 },
{ sku: 'CI-44-AL', name: 'Ciupici 44-46 albi', type: 'ciupici' as const, size: '44-46', color: 'alb', stockQty: 80 },
{ sku: 'VE-S-TE', name: 'Vestă S teal', type: 'vesta' as const, size: 'S', color: 'teal', stockQty: 20 },
{ sku: 'VE-M-TE', name: 'Vestă M teal', type: 'vesta' as const, size: 'M', color: 'teal', stockQty: 20 },
{ sku: 'AT-SAMS-A15', name: 'Samsung Galaxy A15', type: 'aparat_telefon' as const, stockQty: 15 },
{ sku: 'AT-IPHONE-SE', name: 'iPhone SE 2022', type: 'aparat_telefon' as const, stockQty: 10 },
];
for (const item of inventory) {
await prisma.inventoryItem.upsert({
where: { sku: item.sku },
update: {},
create: item,
});
}
console.log(` ✓ InventoryItem (${inventory.length})`);
// ── Demo data pentru prezentare ──────────────────────────────
console.log('\n🎭 Seeding demo data...');
// Risk cards
const chirExposures = [
{ tip: 'AGENT_CHIMIC' as const, denumire: 'Glutaraldehidă (dezinfectant)', cas: '111-30-8', einecs: '203-856-5', timpExpunere: '2 h/zi', vep: '0,03 ppm', vlep: '0,1 ppm', caracteristici: 'iritant respirator' },
{ tip: 'AGENT_BIOLOGIC' as const, denumire: 'Virusuri hematogene (HBV, HCV, HIV)', clasificare: 'grupa 3', caracteristici: 'risc de infectare prin înțepare/tăiere' },
];
const chirHeader = {
filiala: 'Sediul central',
caemPrimeleDouaCifre: '86',
cormSubgrupaMajora: 'Personal medical — secție chirurgie',
directiaSectiaSectorul: 'Bloc Medical / Chirurgie Generală',
numarulLoculuiDeMunca: 'CH-01',
caemDiviziune: '86.10',
clasaConditiilorDeMunca: '3.2',
numarLucratoriPosibili: 12,
evaluareDetalii: {
echipa: true, oreZi: '8', schimburi: '2', schimbNoapte: true, pauzeOrganizate: true,
riscInfectare: true, riscTaiere: true, riscIntepare: true,
pozitieOrtostatica: true, manipulareRidicare: true, suprasolicitariVizuale: true,
},
anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true },
mijloaceProtectieIndividuala: 'Mănuși, mască, halat steril',
echipamentLucru: 'Uniformă chirurgicală',
};
const rcChir = await prisma.workplaceRiskCard.upsert({
where: { name: 'Secție chirurgie generală' },
update: { ...chirHeader, exposures: { deleteMany: {}, create: chirExposures } },
create: { name: 'Secție chirurgie generală', ...chirHeader, exposures: { create: chirExposures } },
});
const imagExposures = [
{ tip: 'CAMP_ELECTROMAGNETIC' as const, denumire: 'Câmp electromagnetic RMN', zonaAfectata: 'corp întreg', timpExpunere: '4 h/zi', vep: '—', vlep: 'conform NU-10', caracteristici: 'câmp magnetic static intens' },
];
const imagHeader = {
filiala: 'Sediul central',
caemPrimeleDouaCifre: '86',
cormSubgrupaMajora: 'Personal imagistică medicală',
directiaSectiaSectorul: 'Diagnostic / Imagistică Medicală',
numarulLoculuiDeMunca: 'IMG-01',
caemDiviziune: '86.90',
clasaConditiilorDeMunca: '3.3',
numarLucratoriPosibili: 8,
radiatiiIonizante: true,
radiatiiGrupa: 'A',
radiatiiSurse: 'închise',
radiatiiTipExpunere: 'X externă',
radiatiiAparatura: 'CT, aparat Rx',
radiatiiMasuriProtectie: 'șorț cu plumb, ecran de protecție, dozimetru individual',
evaluareDetalii: {
echipa: true, oreZi: '7', schimburi: '2',
riscElectrocutare: true, pozitieAsezat: true, suprasolicitariVizuale: true,
},
anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true },
mijloaceProtectieIndividuala: 'Șorț cu plumb, ochelari, dozimetru',
};
const rcImag = await prisma.workplaceRiskCard.upsert({
where: { name: 'Radiologie și imagistică' },
update: { ...imagHeader, exposures: { deleteMany: {}, create: imagExposures } },
create: { name: 'Radiologie și imagistică', ...imagHeader, exposures: { create: imagExposures } },
});
console.log(' ✓ WorkplaceRiskCard demo (2) — cu antet Anexa 4 + factori');
// Lookup departments & inventory items
const chirGenDept = await prisma.department.findUnique({ where: { code: 'CHIR_GEN' } });
const imagDept = await prisma.department.findUnique({ where: { code: 'IMAG' } });
const uniformaS = await prisma.inventoryItem.findUnique({ where: { sku: 'UN-CHIR-S-AL' } });
const halatM = await prisma.inventoryItem.findUnique({ where: { sku: 'HA-MED-M-AL' } });
// 4 demo employees (IDNPs pre-validated cu algoritmul de sumă de control MD)
const emp1 = await prisma.employee.upsert({
where: { idnp: '1985061500016' },
update: {},
create: {
idnp: '1985061500016', nume: 'Popescu', prenume: 'Alexandru',
sex: 'M', dataNasterii: new Date('1985-06-15'),
domiciliu: 'mun. Chișinău, str. Ștefan cel Mare 1',
telefonPersonal: '+37369100001', status: 'activ',
},
});
const emp2 = await prisma.employee.upsert({
where: { idnp: '1990032200017' },
update: {},
create: {
idnp: '1990032200017', nume: 'Ionescu', prenume: 'Maria',
sex: 'F', dataNasterii: new Date('1990-03-22'),
domiciliu: 'mun. Chișinău, str. Mihai Viteazul 5',
telefonPersonal: '+37369100002', status: 'activ',
},
});
const emp3 = await prisma.employee.upsert({
where: { idnp: '1978110800016' },
update: {},
create: {
idnp: '1978110800016', nume: 'Rusu', prenume: 'Viorel',
sex: 'M', dataNasterii: new Date('1978-11-08'),
domiciliu: 'mun. Chișinău, str. Alba Iulia 12',
telefonPersonal: '+37369100003', status: 'activ',
},
});
const emp4 = await prisma.employee.upsert({
where: { idnp: '2001091400010' },
update: {},
create: {
idnp: '2001091400010', nume: 'Cojocaru', prenume: 'Elena',
sex: 'F', dataNasterii: new Date('2001-09-14'),
domiciliu: 'mun. Chișinău, str. Trandafirilor 3',
telefonPersonal: '+37369100004', status: 'activ',
},
});
console.log(' ✓ Employee demo (4)');
// Employment contracts
if (chirGenDept) {
await prisma.employmentContract.upsert({
where: { nrCim: 'CIM-DEMO-001' },
update: {},
create: {
nrCim: 'CIM-DEMO-001', employeeId: emp1.id,
categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata',
dataSemnarii: new Date('2020-01-10'), dataAngajarii: new Date('2020-01-15'),
departmentId: chirGenDept.id, functiaOrganigrama: 'Chirurg',
salarizareDetails: { tip: 'fix', salariu: 18000, zileConcediu: 28 },
},
});
await prisma.employmentContract.upsert({
where: { nrCim: 'CIM-DEMO-002' },
update: {},
create: {
nrCim: 'CIM-DEMO-002', employeeId: emp2.id,
categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata',
dataSemnarii: new Date('2021-03-01'), dataAngajarii: new Date('2021-03-05'),
departmentId: chirGenDept.id, functiaOrganigrama: 'Asistentă medicală',
salarizareDetails: { tip: 'fix', salariu: 10000, zileConcediu: 28 },
},
});
}
if (imagDept) {
await prisma.employmentContract.upsert({
where: { nrCim: 'CIM-DEMO-003' },
update: {},
create: {
nrCim: 'CIM-DEMO-003', employeeId: emp3.id,
categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata',
dataSemnarii: new Date('2018-06-01'), dataAngajarii: new Date('2018-06-10'),
departmentId: imagDept.id, functiaOrganigrama: 'Radiolog',
salarizareDetails: { tip: 'fix', salariu: 20000, zileConcediu: 35 },
},
});
await prisma.employmentContract.upsert({
where: { nrCim: 'CIM-DEMO-004' },
update: {},
create: {
nrCim: 'CIM-DEMO-004', employeeId: emp4.id,
categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata',
dataSemnarii: new Date('2023-09-01'), dataAngajarii: new Date('2023-09-15'),
departmentId: imagDept.id, functiaOrganigrama: 'Asistentă radiologie',
salarizareDetails: { tip: 'fix', salariu: 9500, zileConcediu: 28 },
},
});
}
console.log(' ✓ EmploymentContract demo (4)');
// Medical profiles:
// emp1 — chirurgie, niciodată examinat
// emp2 — chirurgie, examinat acum 15 luni (expirat)
// emp3 — radiologie, examinat acum 11 luni + radiații (expiră curând)
// emp4 — radiologie, niciodată examinat + radiații
await prisma.employeeMedicalProfile.upsert({
where: { employeeId: emp1.id }, update: {},
create: { employeeId: emp1.id, workplaceRiskCardId: rcChir.id, expusRadiatiiIonizante: false },
});
await prisma.employeeMedicalProfile.upsert({
where: { employeeId: emp2.id }, update: {},
create: {
employeeId: emp2.id, workplaceRiskCardId: rcChir.id,
dataUltimControlMedical: new Date('2025-02-14'),
expusRadiatiiIonizante: false,
},
});
const emp3Radiatii = {
workplaceRiskCardId: rcImag.id,
dataUltimControlMedical: new Date('2025-06-14'),
expusRadiatiiIonizante: true,
dataIntrarii: new Date('2019-02-01'),
expunereAnterioaraPerioda: '20152018',
expunereAnterioaraAni: 3,
dozaCumulataExternaMsv: 4.2500,
dozaCumulataInternaMsv: 0.8000,
};
const emp3Supra = [
{ fel: 'EXCEPTIONALA' as const, tipExpunere: 'X externă', data: new Date('2023-05-12'), dozaMsv: 2.5000 },
{ fel: 'ACCIDENTALA' as const, tipExpunere: 'gamma externă', data: new Date('2024-09-03'), dozaMsv: 1.2000 },
];
await prisma.employeeMedicalProfile.upsert({
where: { employeeId: emp3.id },
update: { ...emp3Radiatii, overexposures: { deleteMany: {}, create: emp3Supra } },
create: { employeeId: emp3.id, ...emp3Radiatii, overexposures: { create: emp3Supra } },
});
await prisma.employeeMedicalProfile.upsert({
where: { employeeId: emp4.id }, update: {},
create: { employeeId: emp4.id, workplaceRiskCardId: rcImag.id, expusRadiatiiIonizante: true },
});
console.log(' ✓ EmployeeMedicalProfile demo (4)');
// Pending checkups for inbox (verdict = null)
// emp1 — la_angajare, acum 5 zile (depășit → roșu)
// emp2 — periodic, peste 3 zile
// emp3 — la_reluarea_activitatii, mâine
const day = (offsetDays: number) => {
const d = new Date('2026-05-14');
d.setDate(d.getDate() + offsetDays);
return d;
};
for (const [empId, tip, offset] of [
[emp1.id, 'la_angajare', -5],
[emp2.id, 'periodic', 3],
[emp3.id, 'la_reluarea_activitatii', 1],
] as [string, string, number][]) {
const exists = await prisma.medicalCheckup.findFirst({ where: { employeeId: empId, verdict: null } });
if (!exists) {
await prisma.medicalCheckup.create({
data: { employeeId: empId, tip: tip as never, dataPlanificata: day(offset) },
});
}
}
console.log(' ✓ MedicalCheckup demo — pending inbox (3)');
// ── Evaluation campaigns demo (modulul de evaluare nursing) ──────
// Campania A — Chirurgie Generală, IN_PROGRESS:
// emp2 (Ionescu Maria) — formular complet, scoruri bune + 1 criteriu EXPERT
// → categorie calculată "superioara", ÎNCĂ NEAPROBATĂ
// (nursing_director o poate aproba — demo aprobare)
// emp1 (Popescu Alexandru) — formular parțial (în lucru) → "fara"
if (chirGenDept) {
const campMonth = new Date('2026-05-01');
let camp = await prisma.evaluationCampaign.findFirst({
where: { departmentId: chirGenDept.id, month: campMonth },
});
if (!camp) {
camp = await prisma.evaluationCampaign.create({
data: {
name: 'Evaluare anuală nursing — Chirurgie Generală 2026',
departmentId: chirGenDept.id,
month: campMonth,
status: 'in_progress',
},
});
}
await prisma.evaluationForm.upsert({
where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp2.id } },
update: {},
create: {
campaignId: camp.id, employeeId: emp2.id,
abilitatiClinice: 'bine', judecataClinica: 'bine', manopere: 'bine', gestionareaSarcinilor: 'mediu',
constiintaProfesionala: 'bine', atitudineaPacienti: 'bine', atitudineaColegi: 'bine', atitudineaPersonalNonMed: 'mediu',
utilizareSmartphone: 'bine', respectareaProgramului: 'bine', respectareaDressCode: 'bine',
testJci: { score: 18, max_score: 20, percent: 90, completed_at: '2026-05-10', source: 'academy_ocean', external_id: 'AO-DEMO-001' },
completareaDocMed: true, perfectioneazaCunostinte: true,
membruComitetCalitate: true, functieDeMonitor: false, inlocuiesteSuperiorul: false,
categorieCalculata: 'superioara',
},
});
await prisma.evaluationForm.upsert({
where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp1.id } },
update: {},
create: {
campaignId: camp.id, employeeId: emp1.id,
abilitatiClinice: 'bine', judecataClinica: 'mediu', manopere: 'mediu',
categorieCalculata: 'fara',
},
});
console.log(' ✓ EvaluationCampaign demo — Chirurgie (in_progress, 2 formulare)');
}
// Campania B — Imagistică, CLOSED (istoric read-only):
// emp3 (Rusu Viorel) — formular finalizat și aprobat → "cat_I"
if (imagDept) {
const campMonth = new Date('2025-11-01');
let camp = await prisma.evaluationCampaign.findFirst({
where: { departmentId: imagDept.id, month: campMonth },
});
if (!camp) {
camp = await prisma.evaluationCampaign.create({
data: {
name: 'Evaluare anuală nursing — Imagistică 2025',
departmentId: imagDept.id,
month: campMonth,
status: 'closed',
},
});
}
await prisma.evaluationForm.upsert({
where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp3.id } },
update: {},
create: {
campaignId: camp.id, employeeId: emp3.id,
abilitatiClinice: 'bine', judecataClinica: 'bine', manopere: 'bine', gestionareaSarcinilor: 'bine',
constiintaProfesionala: 'bine', atitudineaPacienti: 'mediu', atitudineaColegi: 'bine', atitudineaPersonalNonMed: 'bine',
utilizareSmartphone: 'bine', respectareaProgramului: 'bine', respectareaDressCode: 'mediu',
completareaDocMed: true, perfectioneazaCunostinte: true,
membruComitetCalitate: false, functieDeMonitor: false, inlocuiesteSuperiorul: false,
categorieCalculata: 'cat_I',
categorieAprobata: 'cat_I',
observatii: 'Performanță constantă, recomandat pentru categoria I.',
completedAt: new Date('2025-11-20'),
},
});
console.log(' ✓ EvaluationCampaign demo — Imagistică (closed, 1 formular aprobat)');
}
// Benefit cu vestimentație pentru emp1
if (uniformaS && halatM) {
await prisma.benefit.upsert({
where: { employeeId: emp1.id },
update: {},
create: {
employeeId: emp1.id,
uniformaId: uniformaS.id,
halatId: halatM.id,
ticheteMasa: true,
valoareTichet: 65,
alimentatiePersonal: false,
abonamentTel: 150,
},
});
console.log(' ✓ Benefit demo (1) — Popescu Alexandru: uniformă + halat');
}
console.log('\n✅ Seed complete.');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+226
View File
@@ -0,0 +1,226 @@
/**
* Generează BOLĂVANKE (stub) .docx pentru Anexele 3/4/4A/4B/6 cu TOATE placeholder-ele
* docxtemplater din `templates/docx/README.md`. Formatarea o ajustați apoi în Word.
*
* Rulare: pnpm --filter api exec ts-node scripts/generate-docx-stubs.ts
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
HeadingLevel, WidthType, BorderStyle,
} from 'docx';
const OUT = join(__dirname, '..', 'templates', 'docx');
mkdirSync(OUT, { recursive: true });
// ── helpers ──
const T = (text: string, bold = false) => new TextRun({ text, bold });
const ph = (name: string) => new TextRun({ text: `{${name}}`, bold: true, color: '0B6E70' });
const P = (...children: TextRun[]) => new Paragraph({ children });
const H = (text: string, level: (typeof HeadingLevel)[keyof typeof HeadingLevel] = HeadingLevel.HEADING_2) =>
new Paragraph({ heading: level, children: [T(text, true)] });
const empty = () => new Paragraph({ children: [] });
// "Label {ph}"
const line = (label: string, name: string) => P(T(label + ' '), ph(name));
// checkbox: "{cbX} Label"
const cb = (name: string, label: string) => [ph(name), T(' ' + label + ' ')];
const cbLine = (...pairs: [string, string][]) =>
P(...pairs.flatMap(([n, l]) => cb(n, l)));
const BORDER = {
top: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
left: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
right: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
};
const cell = (children: Paragraph[]) => new TableCell({ children, borders: BORDER });
const headerRow = (labels: string[]) =>
new TableRow({ children: labels.map((l) => cell([P(T(l, true))])) });
/**
* Tabel repetabil: rândul-șablon repetă pentru fiecare element din `loop`.
* `{#loop}` în prima celulă, `{/loop}` în ultima.
*/
function loopTable(loop: string, headers: string[], rowFields: string[]): Table {
const tplCells = rowFields.map((f, i) => {
const runs: TextRun[] = [];
if (i === 0) runs.push(new TextRun({ text: `{#${loop}}`, bold: true, color: 'B11116' }));
runs.push(ph(f));
if (i === rowFields.length - 1) runs.push(new TextRun({ text: `{/${loop}}`, bold: true, color: 'B11116' }));
return cell([P(...runs)]);
});
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [headerRow(headers), new TableRow({ children: tplCells })],
});
}
function save(name: string, children: (Paragraph | Table)[]) {
const doc = new Document({ sections: [{ children }] });
return Packer.toBuffer(doc).then((buf) => {
writeFileSync(join(OUT, name), buf);
console.log(' ✓', name, `(${buf.length} bytes)`);
});
}
// ════════════════════════ ANEXA 3 ════════════════════════
const anexa3: (Paragraph | Table)[] = [
H('FIȘA de solicitare a examenului medical'),
line('Unitatea economică/instituția:', 'unitatea'),
P(T('IDNO: '), ph('idno'), T(' Adresa: '), ph('adresa')),
P(T('Telefon: '), ph('telefon'), T(' Fax: '), ph('fax'), T(' E-mail: '), ph('email')),
P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' Telefon: '), ph('telefonFiliala')),
empty(),
loopTable('angajati',
['Nr.', 'Numele și prenumele', 'Anul nașterii', 'IDNP', 'Tipul examenului', 'Ocupația (CORM)', 'CAEM', 'Nr. loc muncă', 'Factorul de risc'],
['nr', 'numePrenume', 'anNastere', 'idnp', 'tipExamen', 'ocupatieCorm', 'caem', 'numarLoc', 'factorRisc']),
empty(),
line('Data completării:', 'dataCompletarii'),
P(T('Solicitant: '), ph('solicitant'), T(' Funcția: '), ph('functia')),
P(T('Semnătura: ____________________')),
];
// ════════════════════════ ANEXA 4 ════════════════════════
const anexa4Header: (Paragraph | Table)[] = [
line('Unitatea economică/instituția:', 'unitatea'),
P(T('Adresa: '), ph('adresa')),
P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' CAEM (2 cifre): '), ph('caem2')),
H('FIȘA de evaluare a riscurilor profesionale'),
line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'),
line('Direcția/secția/sectorul:', 'directiaSectia'),
P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune')),
P(T('Nr. lucrători care pot activa: '), ph('numarLucratori'), T(' Clasa condițiilor de muncă: '), ph('clasa')),
];
const anexa4Descriptiv: (Paragraph | Table)[] = [
H('Descrierea activității', HeadingLevel.HEADING_3),
P(T('Lucru în echipă '), ph('cbEchipa'), T(' Nr. ore/zi: '), ph('oreZi'), T(' Nr. schimburi: '), ph('schimburi')),
cbLine(['cbSchimbNoapte', 'schimb de noapte'], ['cbPauze', 'pauze organizate']),
P(T('Riscuri:')),
cbLine(['cbInfectare', 'infectare'], ['cbElectrocutare', 'electrocutare'], ['cbTensiuneInalta', 'tensiune înaltă'], ['cbInecare', 'înecare'], ['cbAsfixiere', 'asfixiere']),
cbLine(['cbStrivire', 'strivire'], ['cbTaiere', 'tăiere'], ['cbIntepare', 'înțepare'], ['cbLovire', 'lovire'], ['cbMuscatura', 'mușcătură'], ['cbMicrotraumatisme', 'microtraumatisme']),
P(T('Conduce mașina '), ph('cbConduceMasina'), T(' categorie: '), ph('categorieConducere'), T(' '), ph('cbUtilajeIntrauzinal'), T(' utilaje intrauzinal')),
H('Descrierea spațiului de lucru', HeadingLevel.HEADING_3),
P(T('Dimensiuni: L '), ph('spatiuL'), T(' l '), ph('spatiul'), T(' H '), ph('spatiuH'), T(' m')),
cbLine(['cbSuprafVerticala', 'suprafață verticală'], ['cbSuprafOrizontala', 'orizontală'], ['cbSuprafOblica', 'oblică']),
cbLine(['cbMuncaIzolare', 'în izolare'], ['cbMuncaInaltime', 'la înălțime'], ['cbMuncaMiscare', 'în mișcare']),
H('Efort fizic', HeadingLevel.HEADING_3),
P(T('Poziție: ')),
cbLine(['cbPozitieOrtostatica', 'ortostatică'], ['cbPozitieAsezat', 'așezat'], ['cbPozitieAplecata', 'aplecată'], ['cbPozitieMixta', 'mixtă'], ['cbPozitieFortata', 'forțată']),
P(T('Suprasolicitări coloană: ')),
cbLine(['cbColoanaCervicala', 'cervicală'], ['cbColoanaToracala', 'toracală'], ['cbColoanaLombara', 'lombară']),
P(T('Manipulare manuală: ')),
cbLine(['cbManipRidicare', 'ridicare'], ['cbManipCoborare', 'coborâre'], ['cbManipImpingere', 'împingere'], ['cbManipTragere', 'tragere'], ['cbManipPurtare', 'purtare'], ['cbManipDeplasare', 'deplasare']),
P(T('Greutate maximă manipulată: '), ph('greutateMaxima')),
cbLine(['cbVizuale', 'suprasolicitări vizuale'], ['cbAuditive', 'auditive'], ['cbNeuropsihice', 'neuropsihice']),
];
const factorTables: (Paragraph | Table)[] = [
H('AGENȚI CHIMICI', HeadingLevel.HEADING_3),
loopTable('chimici', ['Agentul chimic', 'CAS', 'EINECS', 'Timp', 'VEP', 'VLEP', 'Caracteristici'],
['denumire', 'cas', 'einecs', 'timp', 'vep', 'vlep', 'caracteristici']),
H('PULBERI', HeadingLevel.HEADING_3),
loopTable('pulberi', ['Pulberi', 'CAS', 'EINECS', 'Timp', 'VEP', 'VLEP', 'Caracteristici'],
['denumire', 'cas', 'einecs', 'timp', 'vep', 'vlep', 'caracteristici']),
H('AGENȚI BIOLOGICI', HeadingLevel.HEADING_3),
loopTable('biologici', ['Agent biologic', 'Clasificare', 'Note'], ['denumire', 'clasificare', 'note']),
H('ZGOMOT PROFESIONAL', HeadingLevel.HEADING_3),
loopTable('zgomot', ['Tipul', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'timp', 'vep', 'vlep', 'caracteristici']),
H('VIBRAȚII MECANICE', HeadingLevel.HEADING_3),
loopTable('vibratii', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']),
H('CÂMP ELECTROMAGNETIC', HeadingLevel.HEADING_3),
loopTable('campEM', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']),
H('RADIAȚII OPTICE ARTIFICIALE', HeadingLevel.HEADING_3),
loopTable('optice', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']),
];
const anexa4Footer: (Paragraph | Table)[] = [
H('MICROCLIMAT / RADIAȚII / ILUMINAT', HeadingLevel.HEADING_3),
cbLine(['cbMicroclimatInterior', 'interior'], ['cbMicroclimatExterior', 'exterior'], ['cbCaloriceRece', 'rad. calorice (rece)'], ['cbCaloriceCalda', 'rad. calorice (caldă)']),
P(T('Radiații ionizante '), ph('cbRadiatii'), T(' Grupa: '), ph('radGrupa'), T(' Surse: '), ph('radSurse')),
P(T('Tip expunere: '), ph('radTipExpunere'), T(' Aparatură: '), ph('radAparatura'), T(' Măsuri: '), ph('radMasuri')),
cbLine(['cbIluminatSuficient', 'iluminat suficient'], ['cbIluminatInsuficient', 'insuficient'], ['cbIluminatNatural', 'natural'], ['cbIluminatArtificial', 'artificial'], ['cbIluminatMixt', 'mixt']),
H('Protecție și dotări', HeadingLevel.HEADING_3),
line('Mijloace de protecție colectivă:', 'protectieColectiva'),
line('Mijloace de protecție individuală:', 'protectieIndividuala'),
line('Echipament de lucru:', 'echipament'),
P(T('Anexe igienico-sanitare: ')),
cbLine(['cbVestiar', 'vestiar'], ['cbChiuveta', 'chiuvetă'], ['cbWc', 'WC'], ['cbDus', 'duș'], ['cbSalaMese', 'sală de mese'], ['cbRecreere', 'recreere']),
line('Observații:', 'observatii'),
line('Data completării:', 'dataCompletarii'),
P(T('Angajatorul (nume, prenume, semnătura): ____________________')),
P(new TextRun({ text: 'Instrucțiuni: răspuns afirmativ [☑]; răspuns negativ [☐].', italics: true })),
];
// ════════════════════════ ANEXA 4A ════════════════════════
const anexa4a: (Paragraph | Table)[] = [
line('Unitatea economică/instituția:', 'unitatea'),
P(T('Adresa: '), ph('adresa'), T(' Filiala: '), ph('filiala'), T(' CAEM (2 cifre): '), ph('caem2')),
H('FIȘA de evaluare — muncă la distanță / platforme digitale'),
line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'),
line('Direcția/secția/sectorul:', 'directiaSectia'),
P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune'), T(' Clasa: '), ph('clasa')),
H('Descrierea activității', HeadingLevel.HEADING_3),
P(T('Lucru în echipă '), ph('cbEchipa'), T(' Nr. ore/zi: '), ph('oreZi'), T(' Nr. schimburi: '), ph('schimburi')),
cbLine(['cbSchimbNoapte', 'schimb de noapte'], ['cbPauze', 'pauze organizate'], ['cbLucruMonitor', 'lucru la monitor'], ['cbPlatformeDigitale', 'platforme digitale']),
P(T('Conduce mașina '), ph('cbConduceMasina'), T(' categorie: '), ph('categorieConducere')),
line('Operațiuni executate:', 'operatiuni'),
P(T('Deplasări pe teren '), ph('cbDeplasari'), T(' '), ph('deplasariDescriere')),
H('Efort fizic', HeadingLevel.HEADING_3),
P(T('Manipulare manuală: ')),
cbLine(['cbManipRidicare', 'ridicare'], ['cbManipCoborare', 'coborâre'], ['cbManipImpingere', 'împingere'], ['cbManipTragere', 'tragere'], ['cbManipPurtare', 'purtare'], ['cbManipDeplasare', 'deplasare']),
P(T('Greutate maximă: '), ph('greutateMaxima')),
cbLine(['cbVizuale', 'vizuale'], ['cbAuditive', 'auditive'], ['cbNeuropsihice', 'neuropsihice']),
line('Alte riscuri:', 'alteRiscuri'),
line('Data completării:', 'dataCompletarii'),
P(T('Angajatorul (nume, prenume, semnătura): ____________________')),
];
// ════════════════════════ ANEXA 4B ════════════════════════
const anexa4b: (Paragraph | Table)[] = [
line('Unitatea economică/instituția:', 'unitatea'),
P(T('Adresa: '), ph('adresa'), T(' Telefon: '), ph('telefon'), T(' Fax: '), ph('fax'), T(' E-mail: '), ph('email')),
P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' CAEM (2 cifre): '), ph('caem2')),
H('SUPLIMENT la Fișa de evaluare a riscurilor profesionale'),
line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'),
line('Direcția/secția/sectorul:', 'directiaSectia'),
P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune')),
P(T('Numele, prenumele lucrătorului: '), ph('numePrenume'), T(' IDNP: '), ph('idnp')),
P(T('RADIAȚII IONIZANTE: '), ph('cbRadiatii')),
line('Data intrării în mediul cu expunere:', 'dataIntrarii'),
P(T('Expunere anterioară — perioada: '), ph('expAnterioaraPerioada'), T(' ani: '), ph('expAnterioaraAni')),
P(T('Doză externă (mSv): '), ph('dozaExterna'), T(' Doză internă (mSv): '), ph('dozaInterna'), T(' Doză totală (mSv): '), ph('dozaTotala')),
H('Supraexpuneri excepționale', HeadingLevel.HEADING_3),
loopTable('supraexpExceptionale', ['Tip de expunere', 'Data', 'Doză (mSv)'], ['tipExpunere', 'data', 'doza']),
H('Supraexpuneri accidentale', HeadingLevel.HEADING_3),
loopTable('supraexpAccidentale', ['Tip de expunere', 'Data', 'Doză (mSv)'], ['tipExpunere', 'data', 'doza']),
line('Data completării:', 'dataCompletarii'),
P(T('Angajatorul (nume, prenume, semnătura): ____________________')),
];
// ════════════════════════ ANEXA 6 ════════════════════════
const anexa6: (Paragraph | Table)[] = [
H('FIȘĂ DE APTITUDINE ÎN MUNCĂ'),
line('Unitatea:', 'unitatea'),
P(T('Angajat: '), ph('numePrenume'), T(' IDNP: '), ph('idnp'), T(' Anul nașterii: '), ph('anNastere')),
P(T('Ocupația: '), ph('ocupatieCorm'), T(' Departament: '), ph('departament')),
P(T('Tipul examenului: '), ph('tipExamen'), T(' Data: '), ph('dataCompletarii')),
H('Verdict', HeadingLevel.HEADING_3),
P(ph('cbApt'), T(' Apt')),
P(ph('cbAptAdaptare'), T(' Apt în perioada de adaptare')),
P(ph('cbAptConditionat'), T(' Apt condiționat')),
P(ph('cbInaptTemporar'), T(' Inapt temporar')),
P(ph('cbInapt'), T(' Inapt')),
line('Recomandări:', 'recomandari'),
line('Valabil până la:', 'valabilPanaLa'),
P(T('Semnătura medicului: '), ph('semnatDe')),
];
async function main() {
console.log('📄 Generez bolăvanke .docx în', OUT);
await save('anexa-3.docx', anexa3);
await save('anexa-4.docx', [...anexa4Header, ...anexa4Descriptiv, ...factorTables, ...anexa4Footer]);
await save('anexa-4a.docx', anexa4a);
await save('anexa-4b.docx', anexa4b);
await save('anexa-6.docx', anexa6);
console.log('✅ Gata. Editați formatarea în Word — placeholder-ele rămân ca {nume}.');
}
main().catch((e) => { console.error(e); process.exit(1); });
+662
View File
@@ -0,0 +1,662 @@
import {
AnexaType,
CampaignStatus,
ContractCategory,
ContractPeriod,
ContractType,
DisciplinarySanctionType,
DiplomaStatus,
DocumentType,
EmployeeStatus,
EvaluationScore,
FamilyMemberType,
InventoryItemType,
MedicalCheckupType,
MedicalVerdict,
OverexposureKind,
PrismaClient,
ProposedCategory,
QualificationCategory,
RiskExposureType,
SalaryType,
ScientificTitle,
Sex,
StudyLevel,
PostUniversityType,
StudyType,
TrainingType,
} from '@prisma/client';
const prisma = new PrismaClient();
function dbNameFromUrl(url: string): string {
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
}
function requireTemporaryDatabase() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required for test seed');
const dbName = dbNameFromUrl(url);
if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') {
throw new Error(`Refusing to seed non-test database "${dbName}". Expected hrm_medpark_test_*.`);
}
return dbName;
}
const d = (value: string) => new Date(`${value}T00:00:00.000Z`);
async function resetData() {
await prisma.auditLog.deleteMany();
await prisma.anexaTemplateVersion.deleteMany();
await prisma.anexaTemplate.deleteMany();
await prisma.radiationOverexposure.deleteMany();
await prisma.medicalCheckup.deleteMany();
await prisma.employeeMedicalProfile.deleteMany();
await prisma.workplaceRiskExposure.deleteMany();
await prisma.workplaceRiskCard.deleteMany();
await prisma.evaluationForm.deleteMany();
await prisma.evaluationCampaign.deleteMany();
await prisma.cimServiceCategory.deleteMany();
await prisma.employmentContract.deleteMany();
await prisma.benefit.deleteMany();
await prisma.disciplinarySanction.deleteMany();
await prisma.training.deleteMany();
await prisma.qualification.deleteMany();
await prisma.education.deleteMany();
await prisma.familyMember.deleteMany();
await prisma.identityDocument.deleteMany();
await prisma.employee.deleteMany();
await prisma.inventoryItem.deleteMany();
await prisma.department.deleteMany();
await prisma.workSchedule.deleteMany();
await prisma.taxExemption.deleteMany();
await prisma.disabilityGrade.deleteMany();
}
async function main() {
const dbName = requireTemporaryDatabase();
console.log(`Seeding Medpark test data into ${dbName}...`);
await resetData();
const disability = await prisma.disabilityGrade.create({
data: { code: 'TEST-GR-I', name: 'Grad dizabilitate I - test' },
});
const childTax = await prisma.taxExemption.create({
data: { code: 'TEST-SCUTIRE-COPIL', description: 'Scutire copil - test' },
});
const schedule = await prisma.workSchedule.create({
data: { name: 'Test 5/2 8h', daysWork: 5, daysRest: 2, hoursPerDay: 8 },
});
const shiftSchedule = await prisma.workSchedule.create({
data: { name: 'Test 12/24', daysWork: 1, daysRest: 1, hoursPerDay: 12 },
});
const root = await prisma.department.create({ data: { name: 'Medpark Test', code: 'TEST_ROOT' } });
const surgeryDept = await prisma.department.create({
data: { name: 'Chirurgie Test', code: 'TEST_CHIR', parentId: root.id },
});
const radiologyDept = await prisma.department.create({
data: { name: 'Radiologie Test', code: 'TEST_RAD', parentId: root.id },
});
const remoteDept = await prisma.department.create({
data: { name: 'Administrativ Digital Test', code: 'TEST_REMOTE', parentId: root.id },
});
const labDept = await prisma.department.create({
data: { name: 'Laborator Test', code: 'TEST_LAB', parentId: root.id },
});
const uniform = await prisma.inventoryItem.create({
data: { sku: 'TEST-UNIFORM-M', name: 'Uniformă test M', type: InventoryItemType.uniforma, size: 'M', color: 'teal', stockQty: 20 },
});
const coat = await prisma.inventoryItem.create({
data: { sku: 'TEST-HALAT-L', name: 'Halat test L', type: InventoryItemType.halat, size: 'L', color: 'alb', stockQty: 20 },
});
const shoes = await prisma.inventoryItem.create({
data: { sku: 'TEST-CIUPICI-40', name: 'Ciupici test 40', type: InventoryItemType.ciupici, size: '40', color: 'alb', stockQty: 20 },
});
const phone = await prisma.inventoryItem.create({
data: { sku: 'TEST-PHONE-A15', name: 'Telefon test Samsung A15', type: InventoryItemType.aparat_telefon, stockQty: 5 },
});
const commonEval = {
echipa: true,
oreZi: '8',
schimburi: '2',
schimbNoapte: true,
pauzeOrganizate: true,
riscInfectare: true,
riscElectrocutare: true,
riscTensiuneInalta: false,
riscInecare: false,
riscAsfixiere: false,
riscStrivire: true,
riscTaiere: true,
riscIntepare: true,
riscLovire: true,
riscMuscatura: false,
riscMicrotraumatisme: true,
conduceMasina: true,
conduceMasinaCategorie: 'B',
conduceUtilajeIntrauzinal: false,
spatiuL: '4',
spatiul: '5',
spatiuH: '3',
suprafataVerticala: false,
suprafataOrizontala: true,
suprafataOblica: false,
muncaIzolare: false,
muncaInaltime: true,
muncaInMiscare: true,
pozitieOrtostatica: true,
pozitieAsezat: false,
pozitieAplecata: true,
pozitieMixta: true,
pozitieFortata: false,
coloanaCervicala: true,
coloanaToracala: true,
coloanaLombara: true,
manipulareRidicare: true,
manipulareCoborare: true,
manipulareImpingere: true,
manipulareTragere: false,
manipularePurtare: true,
manipulareDeplasare: true,
greutateMaxima: '15 kg',
suprasolicitariVizuale: true,
suprasolicitariAuditive: true,
suprasolicitariNeuropsihice: true,
microclimatInterior: true,
microclimatExterior: false,
radiatiiCaloriceRece: false,
radiatiiCaloriceCalda: false,
iluminatSuficient: true,
iluminatInsuficient: false,
iluminatNatural: true,
iluminatArtificial: true,
iluminatMixt: true,
};
const surgeryCard = await prisma.workplaceRiskCard.create({
data: {
name: 'Test - Medic profil chirurgical cu gărzi de noapte',
riskFactors: { source: 'Control medical (5).docx', categories: ['chimici', 'biologici', 'fizici', 'ergonomici'] },
filiala: 'Sediul central',
adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău',
telefonFiliala: '+373 22 000 101',
caemPrimeleDouaCifre: '86',
cormSubgrupaMajora: 'Personal medical profil chirurgical',
directiaSectiaSectorul: 'Bloc operator / Chirurgie',
numarulLoculuiDeMunca: 'TEST-CHIR-01',
caemDiviziune: '86.10',
clasaConditiilorDeMunca: '3.2',
numarLucratoriPosibili: 12,
tipFisa: 'STANDARD',
evaluareDetalii: commonEval,
radiatiiIonizante: false,
mijloaceProtectieColectiva: 'Ventilație locală, containere pentru obiecte ascuțite',
mijloaceProtectieIndividuala: 'Mănuși, mască, halat steril, vizieră',
echipamentLucru: 'Uniformă chirurgicală',
anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true, recreere: true },
observatii: 'Set complet pentru testarea Anexa 4.',
exposures: {
create: [
{ tip: RiskExposureType.AGENT_CHIMIC, denumire: 'Glutaraldehidă', cas: '111-30-8', einecs: '203-856-5', timpExpunere: '2 h/zi', vep: '0,03 ppm', vlep: '0,1 ppm', caracteristici: 'iritant respirator' },
{ tip: RiskExposureType.PULBERI, denumire: 'Pulberi textile sterile', cas: '—', einecs: '—', timpExpunere: '1 h/zi', vep: '2 mg/m3', vlep: '5 mg/m3', caracteristici: 'pulberi inhalabile' },
{ tip: RiskExposureType.AGENT_BIOLOGIC, denumire: 'HBV/HCV/HIV', clasificare: 'grupa 3', caracteristici: 'risc prin înțepare/tăiere' },
{ tip: RiskExposureType.ZGOMOT, denumire: 'Echipamente bloc operator', timpExpunere: '4 h/zi', vep: '80 dB', vlep: '87 dB', caracteristici: 'zgomot intermitent' },
{ tip: RiskExposureType.VIBRATII, denumire: 'Instrumentar oscilant', zonaAfectata: 'mână-braț', timpExpunere: '30 min/zi', vep: '2,5 m/s2', vlep: '5 m/s2', caracteristici: 'vibrații locale' },
{ tip: RiskExposureType.CAMP_ELECTROMAGNETIC, denumire: 'Electrocauter', zonaAfectata: 'corp întreg', timpExpunere: '1 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'câmp EM local' },
{ tip: RiskExposureType.RADIATII_OPTICE, denumire: 'Lămpi chirurgicale', zonaAfectata: 'ochi', timpExpunere: '6 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'lumină intensă' },
],
},
},
});
const radiologyCard = await prisma.workplaceRiskCard.create({
data: {
name: 'Test - Radiologie cu radiații ionizante',
riskFactors: { source: 'Control medical (5).docx', categories: ['radiații ionizante', 'câmp electromagnetic'] },
filiala: 'Sediul central',
adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău',
telefonFiliala: '+373 22 000 102',
caemPrimeleDouaCifre: '86',
cormSubgrupaMajora: 'Personal imagistică medicală',
directiaSectiaSectorul: 'Diagnostic / Radiologie',
numarulLoculuiDeMunca: 'TEST-RAD-01',
caemDiviziune: '86.90',
clasaConditiilorDeMunca: '3.3',
numarLucratoriPosibili: 8,
tipFisa: 'STANDARD',
evaluareDetalii: { ...commonEval, riscInfectare: false, pozitieAsezat: true, pozitieOrtostatica: false },
radiatiiIonizante: true,
radiatiiGrupa: 'A',
radiatiiAparatura: 'CT, Rx digital',
radiatiiSurse: 'închise',
radiatiiTipExpunere: 'X externă',
radiatiiMasuriProtectie: 'Ecran de protecție, șorț plumb, dozimetru individual',
mijloaceProtectieColectiva: 'Ecrane plumbate și semnalizare zonă controlată',
mijloaceProtectieIndividuala: 'Șorț plumb, ochelari, dozimetru',
echipamentLucru: 'Uniformă radiologie',
anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true, recreere: false },
exposures: {
create: [
{ tip: RiskExposureType.CAMP_ELECTROMAGNETIC, denumire: 'Câmp electromagnetic RMN', zonaAfectata: 'corp întreg', timpExpunere: '4 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'câmp magnetic static intens' },
{ tip: RiskExposureType.ZGOMOT, denumire: 'Aparatură imagistică', timpExpunere: '3 h/zi', vep: '75 dB', vlep: '87 dB', caracteristici: 'zgomot tehnic' },
],
},
},
});
const remoteCard = await prisma.workplaceRiskCard.create({
data: {
name: 'Test - Activități administrative la distanță',
riskFactors: { source: 'Control medical (5).docx', categories: ['vizual', 'neuropsihic', 'platforme digitale'] },
filiala: 'Sediul central',
adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău',
telefonFiliala: '+373 22 000 103',
caemPrimeleDouaCifre: '86',
cormSubgrupaMajora: 'Personal administrativ',
directiaSectiaSectorul: 'Administrativ / Digital',
numarulLoculuiDeMunca: 'TEST-REMOTE-01',
caemDiviziune: '86.90',
clasaConditiilorDeMunca: '2',
numarLucratoriPosibili: 4,
tipFisa: 'DISTANTA_DIGITAL',
evaluareDetalii: {
echipa: false,
oreZi: '8',
schimburi: '1',
schimbNoapte: false,
pauzeOrganizate: true,
lucruMonitor: true,
platformeDigitale: true,
conduceMasina: false,
operatiuni: 'Operare HIS, e-mail, raportare digitală',
deplasari: true,
deplasariDescriere: 'Deplasări ocazionale la sediu',
manipulareRidicare: false,
manipulareCoborare: false,
manipulareImpingere: false,
manipulareTragere: false,
manipularePurtare: false,
manipulareDeplasare: false,
greutateMaxima: 'sub 3 kg',
suprasolicitariVizuale: true,
suprasolicitariAuditive: false,
suprasolicitariNeuropsihice: true,
alteRiscuri: 'Lucru prelungit la monitor',
},
radiatiiIonizante: false,
mijloaceProtectieIndividuala: 'Scaun ergonomic, monitor extern',
echipamentLucru: 'Laptop corporativ',
anexeIgienicoSanitare: { vestiar: false, chiuveta: true, wc: true, dus: false, salaMese: false, recreere: true },
observatii: 'Set pentru testarea Anexa 4A.',
},
});
const employees = await Promise.all([
prisma.employee.create({
data: {
idnp: '1985061500016',
nume: 'Popescu',
prenume: 'Alexandru',
patronimic: 'Ion',
dataNasterii: d('1985-06-15'),
domiciliu: 'mun. Chișinău, str. Ștefan cel Mare 1',
adresaReala: 'mun. Chișinău, str. Test 1',
telefonPersonal: '+37369100001',
telefonServiciu: '+37322100001',
emailPersonal: 'alexandru.popescu.test@example.com',
emailCorporativ: 'alexandru.popescu@medpark.test',
sex: Sex.M,
codCpas: 'CPAS-001',
stareCivila: 'casatorit',
titluStiintific: ScientificTitle.doctor,
status: EmployeeStatus.activ,
},
}),
prisma.employee.create({
data: {
idnp: '1990032200017',
nume: 'Ionescu',
prenume: 'Maria',
patronimic: 'Vasile',
dataNasterii: d('1990-03-22'),
domiciliu: 'mun. Chișinău, str. Mihai Viteazul 5',
telefonPersonal: '+37369100002',
telefonServiciu: '+37322100002',
emailCorporativ: 'maria.ionescu@medpark.test',
sex: Sex.F,
status: EmployeeStatus.activ,
gradDizabilitateId: disability.id,
},
}),
prisma.employee.create({
data: {
idnp: '1978110800016',
nume: 'Rusu',
prenume: 'Viorel',
dataNasterii: d('1978-11-08'),
domiciliu: 'mun. Chișinău, str. Alba Iulia 12',
telefonPersonal: '+37369100003',
emailCorporativ: 'viorel.rusu@medpark.test',
sex: Sex.M,
status: EmployeeStatus.activ,
},
}),
prisma.employee.create({
data: {
idnp: '2001091400010',
nume: 'Cojocaru',
prenume: 'Elena',
dataNasterii: d('2001-09-14'),
domiciliu: 'mun. Chișinău, str. Trandafirilor 3',
telefonPersonal: '+37369100004',
emailCorporativ: 'elena.cojocaru@medpark.test',
sex: Sex.F,
status: EmployeeStatus.activ,
},
}),
prisma.employee.create({
data: {
idnp: '1995120100019',
nume: 'Munteanu',
prenume: 'Ana',
dataNasterii: d('1995-12-01'),
domiciliu: 'mun. Chișinău, bd. Dacia 20',
telefonPersonal: '+37369100005',
emailCorporativ: 'ana.munteanu@medpark.test',
sex: Sex.F,
status: EmployeeStatus.activ,
},
}),
prisma.employee.create({
data: {
idnp: '1989020300012',
nume: 'Lungu',
prenume: 'Sergiu',
dataNasterii: d('1989-02-03'),
domiciliu: 'mun. Chișinău, str. Laboratorului 7',
telefonPersonal: '+37369100006',
emailCorporativ: 'sergiu.lungu@medpark.test',
sex: Sex.M,
status: EmployeeStatus.activ,
},
}),
]);
const [surgeon, nurse, radiologist, radiologyNurse, remoteAdmin, labDoctor] = employees;
await prisma.identityDocument.createMany({
data: employees.map((employee, index) => ({
employeeId: employee.id,
tipAct: DocumentType.buletin_de_identitate,
seria: `T${index + 1}`,
nr: `TESTDOC${index + 1}`,
dataEmiterii: d('2021-01-10'),
autoritateEmitenta: 'ASP Test',
dataExpirarii: d(`2031-01-${10 + index}`),
})),
});
await prisma.familyMember.createMany({
data: [
{ employeeId: surgeon.id, tip: FamilyMemberType.contact_principal, numePrenume: 'Popescu Elena', telefon: '+37368111111' },
{ employeeId: surgeon.id, tip: FamilyMemberType.copil, numePrenume: 'Popescu Andrei', dataNasterii: d('2015-05-20'), idnp: '2015052000015', tipScutireId: childTax.id },
{ employeeId: nurse.id, tip: FamilyMemberType.mama, numePrenume: 'Ionescu Tatiana', telefon: '+37368222222' },
],
});
await prisma.education.createMany({
data: [
{ employeeId: surgeon.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Chirurgie', dataAbsolvirii: d('2008-06-30'), nrSeriaDiploma: 'DIP-TEST-001', dataEmiterii: d('2008-07-10'), nrInregistrare: 'REG-001', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat },
{ employeeId: nurse.id, tipStudii: StudyType.medii_de_specialitate, institutia: 'Colegiul Național de Medicină', specialitatea: 'Nursing', dataAbsolvirii: d('2012-06-30'), nrSeriaDiploma: 'DIP-TEST-002', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza },
{ employeeId: radiologist.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Radiologie', dataAbsolvirii: d('2002-06-30'), nrSeriaDiploma: 'DIP-TEST-003', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat },
{ employeeId: radiologyNurse.id, tipStudii: StudyType.medii_de_specialitate, institutia: 'Colegiul Național de Medicină', specialitatea: 'Radiologie', dataAbsolvirii: d('2022-06-30'), nrSeriaDiploma: 'DIP-TEST-004', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza },
{ employeeId: remoteAdmin.id, tipStudii: StudyType.superioare, institutia: 'ASEM', specialitatea: 'Management', dataAbsolvirii: d('2017-06-30'), nrSeriaDiploma: 'DIP-TEST-005', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza },
{ employeeId: labDoctor.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Medicină de laborator', dataAbsolvirii: d('2013-06-30'), nrSeriaDiploma: 'DIP-TEST-006', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat },
],
});
await prisma.qualification.createMany({
data: [
{ employeeId: surgeon.id, categorie: QualificationCategory.superioara, dataObtinerii: d('2020-02-01'), dataUltimeiConfirmari: d('2024-02-01'), dataExpirarii: d('2029-02-01'), specialitate: 'Chirurgie' },
{ employeeId: nurse.id, categorie: QualificationCategory.cat_I, dataObtinerii: d('2021-03-01'), dataUltimeiConfirmari: d('2024-03-01'), dataExpirarii: d('2029-03-01'), specialitate: 'Nursing' },
{ employeeId: radiologist.id, categorie: QualificationCategory.superioara, dataObtinerii: d('2019-04-01'), dataUltimeiConfirmari: d('2024-04-01'), dataExpirarii: d('2029-04-01'), specialitate: 'Radiologie' },
{ employeeId: radiologyNurse.id, categorie: QualificationCategory.cat_II, dataObtinerii: d('2024-05-01'), dataUltimeiConfirmari: d('2024-05-01'), dataExpirarii: d('2029-05-01'), specialitate: 'Radiologie' },
],
});
await prisma.training.createMany({
data: employees.map((employee, index) => ({
employeeId: employee.id,
denumire: `Instruire Control medical test ${index + 1}`,
inceput: d('2026-01-10'),
sfirsit: d('2026-01-12'),
tip: index % 2 === 0 ? TrainingType.intern : TrainingType.extern_RM,
tara: 'Republica Moldova',
nrOre: 16,
organizatia: 'Medpark Academy Test',
certificat: true,
cost: '1000.00',
})),
});
await prisma.disciplinarySanction.createMany({
data: [
{ employeeId: nurse.id, tip: DisciplinarySanctionType.avertisment, dataAplicarii: d('2026-02-01'), dataExpirarii: d('2026-08-01') },
{ employeeId: remoteAdmin.id, tip: DisciplinarySanctionType.mustrare, dataAplicarii: d('2025-09-01'), dataExpirarii: d('2026-03-01'), isStinsa: true },
],
});
const contractRows = [
{ employee: surgeon, dept: surgeryDept, nr: 'TEST-CIM-001', role: 'Chirurg', corm: '221201', card: surgeryCard, schedule: shiftSchedule },
{ employee: nurse, dept: surgeryDept, nr: 'TEST-CIM-002', role: 'Asistentă medicală chirurgie', corm: '222101', card: surgeryCard, schedule: shiftSchedule },
{ employee: radiologist, dept: radiologyDept, nr: 'TEST-CIM-003', role: 'Medic radiolog', corm: '221203', card: radiologyCard, schedule },
{ employee: radiologyNurse, dept: radiologyDept, nr: 'TEST-CIM-004', role: 'Asistentă radiologie', corm: '222102', card: radiologyCard, schedule },
{ employee: remoteAdmin, dept: remoteDept, nr: 'TEST-CIM-005', role: 'Specialist documente digitale', corm: '242101', card: remoteCard, schedule },
{ employee: labDoctor, dept: labDept, nr: 'TEST-CIM-006', role: 'Medic laborator', corm: '221207', card: surgeryCard, schedule },
];
for (const row of contractRows) {
const contract = await prisma.employmentContract.create({
data: {
nrCim: row.nr,
employeeId: row.employee.id,
categorie: ContractCategory.principal,
dataSemnarii: d('2024-01-05'),
dataAngajarii: d('2024-01-15'),
perioada: ContractPeriod.nedeterminata,
functiaClasificator: row.corm,
codFunctie: row.corm,
functiaOrganigrama: row.role,
tipCim: ContractType.de_baza,
departmentId: row.dept.id,
regimMunca: 'normă întreagă',
tipSalarizare: SalaryType.fix,
salarizareDetails: { tip: 'fix', salariu: 15000 + contractRows.indexOf(row) * 500, zileConcediu: 28 },
clausaAditionala: { test: true, source: 'Rubrici necesare (6).xlsx / CIM' },
workScheduleId: row.schedule.id,
categoriiServicii: {
create: [
{ categorieId: `TEST-SERV-${contractRows.indexOf(row) + 1}`, tipRemunerare: 'tarif', sumaNeta: '250.00' },
],
},
},
});
await prisma.auditLog.create({
data: { userId: 'seed-test', userRole: 'hr_admin', action: 'CREATE', entity: 'EmploymentContract', entityId: contract.id },
});
}
await prisma.benefit.createMany({
data: [
{
employeeId: nurse.id,
uniformaId: uniform.id,
halatId: coat.id,
ciupiciId: shoes.id,
ticheteMasa: true,
valoareTichet: '70.00',
alimentatiePersonal: true,
abonamentTel: '150.00',
aparatTelefonId: phone.id,
cardCompanie: 'TEST-CARD-001',
},
{
employeeId: radiologyNurse.id,
uniformaId: uniform.id,
halatId: coat.id,
ticheteMasa: true,
valoareTichet: '70.00',
},
],
});
const profileByEmployeeId = new Map<string, string>();
for (const row of contractRows) {
const radiology = row.card.id === radiologyCard.id;
const profile = await prisma.employeeMedicalProfile.create({
data: {
employeeId: row.employee.id,
ocupatieCorm: `${row.role} (${row.corm})`,
workplaceRiskCardId: row.card.id,
dataUltimControlMedical: row.employee.id === surgeon.id ? null : d(row.employee.id === nurse.id ? '2025-02-01' : '2025-06-01'),
expusRadiatiiIonizante: radiology,
dataIntrarii: radiology ? d('2020-01-15') : null,
expunereAnterioaraPerioda: radiology ? '2017-2019' : null,
expunereAnterioaraAni: radiology ? 3 : null,
dozaCumulataExternaMsv: radiology ? '4.2500' : null,
dozaCumulataInternaMsv: radiology ? '0.8000' : null,
overexposures: radiology && row.employee.id === radiologist.id
? {
create: [
{ fel: OverexposureKind.EXCEPTIONALA, tipExpunere: 'X externă', data: d('2023-05-12'), dozaMsv: '2.5000' },
{ fel: OverexposureKind.ACCIDENTALA, tipExpunere: 'gamma externă', data: d('2024-09-03'), dozaMsv: '1.2000' },
],
}
: undefined,
},
});
profileByEmployeeId.set(row.employee.id, profile.id);
}
await prisma.medicalCheckup.createMany({
data: [
{ employeeId: surgeon.id, tip: MedicalCheckupType.la_angajare, dataPlanificata: d('2026-05-20') },
{ employeeId: nurse.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2026-05-28') },
{ employeeId: radiologist.id, tip: MedicalCheckupType.la_reluarea_activitatii, dataPlanificata: d('2026-05-29') },
{ employeeId: surgeon.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-05-20'), dataEfectuata: d('2025-05-20'), verdict: MedicalVerdict.apt, recomandari: 'Control anual', valabilPanaLa: d('2026-05-20'), semnatDe: 'Dr. Test Apt', documenteGenerate: [{ name: 'Anexa_6_Final_Apt', url: 's3://test/anexa6_apt.docx', type: 'ANEXA_6' }] },
{ employeeId: nurse.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-04-20'), dataEfectuata: d('2025-04-20'), verdict: MedicalVerdict.apt_perioada_adaptare, recomandari: 'Adaptare 30 zile', valabilPanaLa: d('2026-04-20'), semnatDe: 'Dr. Test Adaptare', documenteGenerate: [{ name: 'Anexa_6_Final_Adaptare', url: 's3://test/anexa6_adaptare.docx', type: 'ANEXA_6' }] },
{ employeeId: radiologist.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-03-20'), dataEfectuata: d('2025-03-20'), verdict: MedicalVerdict.apt_conditionat, recomandari: 'Dozimetru obligatoriu', valabilPanaLa: d('2026-03-20'), semnatDe: 'Dr. Test Conditionat', documenteGenerate: [{ name: 'Anexa_6_Final_Conditionat', url: 's3://test/anexa6_conditionat.docx', type: 'ANEXA_6' }] },
{ employeeId: radiologyNurse.id, tip: MedicalCheckupType.suplimentar, dataPlanificata: d('2025-02-20'), dataEfectuata: d('2025-02-20'), verdict: MedicalVerdict.inapt_temporar, recomandari: 'Reevaluare peste 30 zile', valabilPanaLa: d('2025-03-20'), semnatDe: 'Dr. Test Temporar', documenteGenerate: [{ name: 'Anexa_6_Final_Temporar', url: 's3://test/anexa6_temporar.docx', type: 'ANEXA_6' }] },
{ employeeId: remoteAdmin.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-01-20'), dataEfectuata: d('2025-01-20'), verdict: MedicalVerdict.inapt, recomandari: 'Inapt pentru postul curent', valabilPanaLa: d('2025-02-20'), semnatDe: 'Dr. Test Inapt', documenteGenerate: [{ name: 'Anexa_6_Final_Inapt', url: 's3://test/anexa6_inapt.docx', type: 'ANEXA_6' }] },
],
});
const campaign = await prisma.evaluationCampaign.create({
data: {
name: 'Test evaluare anuală nursing - Chirurgie 2026',
departmentId: surgeryDept.id,
month: d('2026-05-01'),
status: CampaignStatus.in_progress,
},
});
await prisma.evaluationForm.createMany({
data: [
{
campaignId: campaign.id,
employeeId: nurse.id,
abilitatiClinice: EvaluationScore.bine,
judecataClinica: EvaluationScore.bine,
manopere: EvaluationScore.bine,
gestionareaSarcinilor: EvaluationScore.mediu,
constiintaProfesionala: EvaluationScore.bine,
atitudineaPacienti: EvaluationScore.bine,
atitudineaColegi: EvaluationScore.bine,
atitudineaPersonalNonMed: EvaluationScore.mediu,
utilizareSmartphone: EvaluationScore.bine,
respectareaProgramului: EvaluationScore.bine,
respectareaDressCode: EvaluationScore.bine,
testJci: { score: 18, max_score: 20, percent: 90, source: 'academy_ocean_test' },
completareaDocMed: true,
perfectioneazaCunostinte: true,
membruComitetCalitate: true,
functieDeMonitor: false,
inlocuiesteSuperiorul: false,
categorieCalculata: ProposedCategory.superioara,
},
{
campaignId: campaign.id,
employeeId: surgeon.id,
abilitatiClinice: EvaluationScore.mediu,
judecataClinica: EvaluationScore.mediu,
manopere: EvaluationScore.bine,
categorieCalculata: ProposedCategory.fara,
},
],
});
const closedCampaign = await prisma.evaluationCampaign.create({
data: {
name: 'Test evaluare nursing - Radiologie 2025',
departmentId: radiologyDept.id,
month: d('2025-11-01'),
status: CampaignStatus.closed,
},
});
await prisma.evaluationForm.create({
data: {
campaignId: closedCampaign.id,
employeeId: radiologyNurse.id,
abilitatiClinice: EvaluationScore.bine,
judecataClinica: EvaluationScore.bine,
manopere: EvaluationScore.bine,
gestionareaSarcinilor: EvaluationScore.bine,
constiintaProfesionala: EvaluationScore.bine,
atitudineaPacienti: EvaluationScore.mediu,
atitudineaColegi: EvaluationScore.bine,
atitudineaPersonalNonMed: EvaluationScore.bine,
utilizareSmartphone: EvaluationScore.bine,
respectareaProgramului: EvaluationScore.bine,
respectareaDressCode: EvaluationScore.mediu,
completareaDocMed: true,
perfectioneazaCunostinte: true,
membruComitetCalitate: false,
functieDeMonitor: false,
inlocuiesteSuperiorul: false,
categorieCalculata: ProposedCategory.cat_I,
categorieAprobata: ProposedCategory.cat_I,
observatii: 'Formular închis pentru test read-only.',
completedAt: d('2025-11-20'),
},
});
for (const type of [AnexaType.ANEXA_3, AnexaType.ANEXA_4, AnexaType.ANEXA_4A, AnexaType.ANEXA_4B, AnexaType.ANEXA_6]) {
await prisma.anexaTemplate.create({
data: {
type,
name: `Test ${type}`,
contentJson: { source: 'templates/docx', note: 'DOCX template is stored on disk' },
updatedById: 'seed-test',
},
});
}
console.log('Seed summary:');
console.log(` employees=${await prisma.employee.count()}`);
console.log(` workplaceRiskCards=${await prisma.workplaceRiskCard.count()}`);
console.log(` riskExposures=${await prisma.workplaceRiskExposure.count()}`);
console.log(` medicalCheckups=${await prisma.medicalCheckup.count()}`);
console.log(` evaluationForms=${await prisma.evaluationForm.count()}`);
console.log(` profiles=${profileByEmployeeId.size}`);
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+296
View File
@@ -0,0 +1,296 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawn, ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { PrismaClient } from '@prisma/client';
type CommandEnv = NodeJS.ProcessEnv;
function loadEnvFile(filePath: string) {
if (!existsSync(filePath)) return;
const content = readFileSync(filePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, rawValue] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = rawValue.trim().replace(/^['"]|['"]$/g, '');
}
}
function loadLocalEnv() {
loadEnvFile(resolve(process.cwd(), '.env'));
loadEnvFile(resolve(process.cwd(), '..', '..', '.env'));
}
function timestamp() {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
'_',
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('');
}
function databaseNameFromUrl(url: string) {
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
}
function databaseUrl(baseUrl: string, dbName: string) {
const url = new URL(baseUrl);
url.pathname = `/${dbName}`;
return url.toString();
}
function adminUrl(baseUrl: string) {
const url = new URL(baseUrl);
url.pathname = '/postgres';
return url.toString();
}
function assertTestDatabaseName(dbName: string) {
if (!/^hrm_medpark_test_[A-Za-z0-9_]+$/.test(dbName)) {
throw new Error(`Refusing unsafe database name "${dbName}". Expected hrm_medpark_test_*.`);
}
}
function quotedIdentifier(dbName: string) {
assertTestDatabaseName(dbName);
return `"${dbName.replace(/"/g, '""')}"`;
}
function pnpmCommand() {
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
}
function cleanEnv(env: CommandEnv) {
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined || key.startsWith('=')) continue;
cleaned[key] = value;
}
return cleaned;
}
async function createDatabase(baseUrl: string, dbName: string) {
assertTestDatabaseName(dbName);
const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } });
try {
await admin.$executeRawUnsafe(`CREATE DATABASE ${quotedIdentifier(dbName)}`);
} finally {
await admin.$disconnect();
}
}
async function dropDatabase(baseUrl: string, dbName: string) {
assertTestDatabaseName(dbName);
const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } });
try {
await admin.$executeRawUnsafe(
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()',
dbName,
);
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${quotedIdentifier(dbName)}`);
} finally {
await admin.$disconnect();
}
}
async function runCommand(label: string, args: string[], env: CommandEnv) {
console.log(`\n> ${label}`);
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(pnpmCommand(), args, {
cwd: process.cwd(),
env: cleanEnv(env),
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}`));
});
});
}
async function findFreePort() {
return new Promise<number>((resolvePromise, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Unable to allocate a free API port'));
return;
}
const port = address.port;
server.close(() => resolvePromise(port));
});
});
}
async function waitForApi(baseUrl: string, child: ChildProcess) {
const startedAt = Date.now();
while (Date.now() - startedAt < 60_000) {
if (child.exitCode !== null) {
throw new Error(`Temporary API exited before becoming ready (exit code ${child.exitCode})`);
}
try {
const response = await fetch(`${baseUrl}/auth/dev-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'readiness', role: 'hr_admin' }),
});
if (response.status === 200 || response.status === 201) return;
} catch {
// API is still booting.
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
}
throw new Error(`Temporary API did not become ready at ${baseUrl}`);
}
async function startTemporaryApi(dbUrl: string, dbName: string) {
const port = Number(process.env.TEST_API_PORT ?? (await findFreePort()));
const baseUrl = `http://127.0.0.1:${port}/api/v1`;
const bucket = `hrm-docs-test-${dbName.replace(/_/g, '-')}`.slice(0, 63);
const env: CommandEnv = {
...process.env,
DATABASE_URL: dbUrl,
PORT: String(port),
NODE_ENV: 'test',
ALLOW_DEV_LOGIN: 'true',
MINIO_BUCKET: bucket,
};
console.log(`\n> Starting temporary API on ${baseUrl}`);
const child = spawn(pnpmCommand(), ['exec', 'nest', 'start'], {
cwd: process.cwd(),
env: cleanEnv(env),
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(`[api] ${chunk.toString()}`));
child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[api] ${chunk.toString()}`));
await waitForApi(baseUrl, child);
return { child, baseUrl };
}
async function stopTemporaryApi(child: ChildProcess | null) {
if (!child || child.exitCode !== null) return;
const waitForExit = async () => {
if (child.exitCode !== null) return;
await new Promise<void>((resolvePromise) => {
const timeout = setTimeout(() => resolvePromise(), 10_000);
const done = () => {
clearTimeout(timeout);
resolvePromise();
};
child.once('exit', done);
child.once('close', done);
});
};
if (process.platform === 'win32' && child.pid) {
await new Promise<void>((resolvePromise) => {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
killer.on('exit', () => resolvePromise());
killer.on('error', () => resolvePromise());
});
await waitForExit();
child.stdout?.destroy();
child.stderr?.destroy();
child.unref();
return;
}
child.kill('SIGTERM');
await waitForExit();
child.stdout?.destroy();
child.stderr?.destroy();
child.unref();
}
async function run() {
loadLocalEnv();
const baseUrl = process.env.DATABASE_URL;
if (!baseUrl) throw new Error('DATABASE_URL is required. Put it in apps/api/.env or export it before running testdb:run.');
const dbName = `hrm_medpark_test_${timestamp()}`;
const dbUrl = databaseUrl(baseUrl, dbName);
const commandEnv: CommandEnv = { ...process.env, DATABASE_URL: dbUrl };
let api: Awaited<ReturnType<typeof startTemporaryApi>> | null = null;
console.log(`Creating temporary database ${dbName}...`);
await createDatabase(baseUrl, dbName);
try {
await runCommand('prisma migrate deploy', ['exec', 'prisma', 'migrate', 'deploy'], commandEnv);
await runCommand('seed test data', ['exec', 'ts-node', 'scripts/seed-test-data.ts'], commandEnv);
api = await startTemporaryApi(dbUrl, dbName);
await runCommand('verify functionality', ['exec', 'ts-node', 'scripts/verify-functionality.ts'], {
...commandEnv,
API_BASE_URL: api.baseUrl,
});
console.log('\nTemporary database is ready for manual checks.');
console.log(`DATABASE_URL=${dbUrl}`);
console.log('\nRun API manually from the repo root with:');
console.log(` $env:DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api dev`);
console.log('\nDrop it later with:');
console.log(` $env:TEST_DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api testdb:drop`);
} catch (error) {
console.error('\nTest database run failed. The database was left in place for inspection:');
console.error(`DATABASE_URL=${dbUrl}`);
throw error;
} finally {
await stopTemporaryApi(api?.child ?? null);
}
}
function targetDatabaseFromArgs(baseUrl: string) {
const arg = process.argv.slice(3).find((value) => value !== '--') ?? process.env.TEST_DATABASE_URL ?? process.env.TEST_DATABASE_NAME;
if (!arg) {
throw new Error('Provide a test DB name or URL: pnpm.cmd --filter api testdb:drop -- hrm_medpark_test_YYYYMMDD_HHMMSS');
}
if (/^postgres(?:ql)?:\/\//.test(arg)) {
return { dbName: databaseNameFromUrl(arg), baseUrl: arg };
}
return { dbName: arg, baseUrl };
}
async function drop() {
loadLocalEnv();
const baseUrl = process.env.DATABASE_URL;
if (!baseUrl) throw new Error('DATABASE_URL is required for admin connection');
const target = targetDatabaseFromArgs(baseUrl);
assertTestDatabaseName(target.dbName);
console.log(`Dropping temporary database ${target.dbName}...`);
await dropDatabase(target.baseUrl, target.dbName);
console.log(`Dropped ${target.dbName}.`);
}
async function main() {
const command = process.argv[2] ?? 'run';
if (command === 'run') {
await run();
return;
}
if (command === 'drop') {
await drop();
return;
}
throw new Error(`Unknown command "${command}". Use "run" or "drop".`);
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
+446
View File
@@ -0,0 +1,446 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import ExcelJS = require('exceljs');
import PizZip from 'pizzip';
import {
AnexaType,
MedicalCheckupType,
MedicalVerdict,
PrismaClient,
RiskExposureType,
} from '@prisma/client';
import { DocxTemplateService } from '../src/modules/medical/services/docx-template.service';
type HttpResult = { status: number; body: unknown; text: string };
const prisma = new PrismaClient();
const warnings: string[] = [];
function ok(message: string) {
console.log(`OK ${message}`);
}
function warn(message: string) {
warnings.push(message);
console.warn(`WARN ${message}`);
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function dbNameFromUrl(url: string): string {
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
}
function requireTemporaryDatabase() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required for verification');
const dbName = dbNameFromUrl(url);
if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') {
throw new Error(`Refusing to verify non-test database "${dbName}". Expected hrm_medpark_test_*.`);
}
return dbName;
}
function sourcePath(fileName: string) {
return resolve(process.cwd(), '..', '..', '..', fileName);
}
function docxText(filePath: string): string {
const zip = new PizZip(readFileSync(filePath));
const xml = Object.keys(zip.files)
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
.map((name) => zip.file(name)?.asText() ?? '')
.join('\n');
return xml
.replace(/<\/w:p>/g, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function verifySourceFiles() {
const controlDocx = process.env.CONTROL_MEDICAL_DOCX ?? sourcePath('Control medical (5).docx');
const rubriciXlsx = process.env.RUBRICI_NECESARE_XLSX ?? sourcePath('Rubrici necesare (6).xlsx');
assert(existsSync(controlDocx), `Missing source file: ${controlDocx}`);
assert(existsSync(rubriciXlsx), `Missing source file: ${rubriciXlsx}`);
const controlText = docxText(controlDocx);
for (const phrase of [
/Baza de date a angajatilor trebuie sa contina/i,
/Persoana expusa profesional la radiatii ionizante/i,
/Tipul controlului medical/i,
/Fisa de solicitare/i,
/aptitudine/i,
/Apt condi/i,
/Inapt temporar/i,
]) {
assert(phrase.test(controlText), `Control medical checklist phrase not found: ${phrase}`);
}
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(rubriciXlsx);
const rubrici = workbook.getWorksheet('Rubrici');
const performance = workbook.getWorksheet('Sheet1');
assert(rubrici, 'Rubrici necesare workbook must contain sheet "Rubrici"');
assert(performance, 'Rubrici necesare workbook must contain sheet "Sheet1"');
const fields = new Set<string>();
for (let rowIndex = 1; rowIndex <= rubrici.actualRowCount; rowIndex += 1) {
const value = excelCellText(rubrici.getRow(rowIndex).getCell(3)).trim();
if (value) fields.add(value);
}
for (const field of ['IDNP', 'Nume', 'Prenume', 'Data nasterii', 'Domiciliu', 'Nr de telefon personal', 'tipul actului de identitate', 'Nr CIM']) {
assert(fields.has(field), `Rubrici checklist field not found: ${field}`);
}
const performanceText = docxLikeSheetText(performance);
for (const phrase of ['Abilitati clinice nursing', 'Judecata clinica', 'Respectarea Dress Code', 'Membru unui comitet']) {
assert(performanceText.includes(phrase), `Performance checklist phrase not found: ${phrase}`);
}
ok('source DOCX/XLSX checklists are present and readable');
}
function docxLikeSheetText(worksheet: ExcelJS.Worksheet) {
const parts: string[] = [];
worksheet.eachRow((row) => {
row.eachCell((cell) => {
const text = excelCellText(cell).trim();
if (text) parts.push(text);
});
});
return parts.join(' ');
}
function excelCellText(cell: ExcelJS.Cell) {
const value = cell.value;
if (value == null) return '';
if (value instanceof Date) return value.toISOString();
if (typeof value !== 'object') return String(value);
if ('text' in value && typeof value.text === 'string') return value.text;
if ('result' in value && value.result != null) return String(value.result);
if ('richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('');
}
if ('hyperlink' in value && 'text' in value && typeof value.text === 'string') return value.text;
return '';
}
async function verifyDatabaseCoverage() {
const [
employees,
identityDocuments,
familyMembers,
educations,
qualifications,
trainings,
sanctions,
benefits,
contracts,
riskCards,
profiles,
checkups,
evaluationForms,
] = await Promise.all([
prisma.employee.count(),
prisma.identityDocument.count(),
prisma.familyMember.count(),
prisma.education.count(),
prisma.qualification.count(),
prisma.training.count(),
prisma.disciplinarySanction.count(),
prisma.benefit.count(),
prisma.employmentContract.count(),
prisma.workplaceRiskCard.count(),
prisma.employeeMedicalProfile.count(),
prisma.medicalCheckup.count(),
prisma.evaluationForm.count(),
]);
assert(employees >= 6, `Expected at least 6 employees, got ${employees}`);
assert(identityDocuments >= employees, 'Every seeded employee should have an identity document');
assert(familyMembers >= 3, 'Expected family/contact records');
assert(educations >= employees, 'Expected education records for all employees');
assert(qualifications >= 4, 'Expected qualification records');
assert(trainings >= employees, 'Expected training records for all employees');
assert(sanctions >= 2, 'Expected disciplinary sanction scenarios');
assert(benefits >= 2, 'Expected benefit scenarios');
assert(contracts >= employees, 'Expected active CIM for all employees');
assert(riskCards >= 3, 'Expected STANDARD, radiation, and DISTANTA_DIGITAL risk cards');
assert(profiles >= employees, 'Expected medical profile for all employees');
assert(checkups >= 8, 'Expected pending and completed checkups');
assert(evaluationForms >= 3, 'Expected performance evaluation forms');
const exposureTypes = await prisma.workplaceRiskExposure.findMany({ select: { tip: true }, distinct: ['tip'] });
const seededExposureTypes = new Set(exposureTypes.map((row) => row.tip));
for (const type of Object.values(RiskExposureType)) {
assert(seededExposureTypes.has(type), `Missing risk exposure type in test seed: ${type}`);
}
const verdicts = await prisma.medicalCheckup.findMany({
where: { verdict: { not: null } },
select: { verdict: true },
distinct: ['verdict'],
});
const seededVerdicts = new Set(verdicts.map((row) => row.verdict));
for (const verdict of Object.values(MedicalVerdict)) {
assert(seededVerdicts.has(verdict), `Missing medical verdict scenario: ${verdict}`);
}
const radiationProfiles = await prisma.employeeMedicalProfile.count({ where: { expusRadiatiiIonizante: true } });
const overexposures = await prisma.radiationOverexposure.findMany({ select: { fel: true }, distinct: ['fel'] });
const remoteCards = await prisma.workplaceRiskCard.count({ where: { tipFisa: 'DISTANTA_DIGITAL' } });
const pending = await prisma.medicalCheckup.count({ where: { verdict: null } });
assert(radiationProfiles >= 2, 'Expected at least two radiation-exposed employees');
assert(overexposures.length >= 2, 'Expected exceptional and accidental overexposure rows');
assert(remoteCards >= 1, 'Expected Anexa 4A/DISTANTA_DIGITAL risk card');
assert(pending >= 3, 'Expected pending checkups for medic inbox');
ok('database seed covers HR, performance, and Control medical scenarios');
}
function extractTemplatePlaceholders(fileName: string) {
const fullPath = resolve(process.cwd(), 'templates', 'docx', fileName);
assert(existsSync(fullPath), `Missing DOCX template: ${fullPath}`);
const zip = new PizZip(readFileSync(fullPath));
const xml = Object.keys(zip.files)
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
.map((name) => zip.file(name)?.asText() ?? '')
.join('\n');
const opens = (xml.match(/\{/g) ?? []).length;
const closes = (xml.match(/\}/g) ?? []).length;
assert(opens === closes, `${fileName} has unbalanced placeholders: opens=${opens}, closes=${closes}`);
const placeholders = Array.from(xml.matchAll(/\{([^{}]+)\}/g)).map((match) => match[1]);
return { placeholders, opens };
}
function valueForPlaceholder(name: string, index = 1) {
if (name.startsWith('cb')) return index % 2 === 0 ? '☐' : '☑';
if (/data|PanaLa/i.test(name)) return '27.05.2026';
if (/email/i.test(name)) return 'hr.test@medpark.md';
if (/telefon|fax/i.test(name)) return '+373 22 000 000';
if (/idnp/i.test(name)) return `19850615000${index}`;
if (/nr|numar|anNastere|ani/i.test(name)) return String(index);
if (/doza/i.test(name)) return (index + 0.25).toFixed(4);
return `Test ${name} ${index}`;
}
function sampleDocxData(placeholders: string[]) {
const data: Record<string, unknown> = {};
for (const raw of placeholders) {
const name = raw.replace(/^#|\//g, '');
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
data[name] = valueForPlaceholder(name);
}
const row = (index: number) => {
const values: Record<string, string> = {};
for (const raw of placeholders) {
const name = raw.replace(/^#|\//g, '');
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
values[name] = valueForPlaceholder(name, index);
}
values.nr = String(index);
values.numePrenume = index === 1 ? 'Popescu Alexandru' : 'Ionescu Maria';
values.denumire = index === 1 ? 'Glutaraldehidă' : 'HBV/HCV/HIV';
values.tipExpunere = index === 1 ? 'X externă' : 'gamma externă';
return values;
};
for (const loopName of ['angajati', 'chimici', 'pulberi', 'biologici', 'zgomot', 'vibratii', 'campEM', 'optice', 'supraexpExceptionale', 'supraexpAccidentale']) {
data[loopName] = [row(1), row(2)];
}
return data;
}
async function verifyDocxTemplates() {
const service = new DocxTemplateService();
const files: Record<AnexaType, string> = {
ANEXA_3: 'anexa-3.docx',
ANEXA_4: 'anexa-4.docx',
ANEXA_4A: 'anexa-4a.docx',
ANEXA_4B: 'anexa-4b.docx',
ANEXA_6: 'anexa-6.docx',
};
for (const [type, fileName] of Object.entries(files) as [AnexaType, string][]) {
const { placeholders, opens } = extractTemplatePlaceholders(fileName);
assert(opens > 0, `${fileName} should contain docxtemplater placeholders`);
const rendered = service.render(type, sampleDocxData(placeholders));
const zip = new PizZip(rendered);
const badParts: string[] = [];
for (const name of Object.keys(zip.files).filter((entry) => /^word\/(document|header|footer).*\.xml$/.test(entry))) {
const xml = zip.file(name)?.asText() ?? '';
if (/[{}]/.test(xml) || /\b(undefined|null)\b/i.test(xml)) badParts.push(name);
}
assert(badParts.length === 0, `${fileName} rendered XML still contains placeholders/nulls in ${badParts.join(', ')}`);
}
ok('all Anexa DOCX templates render cleanly through DocxTemplateService');
}
async function isMinioAvailable() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetch('http://localhost:9000/minio/health/live', { signal: controller.signal });
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function apiRequest(baseUrl: string, method: string, path: string, body?: unknown, token?: string): Promise<HttpResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20_000);
try {
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed, text };
} catch (error) {
lastError = error;
if (attempt < 3) await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000 * attempt));
} finally {
clearTimeout(timeout);
}
}
throw new Error(`${method} ${path} failed after retries: ${String(lastError)}`);
}
function tokenFrom(body: unknown) {
if (typeof body === 'object' && body && 'token' in body && typeof (body as { token: unknown }).token === 'string') {
return (body as { token: string }).token;
}
throw new Error('dev-login response did not include token');
}
async function verifyHttpSmoke(minioAvailable: boolean) {
const baseUrl = process.env.API_BASE_URL;
if (!baseUrl) {
warn('API_BASE_URL is not set; skipped HTTP smoke tests. testdb:run starts a temporary API automatically.');
return;
}
const adminLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-admin', role: 'hr_admin' });
const specialistLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-specialist', role: 'hr_specialist' });
const medicLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-medic', role: 'medic_familie' });
assert(adminLogin.status === 201 || adminLogin.status === 200, `hr_admin dev-login failed: ${adminLogin.text}`);
assert(specialistLogin.status === 201 || specialistLogin.status === 200, `hr_specialist dev-login failed: ${specialistLogin.text}`);
assert(medicLogin.status === 201 || medicLogin.status === 200, `medic_familie dev-login failed: ${medicLogin.text}`);
const adminToken = tokenFrom(adminLogin.body);
const specialistToken = tokenFrom(specialistLogin.body);
const medicToken = tokenFrom(medicLogin.body);
for (const path of ['/dashboard/stats', '/medical/risk-cards', '/medical/upcoming-expirations']) {
const response = await apiRequest(baseUrl, 'GET', path, undefined, adminToken);
assert(response.status >= 200 && response.status < 300, `GET ${path} failed: ${response.status} ${response.text}`);
}
const employees = await prisma.employee.findMany({
where: { status: 'activ', medicalProfile: { workplaceRiskCardId: { not: null } } },
select: { id: true },
take: 5,
});
assert(employees.length >= 5, 'HTTP bulk smoke needs at least five eligible employees');
const bulkBody = {
employeeIds: employees.map((employee) => employee.id),
tip: MedicalCheckupType.periodic,
dataPlanificata: '2026-06-15',
documentContext: {
telefon: '+373 22 000 000',
fax: '+373 22 000 001',
email: 'hr.test@medpark.md',
solicitant: 'Test HR Admin',
functia: 'Specialist resurse umane',
},
};
const forbidden = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, specialistToken);
assert(forbidden.status === 403, `hr_specialist bulk initiate should be 403, got ${forbidden.status}: ${forbidden.text}`);
if (!minioAvailable) {
warn('MinIO is not reachable on localhost:9000; skipped full upload-dependent bulk success and medic completion HTTP tests.');
ok('HTTP smoke passed for auth, read endpoints, and role-based 403');
return;
}
const bulk = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, adminToken);
assert(bulk.status >= 200 && bulk.status < 300, `hr_admin bulk initiate failed: ${bulk.status} ${bulk.text}`);
assert(typeof bulk.body === 'object' && bulk.body && 'groupsCount' in bulk.body, 'bulk response should include groupsCount');
const pending = await prisma.medicalCheckup.findFirst({
where: { verdict: null },
orderBy: { createdAt: 'asc' },
});
assert(pending, 'No pending checkup found for medic completion smoke');
const complete = await apiRequest(
baseUrl,
'PATCH',
`/medical/checkups/${pending.id}/complete`,
{
verdict: MedicalVerdict.apt_conditionat,
dataEfectuata: '2026-06-16',
recomandari: 'Test: lucru cu dozimetru și reevaluare anuală.',
valabilPanaLa: '2027-06-16',
semnatDe: 'Dr. Verificare Test',
},
medicToken,
);
assert(complete.status >= 200 && complete.status < 300, `medic completion failed: ${complete.status} ${complete.text}`);
const completed = await prisma.medicalCheckup.findUnique({ where: { id: pending.id } });
assert(completed?.verdict === MedicalVerdict.apt_conditionat, 'Medic completion did not persist verdict');
assert(completed.semnatDe === 'Dr. Verificare Test', 'Medic completion did not persist semnatDe');
assert(Array.isArray(completed.documenteGenerate), 'Completed checkup should contain generated documents');
assert(JSON.stringify(completed.documenteGenerate).includes('Anexa_6_Final'), 'Completed checkup should include final Anexa 6 document');
ok('HTTP smoke passed, including MinIO-backed document generation');
}
async function main() {
const dbName = requireTemporaryDatabase();
console.log(`Verifying Medpark test database ${dbName}...`);
await verifySourceFiles();
await verifyDatabaseCoverage();
await verifyDocxTemplates();
const minioAvailable = await isMinioAvailable();
if (minioAvailable) ok('MinIO is reachable on localhost:9000');
else warn('MinIO is not reachable on localhost:9000; upload-dependent checks will be skipped or marked failed.');
await verifyHttpSmoke(minioAvailable);
if (warnings.length > 0) {
console.log('\nVerification completed with warnings:');
for (const message of warnings) console.log(`- ${message}`);
} else {
console.log('\nVerification completed without warnings.');
}
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+58
View File
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { BullModule } from '@nestjs/bull';
import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n';
import * as path from 'path';
import { PrismaModule } from './common/prisma/prisma.module';
import { AuditModule } from './common/audit/audit.module';
import { AuthModule } from './modules/auth/auth.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { DepartmentsModule } from './modules/departments/departments.module';
import { ReferenceModule } from './modules/reference/reference.module';
import { EvaluationModule } from './modules/evaluation/evaluation.module';
import { MedicalModule } from './modules/medical/medical.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { ContractsGlobalModule } from './modules/contracts/contracts-global.module';
import { AdminModule } from './modules/admin/admin.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
},
}),
I18nModule.forRoot({
fallbackLanguage: 'ro',
loaderOptions: {
path: path.join(process.cwd(), 'i18n'),
watch: true,
},
resolvers: [AcceptLanguageResolver],
}),
PrismaModule,
AuditModule,
AuthModule,
EmployeesModule,
DepartmentsModule,
ReferenceModule,
EvaluationModule,
MedicalModule,
DashboardModule,
ContractsGlobalModule,
AdminModule,
InventoryModule,
NotificationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,12 @@
import { SetMetadata } from '@nestjs/common';
import { AuditAction } from './audit.service';
export const AUDIT_META_KEY = 'audit_meta';
export interface AuditMeta {
action: AuditAction;
entity: string;
}
export const Audit = (action: AuditAction, entity: string) =>
SetMetadata(AUDIT_META_KEY, { action, entity } satisfies AuditMeta);
@@ -0,0 +1,43 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { AuditService } from './audit.service';
import { AUDIT_META_KEY, AuditMeta } from './audit.decorator';
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(
private readonly auditService: AuditService,
private readonly reflector: Reflector,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<AuditMeta>(
AUDIT_META_KEY,
context.getHandler(),
);
if (!meta) return next.handle();
const req = context.switchToHttp().getRequest();
const user = req.user as { id: string; role: string } | undefined;
return next.handle().pipe(
tap(() => {
if (!user) return;
void this.auditService.log({
userId: user.id,
userRole: user.role,
ip: req.ip,
action: meta.action,
entity: meta.entity,
entityId: req.params?.id ?? 'bulk',
});
}),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditInterceptor } from './audit.interceptor';
@Global()
@Module({
providers: [AuditService, AuditInterceptor],
exports: [AuditService, AuditInterceptor],
})
export class AuditModule {}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type AuditAction = 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'EXPORT';
export interface AuditParams {
userId: string;
userRole: string;
ip?: string;
action: AuditAction;
entity: string;
entityId: string;
field?: string;
oldValue?: string;
newValue?: string;
reason?: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(params: AuditParams): Promise<void> {
await this.prisma.auditLog.create({ data: params });
}
async logRead(params: Omit<AuditParams, 'action'>): Promise<void> {
await this.log({ ...params, action: 'READ' });
}
async logChange(
params: Omit<AuditParams, 'action'> & {
action: 'CREATE' | 'UPDATE' | 'DELETE';
},
): Promise<void> {
await this.log(params);
}
}
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export type AppRole =
| 'hr_admin'
| 'hr_specialist'
| 'nursing_director'
| 'quality_auditor'
| 'manager'
| 'medic_familie'
| 'employee';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: AppRole[]) => SetMetadata(ROLES_KEY, roles);
+19
View File
@@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppRole, ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<AppRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest<{ user: { role: AppRole } }>();
return required.includes(user?.role);
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.enableCors({
origin: process.env.FRONTEND_URL
? process.env.FRONTEND_URL
: /^http:\/\/localhost(:\d+)?$/,
credentials: true,
});
const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0');
console.log(`HRM API running on http://localhost:${port}/api/v1`);
}
bootstrap();
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AnexaTemplatesController } from './anexa-templates/anexa-templates.controller';
import { AnexaTemplatesService } from './anexa-templates/anexa-templates.service';
@Module({
controllers: [AnexaTemplatesController],
providers: [AnexaTemplatesService],
})
export class AdminModule {}
@@ -0,0 +1,54 @@
import {
Controller, Get, Put, Post, Body, Param, UseGuards, Request, HttpCode, HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AnexaType } from '@prisma/client';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { AnexaTemplatesService } from './anexa-templates.service';
import { UpdateTemplateDto } from './dto/update-template.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('admin/anexa-templates')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('hr_admin')
export class AnexaTemplatesController {
constructor(private readonly svc: AnexaTemplatesService) {}
// Literal routes BEFORE :type to avoid routing conflicts in Fastify
@Get('preview-employee')
getPreviewEmployee() {
return this.svc.getPreviewEmployee();
}
@Get()
list() {
return this.svc.list();
}
@Get(':type')
findOne(@Param('type') type: AnexaType) {
return this.svc.findOne(type);
}
@Put(':type')
update(@Param('type') type: AnexaType, @Body() dto: UpdateTemplateDto, @Request() req: AuthReq) {
return this.svc.update(type, dto, req.user.id, req.user.role);
}
@Get(':type/versions')
getVersions(@Param('type') type: AnexaType) {
return this.svc.getVersions(type);
}
@Post(':type/restore/:versionId')
@HttpCode(HttpStatus.OK)
restore(
@Param('type') type: AnexaType,
@Param('versionId') versionId: string,
@Request() req: AuthReq,
) {
return this.svc.restore(type, versionId, req.user.id, req.user.role);
}
}
@@ -0,0 +1,116 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AnexaType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { UpdateTemplateDto } from './dto/update-template.dto';
const ANEXA_NAMES: Record<AnexaType, string> = {
ANEXA_3: 'Fișa de solicitare a examenului medical',
ANEXA_4: 'Fișa de evaluare a riscurilor profesionale',
ANEXA_4A: 'Fișa de evaluare — muncă la distanță/platforme digitale',
ANEXA_4B: 'Supliment radiații ionizante',
ANEXA_6: 'Verdict medic de familie',
};
@Injectable()
export class AnexaTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list() {
return this.prisma.anexaTemplate.findMany({
select: { id: true, type: true, name: true, updatedById: true, updatedAt: true },
orderBy: { type: 'asc' },
});
}
async findOne(type: AnexaType) {
const t = await this.prisma.anexaTemplate.findUnique({ where: { type } });
if (!t) throw new NotFoundException(`Template ${type} nu există`);
return t;
}
async update(type: AnexaType, dto: UpdateTemplateDto, userId: string, role: string) {
const existing = await this.prisma.anexaTemplate.findUnique({ where: { type } });
if (existing) {
await this.prisma.anexaTemplateVersion.create({
data: {
templateId: existing.id,
contentJson: existing.contentJson as never,
savedById: userId,
},
});
}
const template = await this.prisma.anexaTemplate.upsert({
where: { type },
update: {
contentJson: dto.contentJson as never,
updatedById: userId,
...(dto.name ? { name: dto.name } : {}),
},
create: {
type,
name: dto.name ?? ANEXA_NAMES[type],
contentJson: dto.contentJson as never,
updatedById: userId,
},
});
await this.audit.logChange({
userId,
userRole: role,
action: 'UPDATE',
entity: 'AnexaTemplate',
entityId: template.id,
});
return template;
}
getVersions(type: AnexaType) {
return this.prisma.anexaTemplateVersion.findMany({
where: { template: { type } },
orderBy: { savedAt: 'desc' },
take: 50,
});
}
async restore(type: AnexaType, versionId: string, userId: string, role: string) {
const version = await this.prisma.anexaTemplateVersion.findUniqueOrThrow({
where: { id: versionId },
});
return this.update(type, { contentJson: version.contentJson }, userId, role);
}
getPreviewEmployee() {
return this.prisma.employee.findFirst({
select: {
id: true,
idnp: true,
nume: true,
prenume: true,
dataNasterii: true,
contracts: {
select: {
functiaOrganigrama: true,
functiaClasificator: true,
department: { select: { name: true } },
},
orderBy: { dataAngajarii: 'desc' },
take: 1,
},
medicalProfile: {
select: {
ocupatieCorm: true,
dozaCumulataExternaMsv: true,
dozaCumulataInternaMsv: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
}
@@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTemplateDto {
@IsOptional()
contentJson?: unknown;
@IsOptional()
@IsString()
name?: string;
}
@@ -0,0 +1,50 @@
import { Controller, Post, Body, Get, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
const DEV_SECRET = process.env.DEV_JWT_SECRET ?? 'dev-secret-hrm-2026';
const VALID_ROLES = [
'hr_admin', 'hr_specialist', 'nursing_director',
'quality_auditor', 'manager', 'medic_familie', 'employee',
];
@Controller('auth')
export class AuthController {
constructor(private readonly jwt: JwtService) {}
/**
* Local dev login — generates HS256 token.
* Blocked in production via NODE_ENV check.
* Safe in production: when KEYCLOAK_URL is set, the JWT strategy uses RS256/JWKS
* and will reject any HS256 token automatically.
*/
@Post('dev-login')
devLogin(@Body() body: { username?: string; role?: string }) {
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_DEV_LOGIN !== 'true') {
throw new ForbiddenException('Dev-login este dezactivat în producție');
}
const username = (body.username ?? 'admin').trim() || 'admin';
const role = VALID_ROLES.includes(body.role ?? '') ? body.role! : 'hr_admin';
const payload = {
sub: `dev-${role}`,
preferred_username: username,
realm_access: { roles: [role] },
};
const token = this.jwt.sign(payload, {
secret: DEV_SECRET,
expiresIn: '8h',
});
return { token, username, role };
}
/** Returns current user info from JWT — useful for the frontend */
@Get('me')
@UseGuards(AuthGuard('jwt'))
me(@Request() req: { user: { id: string; username: string; role: string } }) {
return req.user;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { KeycloakStrategy } from './keycloak.strategy';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({}),
],
controllers: [AuthController],
providers: [KeycloakStrategy],
exports: [PassportModule],
})
export class AuthModule {}
@@ -0,0 +1,93 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { AppRole } from '../../common/decorators/roles.decorator';
interface KeycloakToken {
sub: string;
preferred_username: string;
email?: string;
realm_access?: { roles: string[] };
resource_access?: Record<string, { roles: string[] }>;
}
type JwksSecretCallback = (
request: unknown,
rawJwtToken: string,
done: (err: Error | null, secret?: string | Buffer) => void,
) => void;
const DEV_SECRET = process.env.DEV_JWT_SECRET ?? 'dev-secret-hrm-2026';
// Lazy-initialised JWKS provider (only when KEYCLOAK_URL is set)
let jwksProvider: JwksSecretCallback | null = null;
function getJwksProvider(): JwksSecretCallback {
if (!jwksProvider) {
jwksProvider = passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`,
}) as unknown as JwksSecretCallback;
}
return jwksProvider;
}
function parseTokenHeader(raw: string): Record<string, unknown> {
try {
return JSON.parse(Buffer.from(raw.split('.')[0], 'base64url').toString('utf8'));
} catch {
return {};
}
}
@Injectable()
export class KeycloakStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
// Accept both HS256 (dev tokens) and RS256 (Keycloak tokens)
algorithms: ['HS256', 'RS256'],
secretOrKeyProvider: (
request: unknown,
rawToken: string,
done: (err: Error | null, secret?: string | Buffer) => void,
) => {
const header = parseTokenHeader(rawToken);
if (header['alg'] === 'HS256') {
// Dev-login token — validate with local secret
done(null, DEV_SECRET);
return;
}
// Keycloak RS256 token — validate via JWKS
if (!process.env.KEYCLOAK_URL) {
done(new UnauthorizedException('Keycloak not configured for RS256'));
return;
}
getJwksProvider()(request, rawToken, done);
},
});
}
validate(payload: KeycloakToken) {
const realmRoles = payload.realm_access?.roles ?? [];
const clientRoles =
payload.resource_access?.[process.env.KEYCLOAK_CLIENT_ID ?? 'hrm-api']
?.roles ?? [];
const hrm_roles: AppRole[] = [
'hr_admin', 'hr_specialist', 'nursing_director',
'quality_auditor', 'manager', 'medic_familie', 'employee',
];
const role = [...realmRoles, ...clientRoles].find((r) =>
hrm_roles.includes(r as AppRole),
) as AppRole | undefined;
if (!role) throw new UnauthorizedException('No HRM role assigned');
return { id: payload.sub, username: payload.preferred_username, role };
}
}
@@ -0,0 +1,39 @@
import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ContractsGlobalService, ContractStatus } from './contracts-global.service';
interface AuthReq extends Request { user: { id: string; role: string } }
interface ContractsQuery {
page?: string;
limit?: string;
departmentId?: string;
perioada?: string;
status?: ContractStatus;
search?: string;
}
@Controller('contracts')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class ContractsGlobalController {
constructor(private readonly svc: ContractsGlobalService) {}
@Get()
@Roles('hr_admin', 'hr_specialist')
findAll(@Query() q: ContractsQuery, @Request() req: AuthReq) {
return this.svc.findAll(
{
page: q.page ? Number(q.page) : 1,
limit: q.limit ? Number(q.limit) : 50,
departmentId: q.departmentId,
perioada: q.perioada,
status: q.status,
search: q.search,
},
req.user.id,
req.user.role,
);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ContractsGlobalController } from './contracts-global.controller';
import { ContractsGlobalService } from './contracts-global.service';
@Module({
controllers: [ContractsGlobalController],
providers: [ContractsGlobalService],
})
export class ContractsGlobalModule {}
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { Prisma, ContractPeriod } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.service';
import { AuditService } from '../../common/audit/audit.service';
export type ContractStatus = 'activ' | 'expirat' | 'expira_in_curand';
interface ListQuery {
page: number;
limit: number;
departmentId?: string;
perioada?: string;
status?: ContractStatus;
search?: string;
}
@Injectable()
export class ContractsGlobalService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async findAll(query: ListQuery, userId: string, role: string) {
const { page, limit, departmentId, perioada, status, search } = query;
// Issue 4: cap limit at 200
const safLimit = Math.min(limit, 200);
const now = new Date();
const in30Days = new Date(now.getTime() + 30 * 86_400_000);
// Issue 1: use Prisma.EmploymentContractWhereInput instead of Record<string, unknown>
const where: Prisma.EmploymentContractWhereInput = {};
if (departmentId) where.departmentId = departmentId;
if (perioada) where.perioada = perioada as ContractPeriod;
if (search) {
where.employee = {
OR: [
{ nume: { contains: search, mode: 'insensitive' } },
{ prenume: { contains: search, mode: 'insensitive' } },
],
};
}
// Issue 2: push status filter to DB instead of in-memory full-table scan
if (status === 'expirat') {
where.OR = [
{ dataDemisiei: { lt: now } },
{ AND: [{ perioada: ContractPeriod.determinata }, { dataTerminarii: { lt: now } }] },
];
} else if (status === 'expira_in_curand') {
where.AND = [
{ dataDemisiei: null },
{ perioada: ContractPeriod.determinata },
{ dataTerminarii: { gte: now, lte: in30Days } },
];
} else if (status === 'activ') {
where.dataDemisiei = null;
where.NOT = {
OR: [
{ dataTerminarii: { lt: now } },
{ AND: [{ dataTerminarii: { gte: now, lte: in30Days } }, { perioada: ContractPeriod.determinata }] },
],
};
}
const include = {
employee: { select: { id: true, idnp: true, nume: true, prenume: true } },
department: { select: { id: true, name: true } },
workSchedule: { select: { id: true, name: true } },
categoriiServicii: true,
} satisfies Prisma.EmploymentContractInclude;
const [total, rows] = await this.prisma.$transaction([
this.prisma.employmentContract.count({ where }),
this.prisma.employmentContract.findMany({
where,
include,
orderBy: { dataAngajarii: 'desc' },
skip: (page - 1) * safLimit,
take: safLimit,
}),
]);
const items = rows.map((c) => ({ ...c, status: this.computeStatus(c, now) }));
await this.audit.logRead({ userId, userRole: role, entity: 'EmploymentContract', entityId: 'GLOBAL_LIST' });
return { total, page, limit: safLimit, items };
}
private computeStatus(
c: { dataDemisiei: Date | null; perioada: ContractPeriod; dataTerminarii: Date | null },
now: Date,
): ContractStatus {
// Issue 3: use <= instead of <
if (c.dataDemisiei && c.dataDemisiei <= now) return 'expirat';
if (c.perioada === ContractPeriod.determinata && c.dataTerminarii) {
const daysLeft = Math.floor((c.dataTerminarii.getTime() - now.getTime()) / 86_400_000);
if (daysLeft < 0) return 'expirat';
if (daysLeft <= 30) return 'expira_in_curand';
}
return 'activ';
}
}
@@ -0,0 +1,14 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
@UseGuards(AuthGuard('jwt'))
export class DashboardController {
constructor(private readonly svc: DashboardService) {}
@Get('stats')
getStats() {
return this.svc.getStats();
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
@@ -0,0 +1,136 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats() {
const today = new Date();
const in30 = new Date(today); in30.setDate(today.getDate() + 30);
const in60 = new Date(today); in60.setDate(today.getDate() + 60);
const in90 = new Date(today); in90.setDate(today.getDate() + 90);
const ago30 = new Date(today); ago30.setDate(today.getDate() - 30);
const [
employeesByStatus,
activeContracts,
contractsDeterminata,
recentHires,
activeSanctions,
expiringDocs,
upcomingCheckups,
expiringQualifications,
] = await Promise.all([
// Employee counts by status
this.prisma.employee.groupBy({
by: ['status'],
_count: { _all: true },
}),
// Active contracts (no dataDemisiei)
this.prisma.employmentContract.count({
where: { dataDemisiei: null },
}),
// Determinata contracts expiring in 30 days
this.prisma.employmentContract.findMany({
where: {
dataDemisiei: null,
perioada: 'determinata',
dataTerminarii: { gte: today, lte: in30 },
},
select: {
id: true,
nrCim: true,
dataTerminarii: true,
employee: { select: { id: true, nume: true, prenume: true, idnp: true } },
department: { select: { name: true } },
},
orderBy: { dataTerminarii: 'asc' },
take: 20,
}),
// Recent hires (last 30 days)
this.prisma.employmentContract.count({
where: {
dataAngajarii: { gte: ago30 },
dataDemisiei: null,
},
}),
// Active (non-stinsa) disciplinary sanctions
this.prisma.disciplinarySanction.count({
where: { isStinsa: false },
}),
// Identity documents expiring in 30 days
this.prisma.identityDocument.findMany({
where: {
dataExpirarii: { gte: today, lte: in30 },
},
select: {
id: true,
tipAct: true,
dataExpirarii: true,
employee: { select: { id: true, nume: true, prenume: true, idnp: true } },
},
orderBy: { dataExpirarii: 'asc' },
take: 20,
}),
// Medical checkups due in 60 days (dataPlanificata, not yet effectuated)
this.prisma.medicalCheckup.findMany({
where: {
dataEfectuata: null,
dataPlanificata: { gte: today, lte: in60 },
},
select: {
id: true,
tip: true,
dataPlanificata: true,
employee: { select: { id: true, nume: true, prenume: true, idnp: true } },
},
orderBy: { dataPlanificata: 'asc' },
take: 20,
}),
// Qualifications expiring in 90 days
this.prisma.qualification.findMany({
where: {
dataExpirarii: { gte: today, lte: in90 },
},
select: {
id: true,
categorie: true,
dataExpirarii: true,
employee: { select: { id: true, nume: true, prenume: true, idnp: true } },
},
orderBy: { dataExpirarii: 'asc' },
take: 20,
}),
]);
const statusMap = Object.fromEntries(
employeesByStatus.map((r) => [r.status, r._count._all]),
);
return {
employees: {
total: Object.values(statusMap).reduce((a, b) => a + b, 0),
activ: statusMap['activ'] ?? 0,
concediat: statusMap['concediat'] ?? 0,
suspendat: statusMap['suspendat'] ?? 0,
},
activeContracts,
recentHires,
activeSanctions,
expirations: {
contractsDeterminata,
expiringDocs,
upcomingCheckups,
expiringQualifications,
},
};
}
}
@@ -0,0 +1,44 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { DepartmentsService } from './departments.service';
@Controller('departments')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class DepartmentsController {
constructor(private readonly svc: DepartmentsService) {}
@Get()
findAll() {
return this.svc.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.svc.findOne(id);
}
@Post()
@Roles('hr_admin')
create(@Body() body: { name: string; code?: string; parentId?: string }) {
return this.svc.create(body);
}
@Patch(':id')
@Roles('hr_admin')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { parentId?: string | null; name?: string },
) {
if (body.name !== undefined) return this.svc.rename(id, body.name);
return this.svc.move(id, body.parentId ?? null);
}
@Delete(':id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.svc.delete(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
@Module({
controllers: [DepartmentsController],
providers: [DepartmentsService],
})
export class DepartmentsModule {}
@@ -0,0 +1,81 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
@Injectable()
export class DepartmentsService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
const all = await this.prisma.department.findMany({
orderBy: { name: 'asc' },
});
const map = new Map<string, any>();
all.forEach(d => map.set(d.id, { ...d, children: [] }));
const tree: any[] = [];
all.forEach(d => {
if (d.parentId) {
const parent = map.get(d.parentId);
if (parent) {
parent.children.push(map.get(d.id));
} else {
tree.push(map.get(d.id));
}
} else {
tree.push(map.get(d.id));
}
});
return tree;
}
findOne(id: string) {
return this.prisma.department.findUniqueOrThrow({
where: { id },
include: { children: true, parent: true },
});
}
create(data: { name: string; code?: string; parentId?: string }) {
return this.prisma.department.create({ data });
}
async rename(id: string, name: string) {
const trimmed = name.trim();
if (!trimmed) throw new BadRequestException('Denumirea nu poate fi goală.');
return this.prisma.department.update({ where: { id }, data: { name: trimmed } });
}
async move(id: string, parentId: string | null) {
if (parentId === id) {
throw new BadRequestException('Un departament nu poate fi sub-departament al lui însuși.');
}
if (parentId) {
// circular reference check: walk up from parentId, must not reach id
let cur = await this.prisma.department.findUnique({ where: { id: parentId } });
while (cur?.parentId) {
if (cur.parentId === id) {
throw new BadRequestException('Mutarea creează o referință circulară în ierarhia departamentelor.');
}
cur = await this.prisma.department.findUnique({ where: { id: cur.parentId } });
}
}
return this.prisma.department.update({
where: { id },
data: { parentId: parentId ?? null },
});
}
async delete(id: string) {
const contracts = await this.prisma.employmentContract.count({ where: { departmentId: id } });
if (contracts > 0) {
throw new BadRequestException('Departamentul are contracte asociate. Transferați angajații înainte de ștergere.');
}
const children = await this.prisma.department.count({ where: { parentId: id } });
if (children > 0) {
throw new BadRequestException('Departamentul are sub-departamente. Ștergeți-le mai întâi.');
}
return this.prisma.department.delete({ where: { id } });
}
}
@@ -0,0 +1,108 @@
import {
IsString,
IsEnum,
IsDateString,
IsOptional,
IsEmail,
MinLength,
MaxLength,
Matches,
IsUUID,
} from 'class-validator';
import { Sex, MaritalStatus, EmployeeStatus } from '@prisma/client';
// Алгоритм валидации IDNP Молдовы (контрольная цифра)
function validateIdnp(idnp: string): boolean {
if (!/^\d{13}$/.test(idnp)) return false;
const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1];
const sum = weights.reduce(
(acc, w, i) => acc + w * parseInt(idnp[i], 10),
0,
);
return (sum % 10) === parseInt(idnp[12], 10);
}
import { registerDecorator, ValidationOptions } from 'class-validator';
function IsIdnp(opts?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isIdnp',
target: object.constructor,
propertyName,
options: { message: 'IDNP invalid (13 cifre, cifra de control incorectă)', ...opts },
validator: { validate: (v: unknown) => typeof v === 'string' && validateIdnp(v) },
});
};
}
export class CreateEmployeeDto {
@IsIdnp()
idnp!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
nume!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
prenume!: string;
@IsOptional()
@IsString()
patronimic?: string;
@IsOptional()
@IsString()
numeAnterior?: string;
@IsDateString()
dataNasterii!: string;
@IsString()
domiciliu!: string;
@IsOptional()
@IsString()
adresaReala?: string;
@Matches(/^\+?[0-9\s\-()]{7,20}$/)
telefonPersonal!: string;
@IsOptional()
@Matches(/^\+?[0-9\s\-()]{7,20}$/)
telefonServiciu?: string;
@IsOptional()
@IsEmail()
emailPersonal?: string;
@IsOptional()
@IsEmail()
emailCorporativ?: string;
@IsEnum(Sex)
sex!: Sex;
@IsOptional()
@IsString()
codCpas?: string;
@IsOptional()
@IsEnum(MaritalStatus)
stareCivila?: MaritalStatus;
@IsOptional()
@IsUUID()
gradDizabilitateId?: string;
@IsOptional()
@IsUUID()
recomandareInternaId?: string;
@IsOptional()
@IsEnum(EmployeeStatus)
status?: EmployeeStatus;
}
@@ -0,0 +1,30 @@
import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { EmployeeStatus } from '@prisma/client';
export class QueryEmployeeDto {
@IsOptional()
@IsString()
search?: string; // поиск по nume, prenume, idnp
@IsOptional()
@IsEnum(EmployeeStatus)
status?: EmployeeStatus;
@IsOptional()
@IsString()
departmentId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number = 20;
}
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
ParseUUIDPipe,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { EmployeesService } from './employees.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { QueryEmployeeDto } from './dto/query-employee.dto';
interface AuthRequest extends Request {
user: { id: string; role: string };
}
@Controller('employees')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class EmployeesController {
constructor(private readonly svc: EmployeesService) {}
@Get()
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director')
findAll(@Query() query: QueryEmployeeDto, @Request() req: AuthRequest) {
return this.svc.findAll(query, req.user.id, req.user.role);
}
@Get(':id')
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie')
findOne(
@Param('id', ParseUUIDPipe) id: string,
@Query('reason') reason: string | undefined,
@Request() req: AuthRequest,
) {
return this.svc.findOne(id, req.user.id, req.user.role, reason);
}
@Post()
@Roles('hr_admin')
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateEmployeeDto, @Request() req: AuthRequest) {
return this.svc.create(dto, req.user.id, req.user.role);
}
@Patch(':id')
@Roles('hr_admin', 'hr_specialist')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: Partial<CreateEmployeeDto>,
@Request() req: AuthRequest,
) {
return this.svc.update(id, dto, req.user.id, req.user.role);
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { EmployeesController } from './employees.controller';
import { EmployeesService } from './employees.service';
import { IdentityDocumentsController } from './sub-resources/identity-documents.controller';
import { IdentityDocumentsService } from './sub-resources/identity-documents.service';
import { FamilyMembersController } from './sub-resources/family-members.controller';
import { FamilyMembersService } from './sub-resources/family-members.service';
import { EducationsController } from './sub-resources/educations.controller';
import { EducationsService } from './sub-resources/educations.service';
import { QualificationsController } from './sub-resources/qualifications.controller';
import { QualificationsService } from './sub-resources/qualifications.service';
import { TrainingsController } from './sub-resources/trainings.controller';
import { TrainingsService } from './sub-resources/trainings.service';
import { DisciplinarySanctionsController } from './sub-resources/disciplinary-sanctions.controller';
import { DisciplinarySanctionsService } from './sub-resources/disciplinary-sanctions.service';
import { BenefitController } from './sub-resources/benefit.controller';
import { BenefitService } from './sub-resources/benefit.service';
import { ContractsController } from './sub-resources/contracts.controller';
import { ContractsService } from './sub-resources/contracts.service';
@Module({
controllers: [
EmployeesController,
IdentityDocumentsController,
FamilyMembersController,
EducationsController,
QualificationsController,
TrainingsController,
DisciplinarySanctionsController,
BenefitController,
ContractsController,
],
providers: [
EmployeesService,
IdentityDocumentsService,
FamilyMembersService,
EducationsService,
QualificationsService,
TrainingsService,
DisciplinarySanctionsService,
BenefitService,
ContractsService,
],
exports: [EmployeesService],
})
export class EmployeesModule {}
@@ -0,0 +1,184 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { AuditService } from '../../common/audit/audit.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { QueryEmployeeDto } from './dto/query-employee.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class EmployeesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async findAll(query: QueryEmployeeDto, actorId: string, actorRole: string) {
const { search, status, departmentId, page = 1, limit = 20 } = query;
const where: Prisma.EmployeeWhereInput = {
...(status && { status }),
...(search && {
OR: [
{ idnp: { contains: search } },
{ nume: { contains: search, mode: 'insensitive' } },
{ prenume: { contains: search, mode: 'insensitive' } },
],
}),
...(departmentId && {
contracts: { some: { departmentId, dataDemisiei: null } },
}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.employee.count({ where }),
this.prisma.employee.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: [{ nume: 'asc' }, { prenume: 'asc' }],
select: {
id: true,
idnp: true,
nume: true,
prenume: true,
sex: true,
status: true,
dataNasterii: true,
telefonPersonal: true,
emailCorporativ: true,
contracts: {
where: { dataDemisiei: null },
take: 1,
select: { functiaOrganigrama: true, department: { select: { name: true } } },
},
},
}),
]);
await this.audit.logRead({
userId: actorId,
userRole: actorRole,
entity: 'Employee',
entityId: 'list',
});
return { total, page, limit, items };
}
async findOne(id: string, actorId: string, actorRole: string, reason?: string) {
const employee = await this.prisma.employee.findUnique({
where: { id },
include: {
identityDocuments: true,
familyMembers: { include: { tipScutire: true } },
educations: true,
qualifications: true,
trainings: true,
disciplinarySanctions: true,
benefit: { include: { uniforma: true, halat: true, ciupici: true, vesta: true, aparatTelefon: true } },
contracts: { include: { department: true, categoriiServicii: true } },
gradDizabilitate: true,
medicalProfile: { include: { workplaceRiskCard: true } },
},
});
if (!employee) throw new NotFoundException(`Angajatul cu id=${id} nu există`);
await this.audit.logRead({
userId: actorId,
userRole: actorRole,
entity: 'Employee',
entityId: id,
reason,
});
return employee;
}
async create(dto: CreateEmployeeDto, actorId: string, actorRole: string) {
const exists = await this.prisma.employee.findUnique({
where: { idnp: dto.idnp },
});
if (exists) throw new ConflictException(`IDNP ${dto.idnp} deja există`);
// Бизнес-правило: нельзя рекомендовать супруга текущего сотрудника
if (dto.recomandareInternaId) {
await this.validateRecomandare(dto.idnp, dto.recomandareInternaId);
}
const employee = await this.prisma.employee.create({
data: { ...dto, dataNasterii: new Date(dto.dataNasterii) },
});
await this.audit.logChange({
userId: actorId,
userRole: actorRole,
action: 'CREATE',
entity: 'Employee',
entityId: employee.id,
});
return employee;
}
async update(
id: string,
dto: Partial<CreateEmployeeDto>,
actorId: string,
actorRole: string,
) {
const existing = await this.findOne(id, actorId, actorRole);
if (dto.recomandareInternaId) {
await this.validateRecomandare(existing.idnp, dto.recomandareInternaId);
}
const updated = await this.prisma.employee.update({
where: { id },
data: {
...dto,
...(dto.dataNasterii && { dataNasterii: new Date(dto.dataNasterii) }),
},
});
await this.audit.logChange({
userId: actorId,
userRole: actorRole,
action: 'UPDATE',
entity: 'Employee',
entityId: id,
});
return updated;
}
// Проверка: нельзя назначить рекомендатора, который является супругом текущего сотрудника
private async validateRecomandare(
currentIdnp: string,
recomandareId: string,
) {
const current = await this.prisma.employee.findUnique({
where: { idnp: currentIdnp },
include: {
familyMembers: { where: { tip: { in: ['sot', 'sotie'] } } },
},
});
const spouseIdnps = current?.familyMembers.map((f) => f.idnp) ?? [];
const recommender = await this.prisma.employee.findUnique({
where: { id: recomandareId },
select: { idnp: true },
});
if (recommender && spouseIdnps.includes(recommender.idnp)) {
throw new BadRequestException(
'Nu se poate selecta soțul/soția angajatului ca recomandare internă',
);
}
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Body, Param, ParseUUIDPipe, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { BenefitService } from './benefit.service';
import { UpsertBenefitDto } from './upsert-benefit.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/benefit')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class BenefitController {
constructor(private readonly svc: BenefitService) {}
@Get()
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findOne(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.svc.findOne(employeeId);
}
@Post()
@Roles('hr_admin', 'hr_specialist')
upsert(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: UpsertBenefitDto, @Request() req: AuthReq) {
return this.svc.upsert(employeeId, dto, req.user.id, req.user.role);
}
}
@@ -0,0 +1,74 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { UpsertBenefitDto } from './upsert-benefit.dto';
const INVENTORY_FIELDS = [
'uniformaId',
'halatId',
'ciupiciId',
'vestaId',
'aparatTelefonId',
] as const;
type InventoryField = (typeof INVENTORY_FIELDS)[number];
@Injectable()
export class BenefitService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findOne(employeeId: string) {
return this.prisma.benefit.findUnique({
where: { employeeId },
include: {
uniforma: true,
halat: true,
ciupici: true,
vesta: true,
aparatTelefon: true,
},
});
}
async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) {
await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } });
const old = await this.prisma.benefit.findUnique({ where: { employeeId } });
const record = await this.prisma.$transaction(async (tx) => {
for (const f of INVENTORY_FIELDS) {
const oldId = (old?.[f as InventoryField] as string | null | undefined) ?? null;
const newId = (dto[f] as string | null | undefined) ?? null;
if (oldId === newId) continue;
if (oldId) {
await tx.inventoryItem.update({
where: { id: oldId },
data: { stockQty: { increment: 1 } },
});
}
if (newId) {
const item = await tx.inventoryItem.update({
where: { id: newId },
data: { stockQty: { decrement: 1 } },
});
if (item.stockQty < 0) {
throw new BadRequestException(`Stoc epuizat pentru ${f}`);
}
}
}
return tx.benefit.upsert({
where: { employeeId },
create: { ...dto, employeeId },
update: dto,
});
});
await this.audit.logChange({
userId,
userRole: role,
action: old ? 'UPDATE' : 'CREATE',
entity: 'Benefit',
entityId: record.id,
});
return record;
}
}
@@ -0,0 +1,83 @@
import {
Controller, Get, Post, Patch, Delete, Body, Param,
ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { ContractsService } from './contracts.service';
import { CreateContractDto } from './create-contract.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/contracts')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class ContractsController {
constructor(private readonly svc: ContractsService) {}
@Get()
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.svc.findAll(employeeId);
}
/** MAX(zile_concediu) across all CIM of this employee */
@Get('zile-concediu-max')
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee')
getMaxZileConcediu(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.svc.getMaxZileConcediu(employeeId);
}
@Get(':id')
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee')
findOne(
@Param('employeeId', ParseUUIDPipe) employeeId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.svc.findOne(employeeId, id);
}
@Post()
@Roles('hr_admin', 'hr_specialist')
@HttpCode(HttpStatus.CREATED)
create(
@Param('employeeId', ParseUUIDPipe) employeeId: string,
@Body() dto: CreateContractDto,
@Request() req: AuthReq,
) {
return this.svc.create(employeeId, dto, req.user.id, req.user.role);
}
@Patch(':id')
@Roles('hr_admin', 'hr_specialist')
update(
@Param('employeeId', ParseUUIDPipe) employeeId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: Partial<CreateContractDto>,
@Request() req: AuthReq,
) {
return this.svc.update(employeeId, id, dto, req.user.id, req.user.role);
}
@Patch(':id/terminate')
@Roles('hr_admin', 'hr_specialist')
terminate(
@Param('employeeId', ParseUUIDPipe) employeeId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('dataDemisiei') dataDemisiei: string,
@Request() req: AuthReq,
) {
return this.svc.terminate(employeeId, id, dataDemisiei, req.user.id, req.user.role);
}
@Delete(':id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
remove(
@Param('employeeId', ParseUUIDPipe) employeeId: string,
@Param('id', ParseUUIDPipe) id: string,
@Request() req: AuthReq,
) {
return this.svc.remove(employeeId, id, req.user.id, req.user.role);
}
}
@@ -0,0 +1,124 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateContractDto } from './create-contract.dto';
@Injectable()
export class ContractsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.employmentContract.findMany({
where: { employeeId },
include: {
department: true,
workSchedule: true,
categoriiServicii: true,
},
orderBy: { dataAngajarii: 'desc' },
});
}
async findOne(employeeId: string, id: string) {
const c = await this.prisma.employmentContract.findFirst({
where: { id, employeeId },
include: { department: true, workSchedule: true, categoriiServicii: true },
});
if (!c) throw new NotFoundException();
return c;
}
async create(employeeId: string, dto: CreateContractDto, userId: string, role: string) {
await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } });
const existing = await this.prisma.employmentContract.findUnique({ where: { nrCim: dto.nrCim } });
if (existing) throw new ConflictException(`Contractul cu nr. ${dto.nrCim} există deja`);
const { categoriiServicii, ...rest } = dto;
const contract = await this.prisma.employmentContract.create({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: {
...rest,
employeeId,
dataSemnarii: new Date(dto.dataSemnarii),
dataAngajarii: new Date(dto.dataAngajarii),
dataDemisiei: dto.dataDemisiei ? new Date(dto.dataDemisiei) : null,
dataTerminarii: dto.dataTerminarii ? new Date(dto.dataTerminarii) : null,
salarizareDetails: (dto.salarizareDetails ?? null) as never,
clausaAditionala: (dto.clausaAditionala ?? null) as never,
categoriiServicii: categoriiServicii?.length
? { create: categoriiServicii }
: undefined,
} as never,
include: { department: true, workSchedule: true, categoriiServicii: true },
});
await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'EmploymentContract', entityId: contract.id });
return contract;
}
async update(employeeId: string, id: string, dto: Partial<CreateContractDto>, userId: string, role: string) {
await this.findOne(employeeId, id);
const { categoriiServicii, ...rest } = dto;
if (rest.dataSemnarii) (rest as Record<string, unknown>).dataSemnarii = new Date(rest.dataSemnarii) as unknown;
if (rest.dataAngajarii) (rest as Record<string, unknown>).dataAngajarii = new Date(rest.dataAngajarii) as unknown;
if (rest.dataDemisiei !== undefined) (rest as Record<string, unknown>).dataDemisiei = rest.dataDemisiei ? new Date(rest.dataDemisiei) : null;
if (rest.dataTerminarii !== undefined) (rest as Record<string, unknown>).dataTerminarii = rest.dataTerminarii ? new Date(rest.dataTerminarii) : null;
const updated = await this.prisma.employmentContract.update({
where: { id },
data: {
...rest,
...(rest.salarizareDetails !== undefined ? { salarizareDetails: (rest.salarizareDetails ?? null) as never } : {}),
...(rest.clausaAditionala !== undefined ? { clausaAditionala: (rest.clausaAditionala ?? null) as never } : {}),
...(categoriiServicii !== undefined ? {
categoriiServicii: { deleteMany: {}, create: categoriiServicii },
} : {}),
} as never,
include: { department: true, workSchedule: true, categoriiServicii: true },
});
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EmploymentContract', entityId: id });
return updated;
}
async remove(employeeId: string, id: string, userId: string, role: string) {
await this.findOne(employeeId, id);
await this.prisma.employmentContract.delete({ where: { id } });
await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'EmploymentContract', entityId: id });
}
/**
* MAX(zile_concediu) across all contracts of an employee.
* `zileConcediu` lives inside the `salarizareDetails` JSONB column.
* Returns null if the employee has no contracts or no contract carries the field.
*/
async getMaxZileConcediu(employeeId: string): Promise<{ employeeId: string; maxZileConcediu: number | null; contractsConsidered: number }> {
const contracts = await this.prisma.employmentContract.findMany({
where: { employeeId },
select: { salarizareDetails: true },
});
let max: number | null = null;
for (const c of contracts) {
const raw = (c.salarizareDetails as { zileConcediu?: unknown } | null)?.zileConcediu;
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
if (Number.isFinite(n) && n > 0 && (max === null || n > max)) max = n;
}
return { employeeId, maxZileConcediu: max, contractsConsidered: contracts.length };
}
/** Terminate a contract — sets dataDemisiei, keeps record */
async terminate(employeeId: string, id: string, dataDemisiei: string, userId: string, role: string) {
await this.findOne(employeeId, id);
const updated = await this.prisma.employmentContract.update({
where: { id },
data: { dataDemisiei: new Date(dataDemisiei) },
include: { department: true, workSchedule: true, categoriiServicii: true },
});
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EmploymentContract', entityId: id });
return updated;
}
}
@@ -0,0 +1,78 @@
import {
IsEnum, IsDateString, IsString, IsOptional, IsUUID,
IsArray, ValidateNested, IsIn, IsNumber, Min,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CimServiceCategoryDto {
@IsString()
categorieId!: string;
@IsIn(['tarif', 'procent'])
tipRemunerare!: 'tarif' | 'procent';
@IsOptional() @IsNumber({ maxDecimalPlaces: 2 }) @Min(0)
sumaNeta?: number;
@IsOptional() @IsNumber({ maxDecimalPlaces: 2 }) @Min(0)
procent?: number;
}
export class CreateContractDto {
@IsString()
nrCim!: string;
@IsEnum(['principal', 'secundar'])
categorie!: 'principal' | 'secundar';
@IsDateString()
dataSemnarii!: string;
@IsDateString()
dataAngajarii!: string;
@IsOptional() @IsDateString()
dataDemisiei?: string;
@IsEnum(['determinata', 'nedeterminata', 'replasare_temporara'])
perioada!: 'determinata' | 'nedeterminata' | 'replasare_temporara';
@IsOptional() @IsDateString()
dataTerminarii?: string;
@IsOptional() @IsString()
functiaClasificator?: string;
@IsOptional() @IsString()
codFunctie?: string;
@IsOptional() @IsString()
functiaOrganigrama?: string;
@IsEnum(['de_baza', 'cumul'])
tipCim!: 'de_baza' | 'cumul';
@IsUUID()
departmentId!: string;
@IsOptional() @IsString()
regimMunca?: string;
@IsOptional() @IsEnum(['fix', 'pe_ore', 'in_acord'])
tipSalarizare?: 'fix' | 'pe_ore' | 'in_acord';
@IsOptional()
salarizareDetails?: unknown;
@IsOptional()
clausaAditionala?: unknown;
@IsOptional() @IsUUID()
workScheduleId?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CimServiceCategoryDto)
categoriiServicii?: CimServiceCategoryDto[];
}
@@ -0,0 +1,11 @@
import { IsEnum, IsDateString } from 'class-validator';
import { DisciplinarySanctionType } from '@prisma/client';
export class CreateDisciplinarySanctionDto {
@IsEnum(DisciplinarySanctionType)
tip!: DisciplinarySanctionType;
@IsDateString()
dataAplicarii!: string;
// dataExpirarii is computed server-side: dataAplicarii + 6 months
}
@@ -0,0 +1,15 @@
import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator';
import { StudyType, StudyLevel, PostUniversityType, DiplomaStatus } from '@prisma/client';
export class CreateEducationDto {
@IsEnum(StudyType) tipStudii!: StudyType;
@IsString() institutia!: string;
@IsString() specialitatea!: string;
@IsOptional() @IsDateString() dataAbsolvirii?: string;
@IsOptional() @IsString() nrSeriaDiploma?: string;
@IsOptional() @IsDateString() dataEmiterii?: string;
@IsOptional() @IsString() nrInregistrare?: string;
@IsOptional() @IsEnum(DiplomaStatus) confirmare?: DiplomaStatus;
@IsOptional() @IsEnum(StudyLevel) nivel?: StudyLevel;
@IsOptional() @IsEnum(PostUniversityType) tipPostuniversitar?: PostUniversityType;
}
@@ -0,0 +1,22 @@
import { IsEnum, IsString, IsOptional, IsDateString, IsUUID } from 'class-validator';
import { FamilyMemberType } from '@prisma/client';
export class CreateFamilyMemberDto {
@IsEnum(FamilyMemberType)
tip!: FamilyMemberType;
@IsString()
numePrenume!: string;
@IsOptional() @IsDateString()
dataNasterii?: string;
@IsOptional() @IsString()
idnp?: string;
@IsOptional() @IsString()
telefon?: string;
@IsOptional() @IsUUID()
tipScutireId?: string;
}
@@ -0,0 +1,23 @@
import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator';
import { DocumentType } from '@prisma/client';
export class CreateIdentityDocumentDto {
@IsEnum(DocumentType)
tipAct!: DocumentType;
@IsOptional()
@IsString()
seria?: string;
@IsString()
nr!: string;
@IsDateString()
dataEmiterii!: string;
@IsString()
autoritateEmitenta!: string;
@IsDateString()
dataExpirarii!: string;
}
@@ -0,0 +1,10 @@
import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator';
import { QualificationCategory } from '@prisma/client';
export class CreateQualificationDto {
@IsEnum(QualificationCategory) categorie!: QualificationCategory;
@IsOptional() @IsString() specialitate?: string;
@IsOptional() @IsDateString() dataObtinerii?: string;
@IsOptional() @IsDateString() dataUltimeiConfirmari?: string;
@IsOptional() @IsDateString() dataExpirarii?: string;
}
@@ -0,0 +1,14 @@
import { IsEnum, IsString, IsOptional, IsDateString, IsBoolean, IsInt, IsPositive } from 'class-validator';
import { TrainingType } from '@prisma/client';
export class CreateTrainingDto {
@IsString() denumire!: string;
@IsDateString() inceput!: string;
@IsOptional() @IsDateString() sfirsit?: string;
@IsEnum(TrainingType) tip!: TrainingType;
@IsOptional() @IsString() tara?: string;
@IsOptional() @IsInt() @IsPositive() nrOre?: number;
@IsOptional() @IsString() organizatia?: string;
@IsBoolean() certificat!: boolean;
@IsOptional() @IsString() cost?: string;
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { DisciplinarySanctionsService } from './disciplinary-sanctions.service';
import { CreateDisciplinarySanctionDto } from './create-disciplinary-sanction.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/disciplinary-sanctions')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class DisciplinarySanctionsController {
constructor(private readonly svc: DisciplinarySanctionsService) {}
@Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); }
@Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateDisciplinarySanctionDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); }
@Patch(':id') @Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateDisciplinarySanctionDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); }
@Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); }
}
@@ -0,0 +1,52 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateDisciplinarySanctionDto } from './create-disciplinary-sanction.dto';
@Injectable()
export class DisciplinarySanctionsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.disciplinarySanction.findMany({ where: { employeeId }, orderBy: { dataAplicarii: 'desc' } });
}
async create(employeeId: string, dto: CreateDisciplinarySanctionDto, userId: string, role: string) {
await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } });
const dataAplicarii = new Date(dto.dataAplicarii);
const dataExpirarii = new Date(dataAplicarii);
dataExpirarii.setMonth(dataExpirarii.getMonth() + 6);
const record = await this.prisma.disciplinarySanction.create({
data: { tip: dto.tip, dataAplicarii, dataExpirarii, employeeId },
});
await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'DisciplinarySanction', entityId: record.id });
return record;
}
async update(employeeId: string, id: string, dto: Partial<CreateDisciplinarySanctionDto>, userId: string, role: string) {
const existing = await this.prisma.disciplinarySanction.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
const updateData: { tip?: typeof dto.tip; dataAplicarii?: Date; dataExpirarii?: Date } = {};
if (dto.tip) updateData.tip = dto.tip;
if (dto.dataAplicarii) {
const dataAplicarii = new Date(dto.dataAplicarii);
const dataExpirarii = new Date(dataAplicarii);
dataExpirarii.setMonth(dataExpirarii.getMonth() + 6);
updateData.dataAplicarii = dataAplicarii;
updateData.dataExpirarii = dataExpirarii;
}
const updated = await this.prisma.disciplinarySanction.update({ where: { id }, data: updateData });
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'DisciplinarySanction', entityId: id });
return updated;
}
async remove(employeeId: string, id: string, userId: string, role: string) {
const existing = await this.prisma.disciplinarySanction.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
await this.prisma.disciplinarySanction.delete({ where: { id } });
await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'DisciplinarySanction', entityId: id });
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { EducationsService } from './educations.service';
import { CreateEducationDto } from './create-education.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/educations')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class EducationsController {
constructor(private readonly svc: EducationsService) {}
@Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); }
@Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateEducationDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); }
@Patch(':id') @Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateEducationDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); }
@Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); }
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateEducationDto } from './create-education.dto';
import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory';
@Injectable()
export class EducationsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.education.findMany({ where: { employeeId }, orderBy: { dataAbsolvirii: 'desc' } });
}
create(employeeId: string, dto: CreateEducationDto, userId: string, role: string) {
return subCreate(this.prisma, this.audit, this.prisma.education as never, employeeId, dto, userId, role, 'Education');
}
update(employeeId: string, id: string, dto: Partial<CreateEducationDto>, userId: string, role: string) {
return subUpdate(this.prisma.education as never, this.audit, employeeId, id, dto, userId, role, 'Education');
}
remove(employeeId: string, id: string, userId: string, role: string) {
return subRemove(this.prisma.education as never, this.audit, employeeId, id, userId, role, 'Education');
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { FamilyMembersService } from './family-members.service';
import { CreateFamilyMemberDto } from './create-family-member.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/family-members')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class FamilyMembersController {
constructor(private readonly svc: FamilyMembersService) {}
@Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); }
@Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateFamilyMemberDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); }
@Patch(':id') @Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateFamilyMemberDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); }
@Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); }
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateFamilyMemberDto } from './create-family-member.dto';
import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory';
@Injectable()
export class FamilyMembersService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.familyMember.findMany({ where: { employeeId }, include: { tipScutire: true } });
}
create(employeeId: string, dto: CreateFamilyMemberDto, userId: string, role: string) {
return subCreate(this.prisma, this.audit, this.prisma.familyMember as never, employeeId, dto, userId, role, 'FamilyMember');
}
update(employeeId: string, id: string, dto: Partial<CreateFamilyMemberDto>, userId: string, role: string) {
return subUpdate(this.prisma.familyMember as never, this.audit, employeeId, id, dto, userId, role, 'FamilyMember');
}
remove(employeeId: string, id: string, userId: string, role: string) {
return subRemove(this.prisma.familyMember as never, this.audit, employeeId, id, userId, role, 'FamilyMember');
}
}
@@ -0,0 +1,40 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { IdentityDocumentsService } from './identity-documents.service';
import { CreateIdentityDocumentDto } from './create-identity-document.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/identity-documents')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class IdentityDocumentsController {
constructor(private readonly svc: IdentityDocumentsService) {}
@Get()
@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.svc.findAll(employeeId);
}
@Post()
@Roles('hr_admin', 'hr_specialist')
@HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateIdentityDocumentDto, @Request() req: AuthReq) {
return this.svc.create(employeeId, dto, req.user.id, req.user.role);
}
@Patch(':id')
@Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateIdentityDocumentDto>, @Request() req: AuthReq) {
return this.svc.update(employeeId, id, dto, req.user.id, req.user.role);
}
@Delete(':id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.svc.remove(employeeId, id, req.user.id, req.user.role);
}
}
@@ -0,0 +1,36 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateIdentityDocumentDto } from './create-identity-document.dto';
import { parseDateFields } from './sub-resources.service-factory';
@Injectable()
export class IdentityDocumentsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.identityDocument.findMany({ where: { employeeId }, orderBy: { dataExpirarii: 'asc' } });
}
async create(employeeId: string, dto: CreateIdentityDocumentDto, userId: string, role: string) {
await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } });
const doc = await this.prisma.identityDocument.create({ data: { ...parseDateFields(dto), employeeId } });
await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'IdentityDocument', entityId: doc.id });
return doc;
}
async update(employeeId: string, id: string, dto: Partial<CreateIdentityDocumentDto>, userId: string, role: string) {
const existing = await this.prisma.identityDocument.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
const updated = await this.prisma.identityDocument.update({ where: { id }, data: parseDateFields(dto) });
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'IdentityDocument', entityId: id });
return updated;
}
async remove(employeeId: string, id: string, userId: string, role: string) {
const existing = await this.prisma.identityDocument.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
await this.prisma.identityDocument.delete({ where: { id } });
await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'IdentityDocument', entityId: id });
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { QualificationsService } from './qualifications.service';
import { CreateQualificationDto } from './create-qualification.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/qualifications')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class QualificationsController {
constructor(private readonly svc: QualificationsService) {}
@Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); }
@Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateQualificationDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); }
@Patch(':id') @Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateQualificationDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); }
@Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); }
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateQualificationDto } from './create-qualification.dto';
import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory';
@Injectable()
export class QualificationsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.qualification.findMany({ where: { employeeId }, orderBy: { dataExpirarii: 'asc' } });
}
create(employeeId: string, dto: CreateQualificationDto, userId: string, role: string) {
return subCreate(this.prisma, this.audit, this.prisma.qualification as never, employeeId, dto, userId, role, 'Qualification');
}
update(employeeId: string, id: string, dto: Partial<CreateQualificationDto>, userId: string, role: string) {
return subUpdate(this.prisma.qualification as never, this.audit, employeeId, id, dto, userId, role, 'Qualification');
}
remove(employeeId: string, id: string, userId: string, role: string) {
return subRemove(this.prisma.qualification as never, this.audit, employeeId, id, userId, role, 'Qualification');
}
}
@@ -0,0 +1,73 @@
// Shared helper used by all sub-resource services
import { NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
// Converts "YYYY-MM-DD" string values to Date objects so Prisma @db.Date fields accept them
export function parseDateFields<T extends object>(obj: T): T {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
result[key] = new Date(value);
} else {
result[key] = value;
}
}
return result as T;
}
export type SubResourceDelegate = {
findMany: (args: unknown) => Promise<unknown[]>;
findFirst: (args: unknown) => Promise<unknown | null>;
create: (args: unknown) => Promise<unknown>;
update: (args: unknown) => Promise<unknown>;
delete: (args: unknown) => Promise<unknown>;
};
export async function subCreate<T>(
prisma: PrismaService,
audit: AuditService,
delegate: SubResourceDelegate,
employeeId: string,
data: T,
userId: string,
role: string,
entity: string,
) {
await prisma.employee.findUniqueOrThrow({ where: { id: employeeId } });
const record = await delegate.create({ data: { ...parseDateFields(data as object), employeeId } }) as { id: string };
await audit.logChange({ userId, userRole: role, action: 'CREATE', entity, entityId: record.id });
return record;
}
export async function subUpdate<T>(
delegate: SubResourceDelegate,
audit: AuditService,
employeeId: string,
id: string,
data: T,
userId: string,
role: string,
entity: string,
) {
const existing = await delegate.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
const updated = await delegate.update({ where: { id }, data: parseDateFields(data as object) });
await audit.logChange({ userId, userRole: role, action: 'UPDATE', entity, entityId: id });
return updated;
}
export async function subRemove(
delegate: SubResourceDelegate,
audit: AuditService,
employeeId: string,
id: string,
userId: string,
role: string,
entity: string,
) {
const existing = await delegate.findFirst({ where: { id, employeeId } });
if (!existing) throw new NotFoundException();
await delegate.delete({ where: { id } });
await audit.logChange({ userId, userRole: role, action: 'DELETE', entity, entityId: id });
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { TrainingsService } from './trainings.service';
import { CreateTrainingDto } from './create-training.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('employees/:employeeId/trainings')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class TrainingsController {
constructor(private readonly svc: TrainingsService) {}
@Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee')
findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); }
@Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED)
create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateTrainingDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); }
@Patch(':id') @Roles('hr_admin', 'hr_specialist')
update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateTrainingDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); }
@Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); }
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { AuditService } from '../../../common/audit/audit.service';
import { CreateTrainingDto } from './create-training.dto';
import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory';
@Injectable()
export class TrainingsService {
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
findAll(employeeId: string) {
return this.prisma.training.findMany({ where: { employeeId }, orderBy: { inceput: 'desc' } });
}
create(employeeId: string, dto: CreateTrainingDto, userId: string, role: string) {
return subCreate(this.prisma, this.audit, this.prisma.training as never, employeeId, dto, userId, role, 'Training');
}
update(employeeId: string, id: string, dto: Partial<CreateTrainingDto>, userId: string, role: string) {
return subUpdate(this.prisma.training as never, this.audit, employeeId, id, dto, userId, role, 'Training');
}
remove(employeeId: string, id: string, userId: string, role: string) {
return subRemove(this.prisma.training as never, this.audit, employeeId, id, userId, role, 'Training');
}
}
@@ -0,0 +1,16 @@
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpsertBenefitDto {
@IsBoolean() ticheteMasa!: boolean;
@IsOptional() @IsString() valoareTichet?: string;
@IsBoolean() alimentatiePersonal!: boolean;
@IsOptional() @IsString() abonamentTel?: string;
@IsOptional() @IsString() cardCompanie?: string;
@IsOptional() @IsString() automobilServiciu?: string;
@IsOptional() @IsUUID() uniformaId?: string | null;
@IsOptional() @IsUUID() halatId?: string | null;
@IsOptional() @IsUUID() ciupiciId?: string | null;
@IsOptional() @IsUUID() vestaId?: string | null;
@IsOptional() @IsUUID() aparatTelefonId?: string | null;
}
@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ProposedCategory } from '@prisma/client';
export class ApproveFormDto {
@IsEnum(ProposedCategory)
categorieAprobata!: ProposedCategory;
@IsOptional()
@IsString()
observatii?: string;
}
@@ -0,0 +1,14 @@
import { IsString, IsUUID, IsDateString, MinLength } from 'class-validator';
export class CreateCampaignDto {
@IsString()
@MinLength(2)
name!: string;
@IsUUID()
departmentId!: string;
// First day of the campaign month — format: YYYY-MM-01
@IsDateString()
month!: string;
}
@@ -0,0 +1,34 @@
import { IsEnum, IsOptional, IsBoolean, IsObject, IsString } from 'class-validator';
import { EvaluationScore } from '@prisma/client';
export class UpdateFormDto {
// A. Competente clinice
@IsOptional() @IsEnum(EvaluationScore) abilitatiClinice?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) judecataClinica?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) manopere?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) gestionareaSarcinilor?: EvaluationScore;
// B. Comunicare si empatie
@IsOptional() @IsEnum(EvaluationScore) constiintaProfesionala?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) atitudineaPacienti?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) atitudineaColegi?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) atitudineaPersonalNonMed?: EvaluationScore;
// C. Disciplina
@IsOptional() @IsEnum(EvaluationScore) utilizareSmartphone?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) respectareaProgramului?: EvaluationScore;
@IsOptional() @IsEnum(EvaluationScore) respectareaDressCode?: EvaluationScore;
// D. Documentatie
@IsOptional() @IsObject() testJci?: Record<string, unknown>;
@IsOptional() @IsBoolean() completareaDocMed?: boolean;
@IsOptional() @IsBoolean() perfectioneazaCunostinte?: boolean;
// E. Candidat expert
@IsOptional() @IsBoolean() membruComitetCalitate?: boolean;
@IsOptional() @IsBoolean() functieDeMonitor?: boolean;
@IsOptional() @IsBoolean() inlocuiesteSuperiorul?: boolean;
// F. Observatii (director overrides category separately)
@IsOptional() @IsString() observatii?: string;
}
@@ -0,0 +1,102 @@
import {
Controller, Get, Post, Patch, Delete, Body, Param, Query,
ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { EvaluationService } from './evaluation.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateFormDto } from './dto/update-form.dto';
import { ApproveFormDto } from './dto/approve-form.dto';
import { CampaignStatus } from '@prisma/client';
import { IsEnum } from 'class-validator';
class UpdateStatusDto {
@IsEnum(CampaignStatus)
status!: CampaignStatus;
}
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('evaluation')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class EvaluationController {
constructor(private readonly svc: EvaluationService) {}
// ── Campaigns ──────────────────────────────────────────────────────────────
@Get('campaigns')
@Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager')
listCampaigns(@Query('departmentId') deptId?: string) {
return this.svc.findAllCampaigns(deptId);
}
@Get('campaigns/:id')
@Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager')
getCampaign(@Param('id', ParseUUIDPipe) id: string) {
return this.svc.findCampaign(id);
}
@Post('campaigns')
@Roles('hr_admin')
@HttpCode(HttpStatus.CREATED)
createCampaign(@Body() dto: CreateCampaignDto, @Request() req: AuthReq) {
return this.svc.createCampaign(dto, req.user.id, req.user.role);
}
// Generate evaluation forms for all eligible employees
@Post('campaigns/:id/generate-forms')
@Roles('hr_admin')
generateForms(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.svc.generateForms(id, req.user.id, req.user.role);
}
@Patch('campaigns/:id/status')
@Roles('hr_admin', 'nursing_director')
updateStatus(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateStatusDto, @Request() req: AuthReq) {
return this.svc.updateCampaignStatus(id, dto.status, req.user.id, req.user.role);
}
@Delete('campaigns/:id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
deleteCampaign(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.svc.deleteCampaign(id, req.user.id, req.user.role);
}
// ── Forms ──────────────────────────────────────────────────────────────────
@Get('forms/:id')
@Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager', 'employee')
getForm(@Param('id', ParseUUIDPipe) id: string) {
return this.svc.findForm(id);
}
// quality_auditor + manager fill in scores (blocks A, B, C, D)
@Patch('forms/:id')
@Roles('hr_admin', 'quality_auditor', 'manager')
updateForm(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFormDto, @Request() req: AuthReq) {
return this.svc.updateForm(id, dto, req.user.id, req.user.role);
}
// nursing_director approves final category
@Patch('forms/:id/approve')
@Roles('nursing_director')
approveForm(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveFormDto, @Request() req: AuthReq) {
return this.svc.approveForm(id, dto, req.user.id, req.user.role);
}
// Academy Ocean webhook — no auth guard (uses secret header validation in prod)
@Post('webhook/academy-ocean')
@HttpCode(HttpStatus.OK)
academyOceanWebhook(@Body() payload: {
employeeIdnp: string;
score: number;
maxScore: number;
completedAt: string;
externalId: string;
}) {
return this.svc.receiveAcademyOceanWebhook(payload);
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { HttpModule } from '@nestjs/axios';
import { EvaluationController } from './evaluation.controller';
import { EvaluationService } from './evaluation.service';
import { EvaluationNotificationsProcessor } from './workers/evaluation-notifications.processor';
@Module({
imports: [
BullModule.registerQueue({ name: 'evaluation-notifications' }),
HttpModule,
],
controllers: [EvaluationController],
providers: [EvaluationService, EvaluationNotificationsProcessor],
})
export class EvaluationModule {}
@@ -0,0 +1,278 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { PrismaService } from '../../common/prisma/prisma.service';
import { AuditService } from '../../common/audit/audit.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateFormDto } from './dto/update-form.dto';
import { ApproveFormDto } from './dto/approve-form.dto';
import { CampaignStatus, EvaluationScore, Prisma, ProposedCategory } from '@prisma/client';
// Category calculation algorithm based on A+B+C scores and E expert flags
function calculateCategory(form: {
abilitatiClinice?: EvaluationScore | null;
judecataClinica?: EvaluationScore | null;
manopere?: EvaluationScore | null;
gestionareaSarcinilor?: EvaluationScore | null;
constiintaProfesionala?: EvaluationScore | null;
atitudineaPacienti?: EvaluationScore | null;
atitudineaColegi?: EvaluationScore | null;
atitudineaPersonalNonMed?: EvaluationScore | null;
utilizareSmartphone?: EvaluationScore | null;
respectareaProgramului?: EvaluationScore | null;
respectareaDressCode?: EvaluationScore | null;
membruComitetCalitate?: boolean | null;
functieDeMonitor?: boolean | null;
inlocuiesteSuperiorul?: boolean | null;
}): ProposedCategory {
const scores: (EvaluationScore | null | undefined)[] = [
form.abilitatiClinice, form.judecataClinica, form.manopere, form.gestionareaSarcinilor,
form.constiintaProfesionala, form.atitudineaPacienti, form.atitudineaColegi, form.atitudineaPersonalNonMed,
form.utilizareSmartphone, form.respectareaProgramului, form.respectareaDressCode,
];
const filled = scores.filter((s) => s != null) as EvaluationScore[];
if (filled.length < 8) return ProposedCategory.fara;
const scoreValue = (s: EvaluationScore) =>
s === EvaluationScore.bine ? 2 : s === EvaluationScore.mediu ? 1 : 0;
const total = filled.reduce((acc, s) => acc + scoreValue(s), 0);
const max = filled.length * 2;
const percent = total / max;
const expertCount = [form.membruComitetCalitate, form.functieDeMonitor, form.inlocuiesteSuperiorul]
.filter(Boolean).length;
if (percent >= 0.9 && expertCount >= 1) return ProposedCategory.superioara;
if (percent >= 0.75) return ProposedCategory.cat_I;
if (percent >= 0.5) return ProposedCategory.cat_II;
return ProposedCategory.fara;
}
@Injectable()
export class EvaluationService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue('evaluation-notifications') private readonly notifQueue: Queue,
) {}
// ─── Campaigns ────────────────────────────────────────────
findAllCampaigns(departmentId?: string) {
return this.prisma.evaluationCampaign.findMany({
where: departmentId ? { departmentId } : undefined,
orderBy: { month: 'desc' },
include: {
department: { select: { name: true } },
_count: { select: { forms: true } },
},
});
}
async findCampaign(id: string) {
const campaign = await this.prisma.evaluationCampaign.findUnique({
where: { id },
include: {
department: true,
forms: {
include: {
employee: {
select: { id: true, idnp: true, nume: true, prenume: true, status: true },
},
},
orderBy: [{ categorieAprobata: 'asc' }, { completedAt: 'desc' }],
},
},
});
if (!campaign) throw new NotFoundException(`Campania ${id} nu există`);
return campaign;
}
async createCampaign(dto: CreateCampaignDto, userId: string, role: string) {
const month = new Date(dto.month);
// Check no duplicate campaign for same dept+month
const existing = await this.prisma.evaluationCampaign.findFirst({
where: { departmentId: dto.departmentId, month },
});
if (existing) throw new ConflictException('Există deja o campanie pentru această lună și departament');
const campaign = await this.prisma.evaluationCampaign.create({
data: { name: dto.name, departmentId: dto.departmentId, month },
include: { department: true },
});
await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'EvaluationCampaign', entityId: campaign.id });
return campaign;
}
// Generate forms for all eligible employees (>6 months at campaign month start)
async generateForms(campaignId: string, userId: string, role: string) {
const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id: campaignId } });
if (campaign.status !== CampaignStatus.draft) {
throw new BadRequestException('Formularele se pot genera doar pentru campanii în status draft');
}
// All active employees with a contract in this department (active or not yet dismissed)
const eligible = await this.prisma.employee.findMany({
where: {
status: 'activ',
contracts: {
some: {
departmentId: campaign.departmentId,
OR: [{ dataDemisiei: null }, { dataDemisiei: { gt: campaign.month } }],
},
},
// Skip employees already in this campaign
evaluationForms: { none: { campaignId } },
},
select: { id: true },
});
if (eligible.length === 0) return { generated: 0 };
await this.prisma.evaluationForm.createMany({
data: eligible.map((e) => ({ campaignId, employeeId: e.id })),
skipDuplicates: true,
});
await this.prisma.evaluationCampaign.update({
where: { id: campaignId },
data: { status: CampaignStatus.scheduled },
});
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationCampaign', entityId: campaignId });
// Schedule 14-day notification job
const campaignDate = new Date(campaign.month);
const notifyAt = new Date(campaignDate);
notifyAt.setDate(notifyAt.getDate() - 14);
const delay = Math.max(0, notifyAt.getTime() - Date.now());
await this.notifQueue.add('campaign-reminder', { campaignId }, { delay });
return { generated: eligible.length };
}
async updateCampaignStatus(id: string, status: CampaignStatus, userId: string, role: string) {
const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id } });
const allowed: Record<CampaignStatus, CampaignStatus[]> = {
draft: [CampaignStatus.scheduled],
scheduled: [CampaignStatus.in_progress, CampaignStatus.draft],
in_progress: [CampaignStatus.closed],
closed: [],
};
if (!allowed[campaign.status].includes(status)) {
throw new BadRequestException(`Tranziție invalidă: ${campaign.status}${status}`);
}
const updated = await this.prisma.evaluationCampaign.update({ where: { id }, data: { status } });
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationCampaign', entityId: id });
return updated;
}
async deleteCampaign(id: string, userId: string, role: string) {
const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id } });
if (campaign.status === CampaignStatus.in_progress) {
throw new BadRequestException('Nu se poate șterge o campanie în desfășurare.');
}
await this.prisma.evaluationCampaign.delete({ where: { id } });
await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'EvaluationCampaign', entityId: id });
}
// ─── Forms ────────────────────────────────────────────────
findForm(id: string) {
return this.prisma.evaluationForm.findUniqueOrThrow({
where: { id },
include: {
employee: {
select: {
id: true, idnp: true, nume: true, prenume: true, status: true,
qualifications: { orderBy: { dataExpirarii: 'desc' }, take: 1 },
disciplinarySanctions: { where: { isStinsa: false } },
},
},
campaign: { include: { department: { select: { name: true } } } },
},
});
}
async updateForm(id: string, dto: UpdateFormDto, userId: string, role: string) {
const form = await this.prisma.evaluationForm.findUniqueOrThrow({ where: { id } });
// Merge with existing scores to recalculate category
const merged = { ...form, ...dto };
const categorieCalculata = calculateCategory(merged);
const updated = await this.prisma.evaluationForm.update({
where: { id },
data: { ...dto, categorieCalculata } as Prisma.EvaluationFormUpdateInput,
});
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationForm', entityId: id });
return updated;
}
async approveForm(id: string, dto: ApproveFormDto, userId: string, role: string) {
const updated = await this.prisma.evaluationForm.update({
where: { id },
data: {
categorieAprobata: dto.categorieAprobata,
observatii: dto.observatii,
completedAt: new Date(),
},
});
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationForm', entityId: id });
return updated;
}
// Academy Ocean webhook — auto-fill D1 JCI test result
async receiveAcademyOceanWebhook(payload: {
employeeIdnp: string;
score: number;
maxScore: number;
completedAt: string;
externalId: string;
}) {
const employee = await this.prisma.employee.findUnique({ where: { idnp: payload.employeeIdnp } });
if (!employee) return { accepted: false, reason: 'Employee not found' };
// Find open form for this employee (in_progress campaign)
const form = await this.prisma.evaluationForm.findFirst({
where: {
employeeId: employee.id,
campaign: { status: CampaignStatus.in_progress },
completedAt: null,
},
orderBy: { createdAt: 'desc' },
});
if (!form) return { accepted: false, reason: 'No open form found' };
await this.prisma.evaluationForm.update({
where: { id: form.id },
data: {
testJci: {
score: payload.score,
max_score: payload.maxScore,
percent: Math.round((payload.score / payload.maxScore) * 100),
completed_at: payload.completedAt,
source: 'academy_ocean',
external_id: payload.externalId,
},
},
});
return { accepted: true, formId: form.id };
}
}
@@ -0,0 +1,63 @@
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma/prisma.service';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
interface CampaignReminderJob { campaignId: string }
@Injectable()
@Processor('evaluation-notifications')
export class EvaluationNotificationsProcessor {
private readonly logger = new Logger(EvaluationNotificationsProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly http: HttpService,
) {}
@Process('campaign-reminder')
async handleCampaignReminder(job: Job<CampaignReminderJob>) {
const { campaignId } = job.data;
const campaign = await this.prisma.evaluationCampaign.findUnique({
where: { id: campaignId },
include: {
department: true,
forms: { include: { employee: { select: { idnp: true, nume: true, prenume: true } } } },
},
});
if (!campaign) {
this.logger.warn(`Campaign ${campaignId} not found, skipping notification`);
return;
}
const employeeList = campaign.forms.map((f) =>
`${f.employee.nume} ${f.employee.prenume} (${f.employee.idnp})`
).join('\n');
const n8nWebhook = process.env.N8N_WEBHOOK_BASE;
if (!n8nWebhook) {
this.logger.warn('N8N_WEBHOOK_BASE not set, skipping notification');
return;
}
try {
await firstValueFrom(
this.http.post(`${n8nWebhook}/evaluation-reminder`, {
type: 'evaluation-reminder',
campaignName: campaign.name,
departmentName: campaign.department.name,
month: campaign.month,
employeeCount: campaign.forms.length,
employeeList,
}),
);
this.logger.log(`Evaluation reminder sent for campaign ${campaign.name}`);
} catch (err) {
this.logger.error(`Failed to send evaluation reminder: ${(err as Error).message}`);
}
}
}
@@ -0,0 +1,6 @@
import { IsNumber, IsString } from 'class-validator';
export class AdjustStockDto {
@IsNumber() delta!: number;
@IsString() reason!: string;
}
@@ -0,0 +1,13 @@
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { InventoryItemType } from '@prisma/client';
export class CreateInventoryDto {
@IsString() sku!: string;
@IsString() name!: string;
@IsEnum(InventoryItemType) type!: InventoryItemType;
@IsOptional() @IsString() size?: string;
@IsOptional() @IsString() color?: string;
@IsOptional() @IsNumber() pricePerUnit?: number;
@IsNumber() @Min(0) stockQty!: number;
@IsOptional() @IsBoolean() active?: boolean;
}
@@ -0,0 +1,11 @@
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { InventoryItemType } from '@prisma/client';
export class InventoryQueryDto {
@IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType;
@IsOptional() @Type(() => Boolean) @IsBoolean() active?: boolean;
@IsOptional() @IsString() search?: string;
@IsOptional() @Type(() => Number) @IsInt() page?: number = 1;
@IsOptional() @Type(() => Number) @IsInt() limit?: number = 50;
}
@@ -0,0 +1,14 @@
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { InventoryItemType } from '@prisma/client';
// Manual partial of CreateInventoryDto (avoids @nestjs/mapped-types dep).
export class UpdateInventoryDto {
@IsOptional() @IsString() sku?: string;
@IsOptional() @IsString() name?: string;
@IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType;
@IsOptional() @IsString() size?: string;
@IsOptional() @IsString() color?: string;
@IsOptional() @IsNumber() pricePerUnit?: number;
@IsOptional() @IsNumber() @Min(0) stockQty?: number;
@IsOptional() @IsBoolean() active?: boolean;
}
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { InventoryService } from './inventory.service';
import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto';
import { InventoryQueryDto } from './dto/list-query.dto';
import { AdjustStockDto } from './dto/adjust-stock.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('inventory')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class InventoryController {
constructor(private readonly svc: InventoryService) {}
@Get()
@Roles('hr_admin', 'hr_specialist')
list(@Query() q: InventoryQueryDto, @Request() req: AuthReq) {
return this.svc.list(q, req.user.id, req.user.role);
}
@Get(':id')
@Roles('hr_admin', 'hr_specialist')
findOne(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.svc.findOne(id, req.user.id, req.user.role);
}
@Post()
@Roles('hr_admin')
create(@Body() dto: CreateInventoryDto, @Request() req: AuthReq) {
return this.svc.create(dto, req.user.id, req.user.role);
}
@Patch(':id')
@Roles('hr_admin')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateInventoryDto,
@Request() req: AuthReq,
) {
return this.svc.update(id, dto, req.user.id, req.user.role);
}
@Delete(':id')
@Roles('hr_admin')
remove(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.svc.remove(id, req.user.id, req.user.role);
}
@Post(':id/adjust-stock')
@Roles('hr_admin')
adjust(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustStockDto,
@Request() req: AuthReq,
) {
return this.svc.adjustStock(id, dto, req.user.id, req.user.role);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}
@@ -0,0 +1,131 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.service';
import { AuditService } from '../../common/audit/audit.service';
import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto';
import { InventoryQueryDto } from './dto/list-query.dto';
import { AdjustStockDto } from './dto/adjust-stock.dto';
@Injectable()
export class InventoryService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async list(q: InventoryQueryDto, userId: string, role: string) {
const where: Prisma.InventoryItemWhereInput = {};
if (q.type) where.type = q.type;
if (q.active !== undefined) where.active = q.active;
if (q.search) {
where.OR = [
{ sku: { contains: q.search, mode: 'insensitive' } },
{ name: { contains: q.search, mode: 'insensitive' } },
];
}
const limit = Math.min(q.limit ?? 50, 200);
const page = q.page ?? 1;
const [total, items] = await this.prisma.$transaction([
this.prisma.inventoryItem.count({ where }),
this.prisma.inventoryItem.findMany({
where,
orderBy: { name: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: 'LIST' });
return { total, page, limit, items };
}
async findOne(id: string, userId: string, role: string) {
const item = await this.prisma.inventoryItem.findUniqueOrThrow({ where: { id } });
await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: id });
return item;
}
async create(dto: CreateInventoryDto, userId: string, role: string) {
const item = await this.prisma.inventoryItem.create({ data: dto });
await this.audit.logChange({
userId,
userRole: role,
action: 'CREATE',
entity: 'InventoryItem',
entityId: item.id,
});
return item;
}
async update(id: string, dto: UpdateInventoryDto, userId: string, role: string) {
const item = await this.prisma.inventoryItem.update({ where: { id }, data: dto });
await this.audit.logChange({
userId,
userRole: role,
action: 'UPDATE',
entity: 'InventoryItem',
entityId: id,
});
return item;
}
async remove(id: string, userId: string, role: string) {
const used = await this.prisma.benefit.count({
where: {
OR: [
{ uniformaId: id },
{ halatId: id },
{ ciupiciId: id },
{ vestaId: id },
{ aparatTelefonId: id },
],
},
});
if (used > 0) {
await this.prisma.inventoryItem.update({ where: { id }, data: { active: false } });
await this.audit.logChange({
userId,
userRole: role,
action: 'UPDATE',
entity: 'InventoryItem',
entityId: id,
field: 'active',
newValue: 'false',
});
return { softDeleted: true };
}
await this.prisma.inventoryItem.delete({ where: { id } });
await this.audit.logChange({
userId,
userRole: role,
action: 'DELETE',
entity: 'InventoryItem',
entityId: id,
});
return { deleted: true };
}
async adjustStock(id: string, dto: AdjustStockDto, userId: string, role: string) {
const item = await this.prisma.$transaction(async (tx) => {
const updated = await tx.inventoryItem.update({
where: { id },
data: { stockQty: { increment: dto.delta } },
});
if (updated.stockQty < 0) {
throw new BadRequestException('Stoc negativ nu este permis');
}
return updated;
});
await this.audit.logChange({
userId,
userRole: role,
action: 'UPDATE',
entity: 'InventoryItem',
entityId: id,
field: 'stockQty',
newValue: String(item.stockQty),
reason: dto.reason,
});
return item;
}
}
@@ -0,0 +1,60 @@
import {
IsEnum, IsDateString, IsOptional, IsString, IsArray, ArrayNotEmpty, IsUUID,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MedicalCheckupType, MedicalVerdict } from '@prisma/client';
export class CreateCheckupDto {
@IsEnum(MedicalCheckupType)
tip!: MedicalCheckupType;
@IsDateString()
dataPlanificata!: string;
}
export class CompleteCheckupDto {
@IsEnum(MedicalVerdict)
verdict!: MedicalVerdict;
@IsDateString()
dataEfectuata!: string;
@IsOptional()
@IsString()
recomandari?: string;
@IsOptional()
@IsDateString()
valabilPanaLa?: string;
@IsOptional()
@IsString()
semnatDe?: string;
}
export class DocumentContextDto {
@IsOptional() @IsString() telefon?: string;
@IsOptional() @IsString() fax?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() solicitant?: string;
@IsOptional() @IsString() functia?: string;
}
export class BulkInitiateDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
employeeIds!: string[];
@IsEnum(MedicalCheckupType)
tip!: MedicalCheckupType;
@IsDateString()
dataPlanificata!: string;
@IsOptional()
@ValidateNested()
@Type(() => DocumentContextDto)
documentContext?: DocumentContextDto;
}
@@ -0,0 +1,41 @@
import {
IsString, IsOptional, IsBoolean, IsUUID, IsDateString, IsInt, IsNumber, Min,
IsEnum, IsArray, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { OverexposureKind } from '@prisma/client';
// Supraexpunere la radiații ionizante — rând din Anexa 4B.
export class OverexposureDto {
@IsEnum(OverexposureKind) fel!: OverexposureKind;
@IsOptional() @IsString() tipExpunere?: string;
@IsOptional() @IsDateString() data?: string;
@IsOptional() @IsNumber() dozaMsv?: number;
}
export class UpsertMedicalProfileDto {
@IsOptional() @IsString() ocupatieCorm?: string;
@IsOptional() @IsUUID()
workplaceRiskCardId?: string;
@IsOptional() @IsDateString()
dataUltimControlMedical?: string;
@IsBoolean()
expusRadiatiiIonizante!: boolean;
// Conditional fields — required only when expusRadiatiiIonizante = true
@IsOptional() @IsDateString() dataIntrarii?: string;
@IsOptional() @IsString() expunereAnterioaraPerioda?: string;
@IsOptional() @IsInt() @Min(0) expunereAnterioaraAni?: number;
@IsOptional() @IsNumber() dozaCumulataExternaMsv?: number;
@IsOptional() @IsNumber() dozaCumulataInternaMsv?: number;
// Supraexpuneri (Anexa 4B) — set complet (înlocuiește la salvare)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OverexposureDto)
overexposures?: OverexposureDto[];
}
@@ -0,0 +1,78 @@
import {
IsString, IsObject, MinLength, IsOptional, IsInt, IsBoolean, IsEnum,
IsArray, ValidateNested, IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { RiskExposureType } from '@prisma/client';
// Un rând dintr-un tabel factorial al Anexei 4 (NU-10-MS-2026).
export class RiskExposureDto {
@IsEnum(RiskExposureType) tip!: RiskExposureType;
@IsString() @MinLength(1) denumire!: string;
@IsOptional() @IsString() cas?: string;
@IsOptional() @IsString() einecs?: string;
@IsOptional() @IsString() clasificare?: string;
@IsOptional() @IsString() zonaAfectata?: string;
@IsOptional() @IsString() timpExpunere?: string;
@IsOptional() @IsString() vep?: string;
@IsOptional() @IsString() vlep?: string;
@IsOptional() @IsString() caracteristici?: string;
@IsOptional() @IsString() procesVerbal?: string;
}
// Câmpurile comune (create + update) ale cardului de risc / Anexa 4.
class RiskCardBaseDto {
// legacy: { chimici, fizici, biologici, ergonomici, psihosociali }
@IsOptional() @IsObject() riskFactors?: Record<string, string[]>;
// ── Antet Anexa 4 ──
@IsOptional() @IsString() filiala?: string;
@IsOptional() @IsString() adresaFiliala?: string;
@IsOptional() @IsString() telefonFiliala?: string;
@IsOptional() @IsString() caemPrimeleDouaCifre?: string;
@IsOptional() @IsString() cormSubgrupaMajora?: string;
@IsOptional() @IsString() directiaSectiaSectorul?: string;
@IsOptional() @IsString() numarulLoculuiDeMunca?: string;
@IsOptional() @IsString() caemDiviziune?: string;
@IsOptional() @IsString() clasaConditiilorDeMunca?: string;
@IsOptional() @IsInt() numarLucratoriPosibili?: number;
@IsOptional() @IsIn(['STANDARD', 'DISTANTA_DIGITAL']) tipFisa?: string;
// ── Bloc descriptiv (checkbox-uri / descrieri) ──
@IsOptional() @IsObject() evaluareDetalii?: Record<string, unknown>;
// ── Radiații ionizante (per loc de muncă) ──
@IsOptional() @IsBoolean() radiatiiIonizante?: boolean;
@IsOptional() @IsString() radiatiiGrupa?: string;
@IsOptional() @IsString() radiatiiAparatura?: string;
@IsOptional() @IsString() radiatiiSurse?: string;
@IsOptional() @IsString() radiatiiTipExpunere?: string;
@IsOptional() @IsString() radiatiiMasuriProtectie?: string;
// ── Subsol ──
@IsOptional() @IsString() mijloaceProtectieColectiva?: string;
@IsOptional() @IsString() mijloaceProtectieIndividuala?: string;
@IsOptional() @IsString() echipamentLucru?: string;
@IsOptional() @IsString() observatii?: string;
@IsOptional() @IsObject() anexeIgienicoSanitare?: Record<string, unknown>;
// ── Tabele factoriale ──
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RiskExposureDto)
exposures?: RiskExposureDto[];
}
export class CreateRiskCardDto extends RiskCardBaseDto {
@IsString()
@MinLength(2)
name!: string;
}
export class UpdateRiskCardDto extends RiskCardBaseDto {
@IsOptional()
@IsString()
@MinLength(2)
name?: string;
}
@@ -0,0 +1,155 @@
import {
Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe,
UseGuards, Request, HttpCode, HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { RiskCardsService } from './services/risk-cards.service';
import { MedicalProfileService } from './services/medical-profile.service';
import { CheckupService } from './services/checkup.service';
import { BulkService } from './services/bulk.service';
import { StorageService } from './services/storage.service';
import { CreateRiskCardDto, UpdateRiskCardDto } from './dto/risk-card.dto';
import { UpsertMedicalProfileDto } from './dto/medical-profile.dto';
import { CreateCheckupDto, CompleteCheckupDto, BulkInitiateDto } from './dto/checkup.dto';
interface AuthReq extends Request { user: { id: string; role: string } }
@Controller('medical')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class MedicalController {
constructor(
private readonly riskCards: RiskCardsService,
private readonly profiles: MedicalProfileService,
private readonly checkups: CheckupService,
private readonly bulk: BulkService,
private readonly storage: StorageService,
) {}
// ─── Risk Cards ─────────────────────────────────────────────────
@Get('risk-cards')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie')
listRiskCards() { return this.riskCards.findAll(); }
@Get('risk-cards/:id')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie')
getRiskCard(@Param('id', ParseUUIDPipe) id: string) { return this.riskCards.findOne(id); }
@Post('risk-cards')
@Roles('hr_admin')
@HttpCode(HttpStatus.CREATED)
createRiskCard(@Body() dto: CreateRiskCardDto, @Request() req: AuthReq) {
return this.riskCards.create(dto, req.user.id, req.user.role);
}
@Patch('risk-cards/:id')
@Roles('hr_admin')
updateRiskCard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRiskCardDto, @Request() req: AuthReq) {
return this.riskCards.update(id, dto, req.user.id, req.user.role);
}
@Delete('risk-cards/:id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
deleteRiskCard(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.riskCards.remove(id, req.user.id, req.user.role);
}
// ─── Employee Medical Profile ───────────────────────────────────
@Get('profiles/:employeeId')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee')
getProfile(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.profiles.findOne(employeeId);
}
@Post('profiles/:employeeId')
@Roles('hr_admin', 'hr_specialist')
upsertProfile(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: UpsertMedicalProfileDto, @Request() req: AuthReq) {
return this.profiles.upsert(employeeId, dto, req.user.id, req.user.role);
}
// ─── Medical Checkups ──────────────────────────────────────────
@Get('checkups/employee/:employeeId')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee')
listByEmployee(@Param('employeeId', ParseUUIDPipe) employeeId: string) {
return this.checkups.findByEmployee(employeeId);
}
@Get('checkups/:id')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie')
getCheckup(@Param('id', ParseUUIDPipe) id: string) { return this.checkups.findOne(id); }
@Post('checkups/employee/:employeeId')
@Roles('hr_admin', 'hr_specialist')
@HttpCode(HttpStatus.CREATED)
createCheckup(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateCheckupDto, @Request() req: AuthReq) {
return this.checkups.create(employeeId, dto, req.user.id, req.user.role);
}
// medic_familie completes the checkup with verdict
@Patch('checkups/:id/complete')
@Roles('medic_familie')
completeCheckup(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CompleteCheckupDto, @Request() req: AuthReq) {
return this.checkups.complete(id, dto, req.user.id, req.user.role);
}
// medic_familie inbox
@Get('checkups/inbox/pending')
@Roles('medic_familie', 'hr_admin')
pendingForMedic() { return this.checkups.findPendingForMedic(); }
// ─── Bulk Initiation ───────────────────────────────────────────
@Post('bulk/initiate')
@Roles('hr_admin')
bulkInitiate(@Body() dto: BulkInitiateDto, @Request() req: AuthReq) {
return this.bulk.initiate(dto, req.user.id, req.user.role);
}
// Dashboard: employees overdue / due in 30 days
@Get('upcoming-expirations')
@Roles('hr_admin', 'hr_specialist', 'manager')
upcoming() { return this.bulk.upcomingExpirations(); }
// ─── Document download (presigned URL) ─────────────────────────
@Get('documents/presign')
@Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee')
async presignDocument(@Query('key') key: string) {
return { url: await this.storage.presignedUrl(key) };
}
// ─── Delete a single document from a checkup ───────────────────
@Delete('checkups/:id')
@Roles('hr_admin')
@HttpCode(HttpStatus.NO_CONTENT)
removeCheckup(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) {
return this.checkups.remove(id, req.user.id, req.user.role);
}
@Delete('checkups/:id/documents')
@Roles('hr_admin', 'hr_specialist')
@HttpCode(HttpStatus.NO_CONTENT)
deleteDocument(
@Param('id', ParseUUIDPipe) id: string,
@Query('name') name: string,
@Request() req: AuthReq,
) {
return this.checkups.deleteDocument(id, name, req.user.id, req.user.role);
}
@Delete('checkups/:id/documents/all')
@Roles('hr_admin', 'hr_specialist')
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: AuthReq,
) {
return this.checkups.deleteAllDocuments(id, req.user.id, req.user.role);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { MedicalController } from './medical.controller';
import { RiskCardsService } from './services/risk-cards.service';
import { MedicalProfileService } from './services/medical-profile.service';
import { CheckupService } from './services/checkup.service';
import { BulkService } from './services/bulk.service';
import { DocumentGeneratorService } from './services/document-generator.service';
import { DocxTemplateService } from './services/docx-template.service';
import { StorageService } from './services/storage.service';
@Module({
controllers: [MedicalController],
providers: [
RiskCardsService,
MedicalProfileService,
CheckupService,
BulkService,
DocumentGeneratorService,
DocxTemplateService,
StorageService,
],
})
export class MedicalModule {}

Some files were not shown because too many files have changed in this diff Show More