Node.js
Sincronizare angajați cu API-ul ssm.ro — Ghid Node.js
Acest ghid arată cum să sincronizezi angajați din platforma HR internă cu API-ul ssm.ro, atât în mod batch (rulare zilnică), cât și în timp real (un singur angajat creat/modificat).
Logica de sincronizare:
- Verifică dacă marca este în lista de excluderi → ignoră
- Verifică dacă angajatul există în ssm.ro pe baza
marca - Dacă există → actualizează
- Dacă nu există și statusul nu este
rez(reziliat) → creează - Dacă nu există și statusul este
rez→ ignoră (nu creăm contracte reziliate)
Configurare
const API_BASE = 'https://www.appssm.ro/api/v1';
const TOKEN = process.env.SSM_API_TOKEN; // tokenul Bearer primit de la ssm.ro
const ORGANIZATIE = process.env.SSM_ORGANIZATIE; // subdomeniul organizației (ex: "demo-organization")Setează variabilele de mediu înainte de a rula scriptul:
SSM_API_TOKEN=tokenul_tau SSM_ORGANIZATIE=organizatia_ta node sync.jsLista de excluderi (ignore list)
Unii angajați pot fi excluși din sincronizare pe baza regulilor interne HR — de exemplu administratori tehnici, conturi de test, angajați gestionați manual, sau persoane cu regim special de confidențialitate.
/**
* Set de mărci excluse explicit din sincronizare.
*
* Motivele tipice pentru excludere:
* - angajat gestionat manual direct în ssm.ro (fără sursă HR)
* - cont tehnic sau de test care nu reprezintă o persoană reală
* - persoană cu regim special care nu trebuie sincronizată automat
* - angajat cu date HR incorecte, în așteptarea unei corecturi manuale
*
* Adaugă sau elimină mărci din acest set ori de câte ori regulile HR se schimbă.
*/
const IGNORED_MARCA = new Set([
// 'M00001', // exemplu: cont tehnic
// 'M00999', // exemplu: angajat gestionat manual
]);
/**
* Returnează true dacă marca este în lista de excluderi.
*/
function isIgnored(marca) {
return IGNORED_MARCA.has(String(marca));
}Mapare statusuri
Platforma HR poate folosi denumiri interne diferite față de valorile acceptate de API. Funcția de mai jos face conversia.
Valori acceptate de API: activ, suspendat, rez
/**
* Convertește statusul intern HR → statusul acceptat de API.
* activ concediu / activ detasat sunt tratate ca suspendat (contract activ, dar temporar indisponibil).
* reziliat / rez → rez (contract încetat).
*/
function mapStatus(hrStatus) {
const s = hrStatus?.toLowerCase().trim() ?? '';
if (s === 'activ') return 'activ';
if (['activ concediu', 'activ detasat', 'suspendat'].includes(s)) return 'suspendat';
if (['rez', 'reziliat'].includes(s)) return 'rez';
// fallback — status necunoscut tratat ca suspendat
console.warn(`Status necunoscut: "${hrStatus}", tratat ca suspendat`);
return 'suspendat';
}Mapare post (grup de risc) din funcția COR
În platformele HR, câmpul post nu este de obicei disponibil — acesta reprezintă grupul de risc al funcției, folosit în ssm.ro pentru a determina categoria de instruire SSM aplicabilă angajatului (ex: lucru la înălțime, risc electric, birou). Fiecare funcție COR (cor) aparține unui grup de risc; majoritatea funcțiilor partajează același grup implicit.
/**
* Post = grup de risc al funcției, utilizat pentru instruiri SSM.
* Determină categoria de training aplicată angajatului în platformă
* (ex: instruire generală, risc electric, lucru la înălțime etc.).
*
* Majoritatea funcțiilor COR folosesc postul implicit (DEFAULT_POST).
* Adaugă excepții doar pentru funcțiile cu un grup de risc distinct.
*/
const DEFAULT_POST = 'Lucratori';
const COR_TO_POST = {
// 'cor (lowercase)' → 'post / grup de risc'
//
// Posturile sunt gestionate în platformă de inspectorii SSM și pot fi create:
// - manual, de către inspector, prin interfața platformei
// - automat prin API, dacă postul trimis nu există încă — opțiunea de creare automată trebuie activată de super utilizator în setările organizației din platformă
// Funcțiile COR (cor) sunt alocate posturilor:
// - manual de inspector, prin asociere în platformă
// - automat prin API, la primul import al funcției — dacă postul nu există, funcția este plasată
// temporar sub grupul intern "_FUNCTII_NEALOCATE_" până la alocarea manuală
// Recomandare: aliniază valorile de post de mai jos cu cele existente în platformă
// pentru a evita crearea de duplicate sau grupuri neașteptate.
//
'medic medicina muncii': 'Medici medicina muncii',
'inspector protectia muncii': 'Lucratori desemnati SSM',
'electrician': 'Lucratori cu risc electric',
'sudor': 'Lucratori cu risc chimic si termic',
'operator macara': 'Lucratori la inaltime',
'administrator retele de calcul': 'Lucratori birou',
// completează cu funcțiile specifice organizației tale
};
/**
* Returnează postul (grupul de risc SSM) corespunzător funcției COR.
* Dacă funcția nu are o mapare explicită, se folosește DEFAULT_POST.
*/
function getPost(cor) {
if (!cor) return DEFAULT_POST;
return COR_TO_POST[cor.toLowerCase().trim()] ?? DEFAULT_POST;
}Mapare câmpuri angajat
Adaptează această funcție la structura obiectului din platforma ta HR.
/**
* Convertește obiectul HR intern → payload-ul așteptat de API.
*
* Exemplu câmpuri HR interne (stânga) → câmpuri API (dreapta):
* employee.id → marca
* employee.lastName → nume
* employee.firstName → prenume
* employee.dept → departament
* employee.deptCode → codDepartament
* employee.jobTitle → cor (titlul funcției COR din HR)
* employee.managerId → marcaSuperior
* employee.manager2Id → marcaSuperior2
* employee.substituteId→ marcaInlocuitor
* employee.birthDate → dataNasterii (format: YYYY-MM-DD)
* employee.status → status (trecut prin mapStatus)
*
* post (grup de risc SSM) este derivat din cor prin getPost(),
* deoarece HR-ul nu îl expune de obicei ca un câmp separat.
*/
function mapEmployee(employee) {
return {
organizatie: ORGANIZATIE,
marca: String(employee.id),
nume: employee.lastName,
prenume: employee.firstName,
email: employee.email ?? null,
// departament este creat automat în platformă dacă nu există deja.
// Notă: crearea automată a departamentelor trebuie activată de super utilizator în setările organizației din platformă.
// Dacă organizația are departamente cu denumiri identice (ex: "Producție" în mai multe locații),
// trimite codDepartament (codul extern unic din HR) pentru a identifica exact departamentul corect
// și a evita alocarea greșită a angajatului.
departament: employee.dept ?? null,
codDepartament: employee.deptCode ?? null,
cor: employee.jobTitle ?? null, // titlul funcției COR, nu codul numeric
// post = grup de risc SSM, derivat din funcția COR; nu vine din HR
post: getPost(employee.jobTitle),
marcaSuperior: employee.managerId ? String(employee.managerId) : null,
marcaSuperior2: employee.manager2Id ? String(employee.manager2Id) : null,
marcaInlocuitor: employee.substituteId ? String(employee.substituteId) : null,
adresa: employee.address ?? null,
localitate: employee.city ?? null,
judet: employee.county ?? null,
dataNasterii: employee.birthDate ?? null, // format: YYYY-MM-DD
locatieFizica: employee.officeLocation ?? null,
status: mapStatus(employee.status),
echipaPSI: employee.psiTeam ? 'Da' : 'Nu',
};
}Funcții API
const headers = () => ({
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
});
/**
* Returnează datele contactului sau null dacă nu există.
*/
async function getContact(marca) {
const res = await fetch(
`${API_BASE}/contacts/${encodeURIComponent(marca)}?organizatie=${ORGANIZATIE}`,
{ headers: headers() }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`GET /contacts/${marca} → ${res.status}`);
return res.json();
}
/**
* Creează un contact nou. Aruncă eroare dacă marca există deja.
*/
async function createContact(payload) {
const res = await fetch(`${API_BASE}/contacts?organizatie=${ORGANIZATIE}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`POST /contacts → ${res.status}: ${body.error ?? 'eroare necunoscuta'}`);
}
return res.json();
}
/**
* Actualizează un contact existent identificat prin marca.
* Returnează null dacă nu au fost detectate diferențe (204 No Content).
*/
async function updateContact(payload) {
const res = await fetch(`${API_BASE}/contacts/update?organizatie=${ORGANIZATIE}`, {
method: 'PATCH',
headers: headers(),
body: JSON.stringify(payload),
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`PATCH /contacts/update → ${res.status}: ${body.error ?? 'eroare necunoscuta'}`);
}
return res.json();
}Sincronizare un singur angajat
Folosit pentru evenimente în timp real (angajat creat sau modificat în HR).
async function syncEmployee(hrEmployee) {
const payload = mapEmployee(hrEmployee);
const marca = payload.marca;
// Angajatul este exclus explicit din sincronizare prin reguli HR interne.
if (isIgnored(marca)) {
console.log(`[${marca}] Ignorat — exclus din sincronizare (IGNORED_MARCA)`);
return { marca, action: 'skipped', reason: 'ignored' };
}
const existing = await getContact(marca);
if (existing) {
const result = await updateContact(payload);
if (result === null) {
console.log(`[${marca}] Fara modificari (204)`);
return { marca, action: 'no_change' };
}
console.log(`[${marca}] Actualizat`);
return { marca, action: 'updated' };
} else {
if (payload.status === 'rez') {
console.log(`[${marca}] Ignorat — contact reziliat, nu se creeaza`);
return { marca, action: 'skipped', reason: 'reziliat' };
}
await createContact(payload);
console.log(`[${marca}] Creat`);
return { marca, action: 'created' };
}
}Sincronizare batch zilnică
Primește un array de angajați nesincronizați și îi procesează secvențial.
async function syncAll(hrEmployees) {
const summary = { created: 0, updated: 0, skipped: 0, no_change: 0, errors: 0 };
for (const employee of hrEmployees) {
try {
const result = await syncEmployee(employee);
summary[result.action] = (summary[result.action] ?? 0) + 1;
} catch (err) {
const marca = employee.id ?? '?';
console.error(`[${marca}] Eroare: ${err.message}`);
summary.errors++;
}
}
console.log('\n--- Sumar sincronizare ---');
console.log(`Creati: ${summary.created}`);
console.log(`Actualizati: ${summary.updated}`);
console.log(`Fara modif.: ${summary.no_change}`);
console.log(`Ignorati: ${summary.skipped}`);
console.log(`Erori: ${summary.errors}`);
// Trimite/loghează raportul de execuție către client.
// Întotdeauna dacă există erori; opțional pentru rulări fără probleme.
//
// Exemple de implementare:
// - email către echipa HR: sendReportEmail(summary, errorDetails)
// - POST către un webhook intern: await fetch(REPORT_WEBHOOK_URL, { method: 'POST', body: JSON.stringify(summary) })
// - scriere în fișier de log: appendFileSync('./sync.log', JSON.stringify({ date: new Date(), ...summary }) + '\n')
// - trimitere în Slack / Teams: await notifyChannel(summary)
//
// if (summary.errors > 0) {
// await sendReportEmail(summary); // întotdeauna la erori
// } else {
// // await sendReportEmail(summary); // decomentează dacă vrei raport și la succes
// }
return summary;
}Exemple de utilizare
Batch zilnic (fișier JSON)
// sync.js
import { readFileSync } from 'fs';
// employees.json — lista exportată din HR pentru ziua curentă
const employees = JSON.parse(readFileSync('./employees.json', 'utf8'));
syncAll(employees).then((summary) => {
if (summary.errors > 0) process.exit(1);
});Rulare:
SSM_API_TOKEN=abc123 SSM_ORGANIZATIE=demo-organization node sync.jsEveniment în timp real (un singur angajat)
// Apelat dintr-un webhook, queue consumer sau orice sistem de evenimente HR
async function onEmployeeChanged(hrEmployee) {
try {
const result = await syncEmployee(hrEmployee);
console.log('Sync result:', result);
} catch (err) {
console.error('Sync failed:', err.message);
// re-aruncă pentru a permite retry-ul din sistemul de mesagerie
throw err;
}
}
// Exemplu de apel direct
onEmployeeChanged({
id: 'M00212',
lastName: 'Popescu',
firstName: 'Ion',
email: 'ion.popescu@firma.ro',
dept: 'Resurse Umane',
deptCode: 'RU-01',
jobTitle: 'Specialist resurse umane',
managerId: 'M00100',
birthDate: '1985-06-15',
status: 'activ',
psiTeam: false,
});Coduri de răspuns API
| Cod | Semnificație |
|---|---|
| 200 | Succes — returnează datele contactului |
| 204 | Succes — nicio modificare detectată (doar la PATCH) |
| 400 | Organizatie invalidă sau lipsă |
| 401 | Token lipsă sau invalid |
| 404 | Contactul nu a fost găsit (doar la GET) |
| 422 | Date invalide — răspunsul include { "error": "..." } cu detalii |
| 500 | Eroare internă server |