diff --git a/application/controllers/AssetController.php b/application/controllers/AssetController.php
index 08b1350..0c8ddd9 100644
--- a/application/controllers/AssetController.php
+++ b/application/controllers/AssetController.php
@@ -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']);
diff --git a/docs/sql/050_inventory_two_voci.sql b/docs/sql/050_inventory_two_voci.sql
new file mode 100644
index 0000000..ce8c7a1
--- /dev/null
+++ b/docs/sql/050_inventory_two_voci.sql
@@ -0,0 +1,40 @@
+-- =====================================================================
+-- 050 — Inventario a DUE voci + sottoclassi configurabili (Epic C / C4)
+-- =====================================================================
+-- Simon C4: nell'inventario DUE sole voci principali (ID.AM-01 Hardware,
+-- ID.AM-02 Software/Servizi/Sistemi); il dettaglio diventa SOTTOCLASSE
+-- (config: default di sistema + create dall'utente). Riconduzione concordata:
+-- hardware/network/facility -> ID.AM-01 ; software/service/data -> ID.AM-02 ;
+-- personnel -> Organigramma (nessun asset personnel presente).
+--
+-- Additivo: aggiunge cfg_inventory_voci, inventory_subclassi e
+-- assets.voce_code/subclass_id; NON tocca asset_type (resta come legacy per le
+-- viste secondarie). Migrazione dati inclusa (vedi runner). Reversibile.
+-- Runner-safe: solo CREATE/ALTER bare (no DELIMITER).
+-- =====================================================================
+
+CREATE TABLE IF NOT EXISTS cfg_inventory_voci (
+ voce_code VARCHAR(16) NOT NULL,
+ label VARCHAR(120) NOT NULL,
+ descr TEXT NULL,
+ ord INT NOT NULL,
+ PRIMARY KEY (voce_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS inventory_subclassi (
+ id INT NOT NULL AUTO_INCREMENT,
+ organization_id INT NULL, -- NULL = default di sistema; valorizzato = sottoclasse dell'org
+ voce_code VARCHAR(16) NOT NULL,
+ label VARCHAR(120) NOT NULL,
+ ord INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_subclass (organization_id, voce_code, label),
+ KEY idx_subclass_org (organization_id),
+ KEY idx_subclass_voce (voce_code),
+ CONSTRAINT fk_subclass_voce FOREIGN KEY (voce_code) REFERENCES cfg_inventory_voci (voce_code),
+ CONSTRAINT fk_subclass_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE assets ADD COLUMN voce_code VARCHAR(16) NULL DEFAULT NULL;
+ALTER TABLE assets ADD COLUMN subclass_id INT NULL DEFAULT NULL;
+ALTER TABLE assets ADD CONSTRAINT fk_assets_subclass FOREIGN KEY (subclass_id) REFERENCES inventory_subclassi (id) ON DELETE SET NULL;
diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html
index 3eaeaa8..204e9f4 100644
--- a/public/_app-bi-demo.html
+++ b/public/_app-bi-demo.html
@@ -70,9 +70,9 @@
-
-
-
+
+
+
-
-
-
+
+
+
+
-
+
+
@@ -165,9 +165,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
@@ -377,15 +372,16 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+