chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)

- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

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

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