[FEAT] Gestione documentale ISMS — ciclo di vita ISO 27001 cl.7.5 + export Word (v1.24.0)
Ogni documento del Modello SGSI ha un ciclo di vita completo Bozza->In revisione->Approvato->Pubblicato->Archiviato con tracciamento (revisore/approvatore/pubblicatore, date, entrata in vigore, prossimo riesame) e storico versioni (snapshot). Download Word .doc modificabile per documento.
- mig.060: stati published/archived + 10 colonne tracciamento + tabella isms_document_versions
- IsmsModelController: submit/approve/publish/reject/archive/newVersion/versions/exportWord + listDocuments arricchito
- index.php: 8 route documents/{id}/(submit|approve|publish|reject|archive|newVersion|versions|word)
- isms.js: UI badge stato + pulsanti transizione + download Word + storico (IT/EN inline); cache-buster
- help.js: sezione 'Ciclo di vita dei documenti (cl.7.5)'
- 31 documenti di Nuova Agile pubblicati v1.0 (build_nuova_agile_p6_publish)
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
94e3a4293e
commit
68694a0f38
@@ -287,8 +287,13 @@ class IsmsModelController extends BaseController
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
|
||||
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
|
||||
"SELECT d.id, d.doc_type, d.title, d.status, d.ai_generated, d.version, d.updated_at,
|
||||
d.approved_at, d.published_at, d.next_review_date, d.effective_date, d.review_note,
|
||||
ua.full_name AS approved_by_name, up.full_name AS published_by_name
|
||||
FROM isms_documents d
|
||||
LEFT JOIN users ua ON ua.id = d.approved_by
|
||||
LEFT JOIN users up ON up.id = d.published_by
|
||||
WHERE d.isms_model_id = ? ORDER BY d.id",
|
||||
[$model['id']]
|
||||
);
|
||||
$this->jsonSuccess(['documents' => $rows]);
|
||||
@@ -338,6 +343,169 @@ class IsmsModelController extends BaseController
|
||||
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
|
||||
}
|
||||
|
||||
/* ───────── Ciclo di vita documentale (ISO 27001 cl.7.5) ───────── */
|
||||
|
||||
private function requireDocument(int $id): array
|
||||
{
|
||||
$model = $this->requireModel();
|
||||
$doc = Database::fetchOne('SELECT * FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
||||
if (!$doc) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); }
|
||||
return $doc;
|
||||
}
|
||||
|
||||
private function bumpVersion(string $v, bool $major = false): string
|
||||
{
|
||||
$parts = explode('.', preg_replace('/[^0-9.]/', '', $v ?: '1.0'));
|
||||
$maj = (int) ($parts[0] ?? 1); $min = (int) ($parts[1] ?? 0);
|
||||
if ($major) { $maj++; $min = 0; } else { $min++; }
|
||||
return $maj . '.' . $min;
|
||||
}
|
||||
|
||||
private function snapshotDocument(array $doc, string $changeNote): void
|
||||
{
|
||||
Database::insert('isms_document_versions', [
|
||||
'document_id' => $doc['id'],
|
||||
'isms_model_id' => $doc['isms_model_id'],
|
||||
'organization_id' => $doc['organization_id'],
|
||||
'version' => $doc['version'] ?? '1.0',
|
||||
'status' => $doc['status'],
|
||||
'body_html' => $doc['body_html'] ?? null,
|
||||
'change_note' => $changeNote,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/submit bozza -> in revisione */
|
||||
public function submitDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'draft') { $this->jsonError('Solo una bozza può essere inviata in revisione', 409, 'BAD_STATE'); }
|
||||
Database::update('isms_documents', ['status' => 'review', 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_submitted', 'isms_document', $id, []);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'review'], 'Documento inviato in revisione');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/approve in revisione -> approvato */
|
||||
public function approveDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if (!in_array($doc['status'], ['review', 'draft'], true)) { $this->jsonError('Stato non valido per l\'approvazione', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'approved', 'approved_by' => $this->getCurrentUserId(), 'approved_at' => $now,
|
||||
'reviewed_by' => $this->getCurrentUserId(), 'reviewed_at' => $now, 'review_note' => null, 'updated_at' => $now,
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_approved', 'isms_document', $id, ['version' => $doc['version']]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Documento approvato');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/publish approvato -> pubblicato (in vigore) */
|
||||
public function publishDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'approved') { $this->jsonError('Solo un documento approvato può essere pubblicato', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s'); $today = date('Y-m-d'); $nextReview = date('Y-m-d', strtotime('+1 year'));
|
||||
$this->snapshotDocument(array_merge($doc, ['status' => 'published']), 'Pubblicazione versione ' . ($doc['version'] ?? '1.0'));
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'published', 'published_by' => $this->getCurrentUserId(), 'published_at' => $now,
|
||||
'effective_date' => $today, 'next_review_date' => $nextReview, 'updated_at' => $now,
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_published', 'isms_document', $id, ['version' => $doc['version']]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'published'], 'Documento pubblicato (in vigore)');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/reject in revisione -> bozza (con nota) */
|
||||
public function rejectDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'review') { $this->jsonError('Solo un documento in revisione può essere rimandato in bozza', 409, 'BAD_STATE'); }
|
||||
$note = trim((string) ($this->getJsonBody()['note'] ?? ''));
|
||||
Database::update('isms_documents', ['status' => 'draft', 'review_note' => ($note !== '' ? $note : 'Rimandato in bozza dal revisore'), 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_rejected', 'isms_document', $id, ['note' => $note]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'draft'], 'Documento rimandato in bozza');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/archive pubblicato -> archiviato */
|
||||
public function archiveDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'published') { $this->jsonError('Solo un documento pubblicato può essere archiviato', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Database::update('isms_documents', ['status' => 'archived', 'archived_at' => $now, 'updated_at' => $now], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_archived', 'isms_document', $id, []);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'archived'], 'Documento archiviato');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/newVersion pubblicato/archiviato -> nuova bozza */
|
||||
public function newVersionDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if (!in_array($doc['status'], ['published', 'archived', 'approved'], true)) { $this->jsonError('Nuova versione possibile solo da documento approvato/pubblicato/archiviato', 409, 'BAD_STATE'); }
|
||||
$major = !empty($this->getJsonBody()['major']);
|
||||
$newV = $this->bumpVersion($doc['version'] ?? '1.0', $major);
|
||||
$this->snapshotDocument($doc, 'Apertura nuova versione ' . $newV . ' (dalla ' . ($doc['version'] ?? '1.0') . ')');
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'draft', 'version' => $newV,
|
||||
'approved_by' => null, 'approved_at' => null, 'reviewed_by' => null, 'reviewed_at' => null,
|
||||
'published_by' => null, 'published_at' => null, 'archived_at' => null, 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s'),
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_new_version', 'isms_document', $id, ['version' => $newV]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'draft', 'version' => $newV], 'Nuova versione ' . $newV . ' in bozza');
|
||||
}
|
||||
|
||||
/** GET /api/isms/documents/{id}/versions */
|
||||
public function documentVersions(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$this->requireDocument($id);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT v.id, v.version, v.status, v.change_note, v.created_at, u.full_name AS by_name
|
||||
FROM isms_document_versions v LEFT JOIN users u ON u.id = v.created_by
|
||||
WHERE v.document_id = ? ORDER BY v.id DESC', [$id]);
|
||||
$this->jsonSuccess(['versions' => $rows]);
|
||||
}
|
||||
|
||||
/** GET /api/isms/documents/{id}/word — scarica .doc (Word, modificabile) */
|
||||
public function exportDocumentWord(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$doc = $this->requireDocument($id);
|
||||
$org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$doc['organization_id']]);
|
||||
$orgName = htmlspecialchars((string) ($org['name'] ?? ''));
|
||||
$statusLabel = [
|
||||
'draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato',
|
||||
'published' => 'Pubblicato', 'archived' => 'Archiviato',
|
||||
][$doc['status']] ?? $doc['status'];
|
||||
$meta = $orgName . ' — Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' — Stato: ' . $statusLabel;
|
||||
if (!empty($doc['approved_at'])) $meta .= ' — Approvato il ' . date('d/m/Y', strtotime($doc['approved_at']));
|
||||
if (!empty($doc['published_at'])) $meta .= ' — In vigore dal ' . date('d/m/Y', strtotime($doc['published_at']));
|
||||
if (!empty($doc['next_review_date'])) $meta .= ' — Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date']));
|
||||
|
||||
$title = htmlspecialchars((string) $doc['title']);
|
||||
$body = $doc['body_html'] ?? '';
|
||||
$html = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>"
|
||||
. "<head><meta charset='utf-8'><title>$title</title>"
|
||||
. "<style>body{font-family:Calibri,Arial,sans-serif;font-size:11pt;color:#1b1b1b;line-height:1.4;}"
|
||||
. "h1{font-size:18pt;color:#0066CC;}h2{font-size:14pt;color:#0066CC;}h3{font-size:12pt;color:#17324d;}"
|
||||
. "table{border-collapse:collapse;width:100%;}td,th{border:1px solid #999;padding:5px;font-size:10pt;}th{background:#eef4fb;}"
|
||||
. ".meta{color:#555;font-size:9pt;border-bottom:1px solid #ccc;padding-bottom:6px;margin-bottom:12px;}</style></head><body>"
|
||||
. "<h1>$title</h1><p class='meta'>$meta</p>$body</body></html>";
|
||||
|
||||
$slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) $doc['title']), '_');
|
||||
$fname = $slug . '_v' . ($doc['version'] ?? '1.0') . '.doc';
|
||||
header('Content-Type: application/msword; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $fname . '"');
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/isms/documents/ai-generate
|
||||
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).
|
||||
|
||||
Reference in New Issue
Block a user