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:
@@ -0,0 +1,9 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.vite
|
||||||
|
**/.turbo
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "Некорректный номер телефона"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+27
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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: '2015–2018',
|
||||||
|
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());
|
||||||
@@ -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); });
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user