[FEAT] Epic C / C4 — inventario a 2 voci + sottoclassi configurabili (mig.050)
Simon C4: nell'inventario DUE sole voci principali; le sottoclassi le crea l'utente.
Riconduzione concordata (no perdita di dettaglio): hardware/network/facility -> ID.AM-01;
software/service/data -> ID.AM-02; personnel -> Organigramma (0 asset personnel presenti).
- Config: cfg_inventory_voci (2: ID.AM-01 Hardware, ID.AM-02 Software/Servizi/Sistemi),
inventory_subclassi (12 default di sistema org NULL + sottoclassi org-scoped).
- assets.voce_code + subclass_id (FK). 20 asset esistenti MIGRATI (5 ID.AM-01 / 15 ID.AM-02).
- AssetController: list con label voce/sottoclasse; create/update validati (voce in whitelist,
sottoclasse appartenente a org/default+voce); GET/POST /assets/subclasses; import (bulkUpsert)
deriva la voce dal tipo legacy. asset_type resta come campo legacy (viste secondarie).
- assets.html: filtro per voce, form con selettore Voce + Sottoclasse (+ '+ aggiungi'),
lista/dettaglio mostrano 'voce · sottoclasse', stat-chips per voce.
- FIX bug pre-esistente: apostrofo non escapato ('l'inventario') che ROMPEVA tutto il JS
della pagina Inventario. + cache-buster common-bi.js (era a v=20260618, non bumpato dal fix C3).
Help + KB + i18n. Verifiche prod: subclasses 200, create voce/legacy OK, validazione 422,
JS valido (node --check), migrazione idempotente. version 1.18.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e281aa4970
commit
30f4614b37
@@ -37,9 +37,12 @@ class AssetController extends BaseController
|
||||
|
||||
$total = Database::count('assets', $where, $params);
|
||||
$assets = Database::fetchAll(
|
||||
"SELECT a.*, u.full_name as owner_name
|
||||
"SELECT a.*, u.full_name as owner_name,
|
||||
sc.label AS subclass_label, v.label AS voce_label
|
||||
FROM assets a
|
||||
LEFT JOIN users u ON u.id = a.owner_user_id
|
||||
LEFT JOIN inventory_subclassi sc ON sc.id = a.subclass_id
|
||||
LEFT JOIN cfg_inventory_voci v ON v.voce_code = a.voce_code
|
||||
WHERE a.{$where}
|
||||
ORDER BY a.criticality DESC, a.name
|
||||
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
|
||||
@@ -52,12 +55,28 @@ class AssetController extends BaseController
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$this->validateRequired(['name', 'asset_type']);
|
||||
$this->validateRequired(['name']);
|
||||
|
||||
$voce = $this->getParam('voce_code');
|
||||
if (!$voce) {
|
||||
// retro-compatibilità: deriva la voce dal vecchio asset_type (UI non ancora aggiornata / import)
|
||||
$at = (string) $this->getParam('asset_type');
|
||||
if (in_array($at, ['software', 'service', 'data'], true)) { $voce = 'ID.AM-02'; }
|
||||
elseif (in_array($at, ['hardware', 'network', 'facility'], true)) { $voce = 'ID.AM-01'; }
|
||||
}
|
||||
if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) {
|
||||
$this->jsonError('Voce inventario obbligatoria (ID.AM-01 o ID.AM-02)', 422, 'INVALID_VOCE');
|
||||
}
|
||||
$subclassId = $this->validateSubclass($this->getParam('subclass_id'), $voce);
|
||||
// asset_type resta come campo legacy (viste secondarie): usa quello passato o deriva dalla voce
|
||||
$assetType = $this->getParam('asset_type') ?: ($voce === 'ID.AM-01' ? 'hardware' : 'service');
|
||||
|
||||
$assetId = Database::insert('assets', [
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'name' => trim($this->getParam('name')),
|
||||
'asset_type' => $this->getParam('asset_type'),
|
||||
'asset_type' => $assetType,
|
||||
'voce_code' => $voce,
|
||||
'subclass_id' => $subclassId,
|
||||
'category' => $this->getParam('category'),
|
||||
'description' => $this->getParam('description'),
|
||||
'criticality' => $this->getParam('criticality', 'medium'),
|
||||
@@ -119,6 +138,8 @@ class AssetController extends BaseController
|
||||
|
||||
$type = strtolower((string) ($a['asset_type'] ?? 'service'));
|
||||
if (!in_array($type, $validType, true)) $type = 'service';
|
||||
// C4: voce (2 valori) derivata dal tipo legacy per gli asset importati
|
||||
$voce = in_array($type, ['hardware', 'network', 'facility'], true) ? 'ID.AM-01' : 'ID.AM-02';
|
||||
|
||||
// Scoring automatico GV.OC-04 da euristica sui campi CMDB
|
||||
$criteria = AssetScoringService::inferCriteria($a);
|
||||
@@ -130,6 +151,7 @@ class AssetController extends BaseController
|
||||
'organization_id' => $orgId,
|
||||
'name' => $name,
|
||||
'asset_type' => $type,
|
||||
'voce_code' => $voce,
|
||||
'category' => $a['category'] ?? null,
|
||||
'description' => $a['description'] ?? null,
|
||||
'criticality' => $sc['criticality'],
|
||||
@@ -216,6 +238,21 @@ class AssetController extends BaseController
|
||||
$updates['dependencies'] = json_encode($this->getParam('dependencies'));
|
||||
}
|
||||
|
||||
// Voce/sottoclasse (Epic C / C4) — con validazione (no loop generico)
|
||||
if ($this->hasParam('voce_code')) {
|
||||
$voce = $this->getParam('voce_code');
|
||||
if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) {
|
||||
$this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE');
|
||||
}
|
||||
$updates['voce_code'] = $voce;
|
||||
if ($this->hasParam('subclass_id')) {
|
||||
$updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $voce);
|
||||
}
|
||||
} elseif ($this->hasParam('subclass_id')) {
|
||||
$cur = Database::fetchOne('SELECT voce_code FROM assets WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
$updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $cur['voce_code'] ?? '');
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::update('assets', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
$this->logAudit('asset_updated', 'asset', $id, $updates);
|
||||
@@ -224,6 +261,85 @@ class AssetController extends BaseController
|
||||
$this->jsonSuccess($updates, 'Asset aggiornato');
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida una sottoclasse per la voce indicata: deve esistere ed essere o di
|
||||
* sistema (organization_id NULL) o dell'org corrente, e appartenere alla voce.
|
||||
* Ritorna l'id (int) oppure null se non fornita.
|
||||
*/
|
||||
private function validateSubclass($id, string $voce): ?int
|
||||
{
|
||||
$id = (int) $id;
|
||||
if ($id <= 0) { return null; }
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id FROM inventory_subclassi
|
||||
WHERE id = ? AND voce_code = ? AND (organization_id = ? OR organization_id IS NULL)',
|
||||
[$id, $voce, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$row) { $this->jsonError('Sottoclasse non valida per la voce selezionata', 422, 'INVALID_SUBCLASS'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/assets/subclasses — le 2 voci canoniche + le sottoclassi visibili
|
||||
* (default di sistema + quelle dell'org), per i selettori dell'inventario.
|
||||
*/
|
||||
public function subclasses(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$voci = Database::fetchAll('SELECT voce_code, label, descr, ord FROM cfg_inventory_voci ORDER BY ord');
|
||||
$subs = Database::fetchAll(
|
||||
'SELECT id, voce_code, label, (organization_id IS NULL) AS is_default, ord
|
||||
FROM inventory_subclassi
|
||||
WHERE organization_id IS NULL OR organization_id = ?
|
||||
ORDER BY voce_code, ord, label',
|
||||
[$orgId]
|
||||
);
|
||||
$byVoce = [];
|
||||
foreach ($subs as $s) {
|
||||
$byVoce[$s['voce_code']][] = [
|
||||
'id' => (int) $s['id'], 'label' => $s['label'], 'is_default' => ((int) $s['is_default'] === 1),
|
||||
];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($voci as $v) {
|
||||
$out[] = [
|
||||
'voce_code' => $v['voce_code'], 'label' => $v['label'], 'descr' => $v['descr'],
|
||||
'subclassi' => $byVoce[$v['voce_code']] ?? [],
|
||||
];
|
||||
}
|
||||
$this->jsonSuccess(['voci' => $out]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/assets/subclasses { voce_code*, label* }
|
||||
* Aggiunge una sottoclasse ORGANIZZATIVA (org-scoped). Le due voci principali
|
||||
* NON sono modificabili dall'utente (restano due e solo due).
|
||||
*/
|
||||
public function addSubclass(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$body = $this->getJsonBody();
|
||||
$voce = $body['voce_code'] ?? '';
|
||||
$label = trim((string) ($body['label'] ?? ''));
|
||||
if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) {
|
||||
$this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE');
|
||||
}
|
||||
if ($label === '' || mb_strlen($label) > 120) {
|
||||
$this->jsonError('Etichetta sottoclasse obbligatoria (max 120 caratteri)', 422, 'INVALID_LABEL');
|
||||
}
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$dup = Database::fetchOne(
|
||||
'SELECT id FROM inventory_subclassi WHERE voce_code = ? AND label = ? AND (organization_id = ? OR organization_id IS NULL)',
|
||||
[$voce, $label, $orgId]
|
||||
);
|
||||
if ($dup) { $this->jsonError('Sottoclasse già esistente', 409, 'DUPLICATE'); }
|
||||
$id = Database::insert('inventory_subclassi', [
|
||||
'organization_id' => $orgId, 'voce_code' => $voce, 'label' => $label, 'ord' => 99,
|
||||
]);
|
||||
$this->jsonSuccess(['id' => $id, 'voce_code' => $voce, 'label' => $label], 'Sottoclasse aggiunta', 201);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin']);
|
||||
|
||||
Reference in New Issue
Block a user