Simon C3: l'analisi gap ACN non è un artefatto normativo e va eliminata; la conformità emerge da C1 (elenco Misure/Requisiti + valutazione per requisito in org_requisito_state). Ricognizione → migrazione → backup → rimozione (sicuro, 0 perdite): - Tabelle acn_assessments/acn_assessment_responses VUOTE (0 righe) -> droppate (mig.049, guard anti-drop se >0 righe). Ricreabili da 036. Nessun dato utente perso. - Integrazione lg231: /api/services/gap-analysis legge 'assessments' generale, NON il modulo ACN -> intatta. Verificato. - RaciController ripuntato su cfg_nis2_misure (fonte canonica) al posto di acn_measures.json (rimosso); validazione measure_code verificata (/raci/objects?type=measure -> 43 misure). Rimossi: AcnAssessmentController, public/acn-gap.html, application/data/acn_measures.json, routing acn-gap, voci sidebar (common.js + common-bi.js), metodi api.js acn*, sezione help 'acn', chiavi i18n acn.*, mapping demo-selectors. Tour demo step 3 RIPUNTATO su misure-requisiti (no step rotto, 10 step). Sidebar BI ora mostra 'Misure e Requisiti' (gap di common-bi.js sanato). Verifiche prod: /acn-gap.html 404, /api/acn-gap/* 404, /misure-requisiti 200, RaciController OK. Cache-buster ?v=20260624, version 1.18.3, SW v1.18.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
502 lines
22 KiB
PHP
502 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Matrice RACI + link m2m (A4 Fase 4.3)
|
|
* ----------------------------------------------------------------------------
|
|
* Hub relazionale che collega i RUOLI dell'organigramma (mig.041, org_roles)
|
|
* agli OGGETTI di compliance con una responsabilita RACI (R/A/C/I), e gestisce i
|
|
* collegamenti molti-a-molti fra procedure (policies), inventario (assets),
|
|
* rischi (risks) e misure ACN (catalogo JSON).
|
|
*
|
|
* Multi-tenancy ancorata a getCurrentOrgId(): OGNI query filtra organization_id.
|
|
* Scritture riservate a org_admin/compliance_manager (super_admin bypassa; il
|
|
* demo guard gestisce il read-only). Anti-IDOR: ogni id referenziato (ruolo,
|
|
* asset, policy, risk, supplier) e verificato come appartenente all'org corrente
|
|
* prima di insert (fetchRoleOrFail / assertObjectExists, org-scoped).
|
|
*
|
|
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
|
|
* - GV.RR-02 (NIST CSF 2.0): ruoli, responsabilita e poteri stabiliti/comunicati
|
|
* - ID.AM (asset management): inventario; GV.PO (governance): procedure
|
|
* - art. 24 D.Lgs. 138/2024 + ID.RA (risk assessment): rischi
|
|
*
|
|
* NOTE strutturali (verificate sul codice reale):
|
|
* - 'inventario' = `assets`; 'procedure' = `policies`; rischi = `risks`;
|
|
* fornitori = `suppliers`. Label: assets.name, policies.title, risks.title,
|
|
* suppliers.name, org_roles.role_name.
|
|
* - misure = tabella canonica cfg_nis2_misure (campo misura_code); risk_measure.measure_code
|
|
* e una stringa validata su quella tabella (Epic C / C1; ex acn_measures.json, rimosso con C3).
|
|
* - raci_assignments.object_id e POLIMORFICO -> niente FK; esistenza verificata
|
|
* in PHP. I nomi tabella/colonna provengono SEMPRE dalle const-map qui sotto
|
|
* dopo validazione enum (mai da stringhe grezze della request).
|
|
* - Le AZIONI sono capa_actions (figlie di non_conformities): 4.3 non le usa.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class RaciController extends BaseController
|
|
{
|
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
|
|
|
/**
|
|
* object_type => [tabella, colonna_label, ha_soft_delete]
|
|
* I nomi sono hard-coded: dopo il gate enum sono sicuri da interpolare.
|
|
*/
|
|
private const OBJECT_MAP = [
|
|
'inventory' => ['table' => 'assets', 'label' => 'name', 'soft_delete' => false],
|
|
'procedure' => ['table' => 'policies', 'label' => 'title', 'soft_delete' => true],
|
|
'risk' => ['table' => 'risks', 'label' => 'title', 'soft_delete' => true],
|
|
'supplier' => ['table' => 'suppliers', 'label' => 'name', 'soft_delete' => true],
|
|
];
|
|
|
|
/**
|
|
* link_type => [tabella, a_col, a_table, a_label, b_col, b_table, b_label]
|
|
* Per risk_measure il lato b e un codice ACN (stringa): b_table=null.
|
|
*/
|
|
private const LINK_MAP = [
|
|
'procedure_inventory' => [
|
|
'table' => 'procedure_inventory',
|
|
'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true,
|
|
'b_col' => 'asset_id', 'b_table' => 'assets', 'b_label' => 'name', 'b_soft' => false,
|
|
],
|
|
'procedure_risk' => [
|
|
'table' => 'procedure_risk',
|
|
'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true,
|
|
'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true,
|
|
],
|
|
'inventory_risk' => [
|
|
'table' => 'inventory_risk',
|
|
'a_col' => 'asset_id', 'a_table' => 'assets', 'a_label' => 'name', 'a_soft' => false,
|
|
'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true,
|
|
],
|
|
'risk_measure' => [
|
|
'table' => 'risk_measure',
|
|
'a_col' => 'risk_id', 'a_table' => 'risks', 'a_label' => 'title', 'a_soft' => true,
|
|
'b_col' => 'measure_code', 'b_table' => null, 'b_label' => null, 'b_soft' => false,
|
|
],
|
|
];
|
|
|
|
/** @var array<string,array>|null cache del catalogo misure ACN */
|
|
private static $measuresCache = null;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// MATRICE RACI
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* GET /api/raci/matrix
|
|
* { roles:[{role_id, role_name, is_governance_body, holder_user_id}],
|
|
* objects:[{type, id, label}], assignments:[{role_id, object_type, object_id, raci}] }
|
|
*/
|
|
public function matrix(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$roleRows = Database::fetchAll(
|
|
'SELECT id, role_name, is_governance_body, holder_user_id
|
|
FROM org_roles WHERE organization_id = ?
|
|
ORDER BY sort_order, role_name',
|
|
[$orgId]
|
|
);
|
|
$roles = array_map(fn($r) => [
|
|
'role_id' => (int) $r['id'],
|
|
'role_name' => $r['role_name'],
|
|
'is_governance_body' => (bool) $r['is_governance_body'],
|
|
'holder_user_id' => $r['holder_user_id'] !== null ? (int) $r['holder_user_id'] : null,
|
|
], $roleRows);
|
|
|
|
// Oggetti linkabili: union dei 4 tipi, org-scoped, ordinati per tipo+label.
|
|
$objects = [];
|
|
foreach (self::OBJECT_MAP as $type => $cfg) {
|
|
foreach ($this->fetchObjectsForType($type, $orgId) as $o) {
|
|
$objects[] = ['type' => $type, 'id' => $o['id'], 'label' => $o['label']];
|
|
}
|
|
}
|
|
|
|
$assignRows = Database::fetchAll(
|
|
'SELECT role_id, object_type, object_id, raci
|
|
FROM raci_assignments WHERE organization_id = ?',
|
|
[$orgId]
|
|
);
|
|
$assignments = array_map(fn($a) => [
|
|
'role_id' => (int) $a['role_id'],
|
|
'object_type' => $a['object_type'],
|
|
'object_id' => (int) $a['object_id'],
|
|
'raci' => $a['raci'],
|
|
], $assignRows);
|
|
|
|
$this->jsonSuccess([
|
|
'roles' => $roles,
|
|
'objects' => $objects,
|
|
'assignments' => $assignments,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/raci/assign — {role_id, object_type, object_id, raci}
|
|
* Upsert: una sola RACI per (role_id, object_type, object_id) nell'org.
|
|
*/
|
|
public function assign(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$this->validateRequired(['role_id', 'object_type', 'object_id', 'raci']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$roleId = (int) $this->getParam('role_id');
|
|
$type = $this->validateObjectType($this->getParam('object_type'));
|
|
$objId = (int) $this->getParam('object_id');
|
|
$raci = $this->validateRaci($this->getParam('raci'));
|
|
|
|
$this->fetchRoleOrFail($roleId);
|
|
$this->assertObjectExists($type, $objId);
|
|
|
|
$existing = Database::fetchOne(
|
|
'SELECT id FROM raci_assignments
|
|
WHERE organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
|
|
[$orgId, $roleId, $type, $objId]
|
|
);
|
|
if ($existing) {
|
|
$id = (int) $existing['id'];
|
|
Database::update('raci_assignments', ['raci' => $raci], 'id = ?', [$id]);
|
|
$created = false;
|
|
} else {
|
|
$id = Database::insert('raci_assignments', [
|
|
'organization_id' => $orgId,
|
|
'role_id' => $roleId,
|
|
'object_type' => $type,
|
|
'object_id' => $objId,
|
|
'raci' => $raci,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$created = true;
|
|
}
|
|
|
|
$this->logAudit('raci_assigned', 'raci_assignment', $id, [
|
|
'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId, 'raci' => $raci,
|
|
]);
|
|
$this->jsonSuccess(['id' => $id, 'raci' => $raci], $created ? 'RACI assegnata' : 'RACI aggiornata', $created ? 201 : 200);
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/raci/assign?role_id=&object_type=&object_id=
|
|
* (parametri via query string: il DELETE di api.js non porta body.)
|
|
*/
|
|
public function unassign(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$this->validateRequired(['role_id', 'object_type', 'object_id']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$roleId = (int) $this->getParam('role_id');
|
|
$type = $this->validateObjectType($this->getParam('object_type'));
|
|
$objId = (int) $this->getParam('object_id');
|
|
|
|
$count = Database::count(
|
|
'raci_assignments',
|
|
'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
|
|
[$orgId, $roleId, $type, $objId]
|
|
);
|
|
if ($count === 0) {
|
|
$this->jsonError('Assegnazione RACI non trovata', 404, 'RACI_NOT_FOUND');
|
|
}
|
|
|
|
Database::delete(
|
|
'raci_assignments',
|
|
'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
|
|
[$orgId, $roleId, $type, $objId]
|
|
);
|
|
$this->logAudit('raci_unassigned', 'raci_assignment', null, [
|
|
'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId,
|
|
]);
|
|
$this->jsonSuccess(null, 'RACI rimossa');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// OGGETTI LINKABILI (select)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* GET /api/raci/objects?type=
|
|
* type in {inventory,procedure,risk,supplier} -> [{id, label}] dell'org;
|
|
* type=measure -> misure ACN dal catalogo JSON [{code, label}].
|
|
*/
|
|
public function objects(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$type = (string) $this->getParam('type', '');
|
|
|
|
if ($type === 'measure') {
|
|
$out = [];
|
|
foreach ($this->loadMeasures() as $m) {
|
|
$code = (string) ($m['code'] ?? '');
|
|
if ($code === '') {
|
|
continue;
|
|
}
|
|
$out[] = ['code' => $code, 'label' => $code . ' — ' . ($m['title'] ?? '')];
|
|
}
|
|
$this->jsonSuccess($out);
|
|
}
|
|
|
|
$type = $this->validateObjectType($type);
|
|
$this->jsonSuccess($this->fetchObjectsForType($type, $this->getCurrentOrgId()));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// LINK MOLTI-A-MOLTI
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* GET /api/raci/links?link_type=&id=
|
|
* $id = id del lato "a" (policy/asset/risk). Ritorna i lati "b" collegati con label.
|
|
*/
|
|
public function links(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$linkType = $this->validateLinkType($this->getParam('link_type'));
|
|
$aId = (int) $this->getParam('id');
|
|
$orgId = $this->getCurrentOrgId();
|
|
$cfg = self::LINK_MAP[$linkType];
|
|
|
|
if ($linkType === 'risk_measure') {
|
|
$rows = Database::fetchAll(
|
|
'SELECT id AS link_id, measure_code FROM risk_measure
|
|
WHERE organization_id = ? AND risk_id = ? ORDER BY measure_code',
|
|
[$orgId, $aId]
|
|
);
|
|
$out = array_map(fn($r) => [
|
|
'link_id' => (int) $r['link_id'],
|
|
'measure_code' => $r['measure_code'],
|
|
'measure_label' => $this->measureLabel($r['measure_code']),
|
|
], $rows);
|
|
$this->jsonSuccess($out);
|
|
}
|
|
|
|
// Join sul lato b per la label. Nomi tabella/colonna dalla const-map (sicuri).
|
|
$bTable = $cfg['b_table'];
|
|
$bCol = $cfg['b_col'];
|
|
$bLabel = $cfg['b_label'];
|
|
$linkTbl = $cfg['table'];
|
|
$aCol = $cfg['a_col'];
|
|
|
|
$bSoftClause = $cfg['b_soft'] ? ' AND b.deleted_at IS NULL' : '';
|
|
$rows = Database::fetchAll(
|
|
"SELECT l.id AS link_id, l.{$bCol} AS b_id, b.{$bLabel} AS b_label
|
|
FROM {$linkTbl} l
|
|
JOIN {$bTable} b ON b.id = l.{$bCol}
|
|
WHERE l.organization_id = ? AND l.{$aCol} = ?{$bSoftClause}
|
|
ORDER BY b.{$bLabel}",
|
|
[$orgId, $aId]
|
|
);
|
|
$out = array_map(fn($r) => [
|
|
'link_id' => (int) $r['link_id'],
|
|
'b_id' => (int) $r['b_id'],
|
|
'b_label' => $r['b_label'],
|
|
], $rows);
|
|
$this->jsonSuccess($out);
|
|
}
|
|
|
|
/**
|
|
* POST /api/raci/link — {link_type, a_id, b_id}
|
|
* Idempotente: se il link esiste gia ritorna 200 {existed:true}.
|
|
* Per risk_measure il lato b e il codice misura (stringa, validato sul JSON).
|
|
*/
|
|
public function link(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$this->validateRequired(['link_type', 'a_id', 'b_id']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$linkType = $this->validateLinkType($this->getParam('link_type'));
|
|
$cfg = self::LINK_MAP[$linkType];
|
|
$aId = (int) $this->getParam('a_id');
|
|
|
|
// Lato a: sempre un oggetto org-scoped.
|
|
$this->assertRowExists($cfg['a_table'], $aId, $orgId, $cfg['a_soft']);
|
|
|
|
if ($linkType === 'risk_measure') {
|
|
$code = trim((string) $this->getParam('b_id'));
|
|
$this->assertMeasureExists($code);
|
|
$existing = Database::fetchOne(
|
|
'SELECT id FROM risk_measure WHERE organization_id = ? AND risk_id = ? AND measure_code = ?',
|
|
[$orgId, $aId, $code]
|
|
);
|
|
if ($existing) {
|
|
$this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200);
|
|
}
|
|
$id = Database::insert('risk_measure', [
|
|
'organization_id' => $orgId,
|
|
'risk_id' => $aId,
|
|
'measure_code' => $code,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$this->logAudit('raci_link_created', 'risk_measure', $id, ['risk_id' => $aId, 'measure_code' => $code]);
|
|
$this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201);
|
|
}
|
|
|
|
// Lato b numerico (asset/risk), org-scoped.
|
|
$bId = (int) $this->getParam('b_id');
|
|
$this->assertRowExists($cfg['b_table'], $bId, $orgId, $cfg['b_soft']);
|
|
|
|
$existing = Database::fetchOne(
|
|
"SELECT id FROM {$cfg['table']} WHERE organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?",
|
|
[$orgId, $aId, $bId]
|
|
);
|
|
if ($existing) {
|
|
$this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200);
|
|
}
|
|
$id = Database::insert($cfg['table'], [
|
|
'organization_id' => $orgId,
|
|
$cfg['a_col'] => $aId,
|
|
$cfg['b_col'] => $bId,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$this->logAudit('raci_link_created', $cfg['table'], $id, [$cfg['a_col'] => $aId, $cfg['b_col'] => $bId]);
|
|
$this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201);
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/raci/link?link_type=&a_id=&b_id=
|
|
* (parametri via query string.) Per risk_measure b_id = codice misura.
|
|
*/
|
|
public function unlink(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$this->validateRequired(['link_type', 'a_id', 'b_id']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$linkType = $this->validateLinkType($this->getParam('link_type'));
|
|
$cfg = self::LINK_MAP[$linkType];
|
|
$aId = (int) $this->getParam('a_id');
|
|
|
|
if ($linkType === 'risk_measure') {
|
|
$code = trim((string) $this->getParam('b_id'));
|
|
$where = 'organization_id = ? AND risk_id = ? AND measure_code = ?';
|
|
$params = [$orgId, $aId, $code];
|
|
} else {
|
|
$bId = (int) $this->getParam('b_id');
|
|
$where = "organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?";
|
|
$params = [$orgId, $aId, $bId];
|
|
}
|
|
|
|
if (Database::count($cfg['table'], $where, $params) === 0) {
|
|
$this->jsonError('Collegamento non trovato', 404, 'LINK_NOT_FOUND');
|
|
}
|
|
Database::delete($cfg['table'], $where, $params);
|
|
$this->logAudit('raci_link_removed', $cfg['table'], null, ['link_type' => $linkType, 'a_id' => $aId]);
|
|
$this->jsonSuccess(null, 'Collegamento rimosso');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// PRIVATI
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/** Elenco oggetti di un tipo per l'org corrente: [{id, label}] (esclude soft-deleted). */
|
|
private function fetchObjectsForType(string $type, int $orgId): array
|
|
{
|
|
$cfg = self::OBJECT_MAP[$type];
|
|
$table = $cfg['table'];
|
|
$label = $cfg['label'];
|
|
$softClause = $cfg['soft_delete'] ? ' AND deleted_at IS NULL' : '';
|
|
$rows = Database::fetchAll(
|
|
"SELECT id, {$label} AS label FROM {$table}
|
|
WHERE organization_id = ?{$softClause} ORDER BY {$label}",
|
|
[$orgId]
|
|
);
|
|
return array_map(fn($r) => ['id' => (int) $r['id'], 'label' => $r['label']], $rows);
|
|
}
|
|
|
|
/** Ruolo dell'organigramma nell'org corrente oppure 404 (anti-IDOR). */
|
|
private function fetchRoleOrFail(int $id): array
|
|
{
|
|
$r = Database::fetchOne('SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
|
if (!$r) {
|
|
$this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND');
|
|
}
|
|
return $r;
|
|
}
|
|
|
|
/** Verifica che l'oggetto (assets/policies/risks/suppliers) esista nell'org. */
|
|
private function assertObjectExists(string $type, int $id): void
|
|
{
|
|
$cfg = self::OBJECT_MAP[$type];
|
|
$this->assertRowExists($cfg['table'], $id, $this->getCurrentOrgId(), $cfg['soft_delete']);
|
|
}
|
|
|
|
/** Verifica generica di esistenza riga org-scoped (tabella dalla const-map). */
|
|
private function assertRowExists(string $table, int $id, int $orgId, bool $soft): void
|
|
{
|
|
$where = 'id = ? AND organization_id = ?';
|
|
$params = [$id, $orgId];
|
|
if ($soft) {
|
|
$where .= ' AND deleted_at IS NULL';
|
|
}
|
|
if (Database::count($table, $where, $params) === 0) {
|
|
$this->jsonError('Oggetto non valido per questa organizzazione', 422, 'INVALID_OBJECT');
|
|
}
|
|
}
|
|
|
|
private function validateObjectType($t): string
|
|
{
|
|
$t = (string) $t;
|
|
if (!isset(self::OBJECT_MAP[$t])) {
|
|
$this->jsonError('Tipo oggetto non valido', 422, 'INVALID_OBJECT_TYPE');
|
|
}
|
|
return $t;
|
|
}
|
|
|
|
private function validateRaci($r): string
|
|
{
|
|
$r = strtoupper((string) $r);
|
|
if (!in_array($r, ['R', 'A', 'C', 'I'], true)) {
|
|
$this->jsonError('Valore RACI non valido (atteso R/A/C/I)', 422, 'INVALID_RACI');
|
|
}
|
|
return $r;
|
|
}
|
|
|
|
private function validateLinkType($lt): string
|
|
{
|
|
$lt = (string) $lt;
|
|
if (!isset(self::LINK_MAP[$lt])) {
|
|
$this->jsonError('Tipo di collegamento non valido', 422, 'INVALID_LINK_TYPE');
|
|
}
|
|
return $lt;
|
|
}
|
|
|
|
/**
|
|
* Carica e memoizza il catalogo misure dalla tabella canonica cfg_nis2_misure
|
|
* (Epic C / C1). Forma di ritorno invariata (['code','title']) per compatibilità
|
|
* con assertMeasureExists/measureLabel. Sostituisce il vecchio acn_measures.json.
|
|
*/
|
|
private function loadMeasures(): array
|
|
{
|
|
if (self::$measuresCache === null) {
|
|
$rows = Database::fetchAll('SELECT misura_code, misura_descr FROM cfg_nis2_misure ORDER BY ord ASC');
|
|
self::$measuresCache = array_map(static function ($r) {
|
|
return ['code' => $r['misura_code'], 'title' => $r['misura_descr']];
|
|
}, $rows);
|
|
}
|
|
return self::$measuresCache;
|
|
}
|
|
|
|
/** Verifica che il codice misura esista nel catalogo ACN. */
|
|
private function assertMeasureExists(string $code): void
|
|
{
|
|
foreach ($this->loadMeasures() as $m) {
|
|
if ((string) ($m['code'] ?? '') === $code) {
|
|
return;
|
|
}
|
|
}
|
|
$this->jsonError('Misura ACN non valida', 422, 'INVALID_MEASURE');
|
|
}
|
|
|
|
/** Label leggibile per un codice misura ('CODE — titolo'); fallback al solo codice. */
|
|
private function measureLabel(string $code): string
|
|
{
|
|
foreach ($this->loadMeasures() as $m) {
|
|
if ((string) ($m['code'] ?? '') === $code) {
|
|
return $code . ' — ' . ($m['title'] ?? '');
|
|
}
|
|
}
|
|
return $code;
|
|
}
|
|
}
|