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
+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();
});