[DB] Hardening integrità chiavi (mig.058): +4 UNIQUE +8 FK, retry-on-1062, bonifica 16 righe stale — v1.23.1

Estende l'audit mig.039 ai moduli 040-057 (non coperti).
TIER1 additivo (0 dup/0 orfani verificati su DB live):
- UNIQUE internal_audits/management_reviews (org,code), kb_uploaded_documents.qdrant_doc_uuid,
  whistleblowing_reports.anonymous_token (drop idx_token ridondante)
- retry-on-1062 in InternalAuditController/ManagementReviewController (allineati a periodic_controls)
- 6 FK: consulting_firm_id (organizations/users/kb)->consulting_firms; isms_soa.linked_control_id;
  isms_documents.linked_policy_id; management_review_decisions.capa_id (tutte SET NULL)
TIER2 bonifica (backup pre-DELETE in .backups/): -9 firm_org_assignments orfane (org 126-129,
  chiude deferred-b mig.039) -7 active_sessions scadute, +2 FK CASCADE.
FK totali 196->204. Sonda diagnostica db_integrity_probe.php. audit_logs lasciato by-design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-18 07:57:25 +02:00
co-authored by Claude Opus 4.8
parent f32b6f28f5
commit 2b6c5087ad
8 changed files with 399 additions and 10 deletions
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* db_integrity_probe.php — Sonda READ-ONLY di integrità referenziale del DB.
*
* Diagnostico: SOLO SELECT + information_schema. NON modifica nulla.
* Estende l'audit della migrazione 039 alle tabelle aggiunte dopo (040-057).
*
* Uso (dal container app, connessione TLS via config app):
* docker exec nis2-app php /var/www/nis2-agile/application/cli/db_integrity_probe.php
*
* Output: report testuale a sezioni + riepilogo finale. Exit 0 sempre (è una sonda).
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("Solo da CLI.\n"); }
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/database.php';
$pdo = Database::getInstance();
$db = $pdo->query('SELECT DATABASE()')->fetchColumn();
function q(PDO $pdo, string $sql, array $p = []): array {
$st = $pdo->prepare($sql); $st->execute($p); return $st->fetchAll(PDO::FETCH_ASSOC);
}
function h(string $t): void { echo "\n=== $t ===\n"; }
echo "SONDA INTEGRITÀ DB — schema: $db\n";
echo "Modalità: READ-ONLY (information_schema + SELECT). Nessuna scrittura.\n";
$issues = 0;
/* 1) Tabelle senza PRIMARY KEY ----------------------------------------- */
h('1) Tabelle SENZA PRIMARY KEY');
$noPk = q($pdo, "
SELECT t.TABLE_NAME
FROM information_schema.TABLES t
LEFT JOIN information_schema.TABLE_CONSTRAINTS c
ON c.TABLE_SCHEMA=t.TABLE_SCHEMA AND c.TABLE_NAME=t.TABLE_NAME AND c.CONSTRAINT_TYPE='PRIMARY KEY'
WHERE t.TABLE_SCHEMA=? AND t.TABLE_TYPE='BASE TABLE' AND c.CONSTRAINT_NAME IS NULL
ORDER BY t.TABLE_NAME", [$db]);
if (!$noPk) echo " OK: tutte le tabelle hanno PK.\n";
else { foreach ($noPk as $r) echo " [!] {$r['TABLE_NAME']}\n"; $issues += count($noPk); }
/* 2) Tabelle non-InnoDB (niente FK) ------------------------------------ */
h('2) Tabelle con engine != InnoDB (FK non supportate)');
$eng = q($pdo, "
SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES
WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE' AND ENGINE<>'InnoDB' ORDER BY TABLE_NAME", [$db]);
if (!$eng) echo " OK: tutte InnoDB.\n";
else { foreach ($eng as $r) echo " [!] {$r['TABLE_NAME']} = {$r['ENGINE']}\n"; $issues += count($eng); }
/* 3) Conteggio chiavi: PK / UNIQUE / FK per tabella -------------------- */
h('3) Tabelle senza alcun indice UNIQUE oltre alla PK (informativo)');
$rows = q($pdo, "
SELECT t.TABLE_NAME,
SUM(CASE WHEN s.NON_UNIQUE=0 AND s.INDEX_NAME<>'PRIMARY' THEN 1 ELSE 0 END) AS uniq_idx
FROM information_schema.TABLES t
LEFT JOIN information_schema.STATISTICS s
ON s.TABLE_SCHEMA=t.TABLE_SCHEMA AND s.TABLE_NAME=t.TABLE_NAME
WHERE t.TABLE_SCHEMA=? AND t.TABLE_TYPE='BASE TABLE'
GROUP BY t.TABLE_NAME HAVING uniq_idx=0 ORDER BY t.TABLE_NAME", [$db]);
echo ' ' . count($rows) . " tabelle senza UNIQUE secondario (normale per tabelle figlie/log).\n";
/* 4) Colonne 'code'/'token'/'*_hash' candidate a UNIQUE senza unico ----- */
h('4) Colonne chiave-naturale (code/token/hash/slug/uuid) SENZA UNIQUE');
$cands = q($pdo, "
SELECT c.TABLE_NAME, c.COLUMN_NAME
FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA=?
AND (c.COLUMN_NAME IN ('code','token','slug','uuid','key_hash','token_hash','qdrant_doc_uuid')
OR c.COLUMN_NAME LIKE '%_code' OR c.COLUMN_NAME LIKE '%_token' OR c.COLUMN_NAME LIKE '%_uuid')
AND NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS s
WHERE s.TABLE_SCHEMA=c.TABLE_SCHEMA AND s.TABLE_NAME=c.TABLE_NAME
AND s.COLUMN_NAME=c.COLUMN_NAME AND s.NON_UNIQUE=0)
ORDER BY c.TABLE_NAME, c.COLUMN_NAME", [$db]);
if (!$cands) echo " OK: nessuna colonna chiave-naturale priva di UNIQUE.\n";
else { foreach ($cands as $r) echo " [?] {$r['TABLE_NAME']}.{$r['COLUMN_NAME']} (valutare se è chiave naturale)\n"; }
echo " NB: molti '%_code' sono riferimenti (es. ref_code, control_code) e NON vanno resi unici.\n";
/* 5) Verifica mirata: duplicati su (organization_id, code) per i moduli a codice progressivo */
h('5) Duplicati su (organization_id, code) — pre-check sicurezza UNIQUE (mig.058)');
foreach (['internal_audits','management_reviews','periodic_controls'] as $tbl) {
$exists = q($pdo, "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?", [$db,$tbl]);
if (!$exists) { echo " - $tbl: assente\n"; continue; }
$dup = q($pdo, "SELECT organization_id, code, COUNT(*) n FROM `$tbl`
WHERE code IS NOT NULL GROUP BY organization_id, code HAVING n>1");
$uq = q($pdo, "SELECT INDEX_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND NON_UNIQUE=0 AND INDEX_NAME<>'PRIMARY'
GROUP BY INDEX_NAME", [$db,$tbl]);
$hasUq = $uq ? implode(',', array_column($uq,'INDEX_NAME')) : '—';
if ($dup) { echo " [!] $tbl: " . count($dup) . " coppie (org,code) DUPLICATE → UNIQUE da bonificare prima! UNIQUE attuali: $hasUq\n"; $issues += count($dup); }
else { echo " OK $tbl: 0 duplicati (org,code) → UNIQUE applicabile in sicurezza. UNIQUE attuali: $hasUq\n"; }
}
/* 6) Colonne *_id senza FK (candidate) -------------------------------- */
h('6) Colonne *_id SENZA FOREIGN KEY (candidate / da valutare)');
$nofk = q($pdo, "
SELECT c.TABLE_NAME, c.COLUMN_NAME
FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA=? AND (c.COLUMN_NAME LIKE '%\\_id' OR c.COLUMN_NAME='id' AND 1=0)
AND c.COLUMN_NAME NOT IN ('id')
AND NOT EXISTS (
SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k
WHERE k.TABLE_SCHEMA=c.TABLE_SCHEMA AND k.TABLE_NAME=c.TABLE_NAME
AND k.COLUMN_NAME=c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL)
ORDER BY c.TABLE_NAME, c.COLUMN_NAME", [$db]);
if (!$nofk) echo " OK: ogni colonna *_id ha una FK.\n";
else {
echo " " . count($nofk) . " colonne *_id senza FK (alcune legittime: polimorfiche object_id, sso_identity_id cross-DB, sentinel 0):\n";
foreach ($nofk as $r) echo " - {$r['TABLE_NAME']}.{$r['COLUMN_NAME']}\n";
}
/* 7) Orphan check su organization_id senza FK (integrità multi-tenant) */
h('7) Righe ORFANE su organization_id privo di FK (integrità multi-tenant)');
$orgCols = q($pdo, "
SELECT c.TABLE_NAME
FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA=? AND c.COLUMN_NAME='organization_id'
AND NOT EXISTS (
SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k
WHERE k.TABLE_SCHEMA=c.TABLE_SCHEMA AND k.TABLE_NAME=c.TABLE_NAME
AND k.COLUMN_NAME='organization_id' AND k.REFERENCED_TABLE_NAME='organizations')
ORDER BY c.TABLE_NAME", [$db]);
// Pattern "by design": FK volutamente assente (trail immutabile / sentinel). NON bloccanti.
$byDesign = ['audit_logs' => 'audit trail immutabile (mig.006), deve sopravvivere alla cancellazione org'];
if (!$orgCols) echo " OK: ogni organization_id ha FK verso organizations.\n";
else foreach ($orgCols as $r) {
$t = $r['TABLE_NAME'];
$o = q($pdo, "SELECT COUNT(*) n FROM `$t` x
WHERE x.organization_id IS NOT NULL AND x.organization_id<>0
AND NOT EXISTS (SELECT 1 FROM organizations o WHERE o.id=x.organization_id)");
$n = (int)($o[0]['n'] ?? 0);
$zero = (int)(q($pdo, "SELECT COUNT(*) n FROM `$t` WHERE organization_id=0")[0]['n'] ?? 0);
$msg = " $t: orfani=$n" . ($zero ? " (+$zero righe org_id=0 sentinel)" : '') . " — FK assente";
if (isset($byDesign[$t])) { echo " [by-design]$msg ({$byDesign[$t]})\n"; }
elseif ($n>0) { echo " [!]$msg\n"; $issues += $n; }
else echo "$msg\n";
}
/* 8) Riepilogo FK totali ---------------------------------------------- */
h('8) Riepilogo');
$fkCount = (int)(q($pdo, "SELECT COUNT(*) n FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA=? AND CONSTRAINT_TYPE='FOREIGN KEY'", [$db])[0]['n'] ?? 0);
$tblCount = (int)(q($pdo, "SELECT COUNT(*) n FROM information_schema.TABLES
WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE'", [$db])[0]['n'] ?? 0);
echo " Tabelle: $tblCount | Foreign key totali: $fkCount\n";
echo " Problemi BLOCCANTI rilevati (PK mancanti / orfani reali / duplicati su code): $issues\n";
echo ($issues===0 ? " ESITO: nessun problema bloccante.\n" : " ESITO: rivedere le voci [!] sopra.\n");
exit(0);
@@ -0,0 +1,137 @@
<?php
/**
* migrate_058_integrity_keys.php — Hardening integrità chiavi (estende mig.039
* alle tabelle aggiunte dopo: 040-057). Runner IDEMPOTENTE e GUARDATO.
*
* Verificato sul dato reale (db_integrity_probe.php/probe2.php, 2026-06-18):
* - 0 duplicati (org,code) su internal_audits/management_reviews
* - 0 valori duplicati su kb_uploaded_documents.qdrant_doc_uuid (16 righe)
* - whistleblowing_reports vuota; anonymous_token = chiave retrieval
* - 0 orfani su consulting_firm_id (organizations/users/kb), linked_control_id,
* linked_policy_id, capa_id
*
* TIER 1 (additivo, default): 4 UNIQUE + 6 FK. Nessuna modifica di dato.
* TIER 2 (--with-cleanup): bonifica righe stale (orfane) + 2 FK. CANCELLA righe.
*
* Uso:
* docker exec nis2-app php .../application/cli/migrate_058_integrity_keys.php # Tier1
* docker exec nis2-app php .../application/cli/migrate_058_integrity_keys.php --with-cleanup
* ... --dry-run # mostra solo cosa farebbe, non esegue
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/database.php';
$DRY = in_array('--dry-run', $argv, true);
$CLEANUP = in_array('--with-cleanup', $argv, true);
$pdo = Database::getInstance();
$db = $pdo->query('SELECT DATABASE()')->fetchColumn();
echo "mig.058 — schema=$db | dry-run=" . ($DRY?'SI':'no') . " | cleanup=" . ($CLEANUP?'SI':'no') . "\n\n";
function col1(PDO $p,$s,$a=[]){ $st=$p->prepare($s); $st->execute($a); return $st->fetchColumn(); }
function idxExists(PDO $p,$db,$t,$idx){ return (int)col1($p,"SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND INDEX_NAME=?",[$db,$t,$idx])>0; }
function fkExists(PDO $p,$db,$name){ return (int)col1($p,"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=? AND CONSTRAINT_TYPE='FOREIGN KEY' AND CONSTRAINT_NAME=?",[$db,$name])>0; }
function tblExists(PDO $p,$db,$t){ return (int)col1($p,"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?",[$db,$t])>0; }
$applied=0; $skipped=0; $failed=0;
function run(PDO $pdo,bool $DRY,string $label,string $sql){
global $applied,$skipped,$failed;
if($DRY){ echo " [DRY] $label\n $sql\n"; return; }
try { $pdo->exec($sql); echo " [OK ] $label\n"; $applied++; }
catch(\PDOException $e){
$c=(int)($e->errorInfo[1]??0);
// 1061 dup key, 1826/1022 dup FK, 1062 dup entry (già unico) → idempotente
if(in_array($c,[1061,1826,1022,1062],true)){ echo " [SKIP] $label (già presente, code=$c)\n"; $skipped++; }
else { echo " [FAIL] $label → ".$e->getMessage()."\n"; $failed++; }
}
}
/* ───────── TIER 1: UNIQUE su chiavi naturali (pre-check duplicati) ───────── */
echo "── TIER 1: UNIQUE ──\n";
// U1 internal_audits (organization_id, code)
if(!idxExists($pdo,$db,'internal_audits','uk_intaud_code')){
$dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM internal_audits WHERE code IS NOT NULL GROUP BY organization_id,code HAVING COUNT(*)>1) x");
if($dup>0){ echo " [FAIL] internal_audits: $dup duplicati (org,code) — bonificare prima\n"; $failed++; }
else run($pdo,$DRY,'internal_audits ADD UNIQUE uk_intaud_code(organization_id,code)',
"ALTER TABLE internal_audits ADD UNIQUE KEY uk_intaud_code (organization_id, code)");
} else { echo " [SKIP] internal_audits.uk_intaud_code già presente\n"; $skipped++; }
// U2 management_reviews (organization_id, code)
if(!idxExists($pdo,$db,'management_reviews','uk_mgr_code')){
$dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM management_reviews WHERE code IS NOT NULL GROUP BY organization_id,code HAVING COUNT(*)>1) x");
if($dup>0){ echo " [FAIL] management_reviews: $dup duplicati (org,code)\n"; $failed++; }
else run($pdo,$DRY,'management_reviews ADD UNIQUE uk_mgr_code(organization_id,code)',
"ALTER TABLE management_reviews ADD UNIQUE KEY uk_mgr_code (organization_id, code)");
} else { echo " [SKIP] management_reviews.uk_mgr_code già presente\n"; $skipped++; }
// U3 kb_uploaded_documents.qdrant_doc_uuid
if(!idxExists($pdo,$db,'kb_uploaded_documents','uk_kbdoc_qdrant')){
$dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM kb_uploaded_documents WHERE qdrant_doc_uuid IS NOT NULL AND qdrant_doc_uuid<>'' GROUP BY qdrant_doc_uuid HAVING COUNT(*)>1) x");
if($dup>0){ echo " [FAIL] kb_uploaded_documents: $dup uuid duplicati\n"; $failed++; }
else run($pdo,$DRY,'kb_uploaded_documents ADD UNIQUE uk_kbdoc_qdrant(qdrant_doc_uuid)',
"ALTER TABLE kb_uploaded_documents ADD UNIQUE KEY uk_kbdoc_qdrant (qdrant_doc_uuid)");
} else { echo " [SKIP] kb_uploaded_documents.uk_kbdoc_qdrant già presente\n"; $skipped++; }
// U4 whistleblowing_reports.anonymous_token (DROP idx_token ridondante + UNIQUE)
if(!idxExists($pdo,$db,'whistleblowing_reports','uq_wb_anon_token')){
$dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM whistleblowing_reports WHERE anonymous_token IS NOT NULL AND anonymous_token<>'' GROUP BY anonymous_token HAVING COUNT(*)>1) x");
if($dup>0){ echo " [FAIL] whistleblowing_reports: $dup token duplicati\n"; $failed++; }
else {
if(idxExists($pdo,$db,'whistleblowing_reports','idx_token'))
run($pdo,$DRY,'whistleblowing_reports DROP INDEX idx_token (ridondante)',
"ALTER TABLE whistleblowing_reports DROP INDEX idx_token");
run($pdo,$DRY,'whistleblowing_reports ADD UNIQUE uq_wb_anon_token(anonymous_token)',
"ALTER TABLE whistleblowing_reports ADD UNIQUE KEY uq_wb_anon_token (anonymous_token)");
}
} else { echo " [SKIP] whistleblowing_reports.uq_wb_anon_token già presente\n"; $skipped++; }
/* ───────── TIER 1: FOREIGN KEY mancanti (pre-check orfani) ───────── */
echo "\n── TIER 1: FOREIGN KEY ──\n";
// [constraint, tabella, colonna, ref_table, on_delete]
$fks = [
['fk_org_consulting_firm','organizations','consulting_firm_id','consulting_firms','SET NULL'],
['fk_users_consulting_firm','users','consulting_firm_id','consulting_firms','SET NULL'],
['fk_kbdoc_consulting_firm','kb_uploaded_documents','consulting_firm_id','consulting_firms','SET NULL'],
['fk_isms_soa_control','isms_soa','linked_control_id','compliance_controls','SET NULL'],
['fk_isms_doc_policy','isms_documents','linked_policy_id','policies','SET NULL'],
['fk_mrd_capa','management_review_decisions','capa_id','capa_actions','SET NULL'],
];
foreach($fks as [$name,$t,$c,$ref,$od]){
if(!tblExists($pdo,$db,$t) || !tblExists($pdo,$db,$ref)){ echo " [SKIP] $name (tabella assente)\n"; $skipped++; continue; }
if(fkExists($pdo,$db,$name)){ echo " [SKIP] $name già presente\n"; $skipped++; continue; }
$orf=(int)col1($pdo,"SELECT COUNT(*) FROM `$t` x WHERE x.`$c` IS NOT NULL AND x.`$c`<>0 AND NOT EXISTS(SELECT 1 FROM `$ref` r WHERE r.id=x.`$c`)");
if($orf>0){ echo " [FAIL] $name: $orf righe orfane su $t.$c → bonificare prima\n"; $failed++; continue; }
run($pdo,$DRY,"$t ADD FK $name($c)->$ref ON DELETE $od",
"ALTER TABLE `$t` ADD CONSTRAINT `$name` FOREIGN KEY (`$c`) REFERENCES `$ref`(id) ON DELETE $od ON UPDATE CASCADE");
}
/* ───────── TIER 2: bonifica righe stale + FK (solo con --with-cleanup) ───────── */
echo "\n── TIER 2: cleanup orfani (".($CLEANUP?'ATTIVO':'saltato, usa --with-cleanup').") ──\n";
if($CLEANUP){
// C1 firm_org_assignments: 9 righe verso org 126-129 inesistenti (dogfooding, deferred-b 039)
$n=(int)col1($pdo,"SELECT COUNT(*) FROM firm_org_assignments x WHERE NOT EXISTS(SELECT 1 FROM organizations o WHERE o.id=x.organization_id)");
if($n>0){
if($DRY) echo " [DRY] DELETE $n righe stale firm_org_assignments (org inesistente)\n";
else { $pdo->exec("DELETE FROM firm_org_assignments WHERE organization_id NOT IN (SELECT id FROM organizations)"); echo " [OK ] cancellate $n righe stale firm_org_assignments\n"; $applied++; }
} else echo " [SKIP] firm_org_assignments: 0 orfani\n";
if(!fkExists($pdo,$db,'fk_firm_org_assignments_organization_id'))
run($pdo,$DRY,'firm_org_assignments ADD FK organization_id->organizations CASCADE',
"ALTER TABLE firm_org_assignments ADD CONSTRAINT fk_firm_org_assignments_organization_id FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE");
else { echo " [SKIP] fk_firm_org_assignments_organization_id già presente\n"; $skipped++; }
// C2 active_sessions: 7 sessioni verso org inesistenti
$n2=(int)col1($pdo,"SELECT COUNT(*) FROM active_sessions x WHERE x.organization_id IS NOT NULL AND x.organization_id<>0 AND NOT EXISTS(SELECT 1 FROM organizations o WHERE o.id=x.organization_id)");
if($n2>0){
if($DRY) echo " [DRY] DELETE $n2 sessioni stale active_sessions\n";
else { $pdo->exec("DELETE FROM active_sessions WHERE organization_id IS NOT NULL AND organization_id<>0 AND organization_id NOT IN (SELECT id FROM organizations)"); echo " [OK ] cancellate $n2 sessioni stale active_sessions\n"; $applied++; }
} else echo " [SKIP] active_sessions: 0 orfani\n";
if(!fkExists($pdo,$db,'fk_active_sessions_organization_id'))
run($pdo,$DRY,'active_sessions ADD FK organization_id->organizations CASCADE',
"ALTER TABLE active_sessions ADD CONSTRAINT fk_active_sessions_organization_id FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE");
else { echo " [SKIP] fk_active_sessions_organization_id già presente\n"; $skipped++; }
}
echo "\n── RIEPILOGO ── applicati=$applied skip=$skipped falliti=$failed\n";
echo ($failed===0 ? "ESITO OK\n" : "ESITO: rivedere i [FAIL]\n");
exit($failed===0?0:1);
@@ -140,10 +140,11 @@ class InternalAuditController extends BaseController
$leadUserId = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId);
$leadRoleId = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId);
$code = $this->generateAuditCode($orgId);
$id = (int) Database::insert('internal_audits', [
// Retry-on-duplicate: generateAuditCode() è check-poi-insert non atomico (race su
// create concorrenti); la UNIQUE (organization_id, code) [mig.058] blocca i doppioni
// e qui si rigenera il codice.
$base = [
'organization_id' => $orgId,
'code' => $code,
'title' => $title,
'scope' => $this->nullableStr($b['scope'] ?? null),
'criteria' => $this->nullableStr($b['criteria'] ?? null),
@@ -154,7 +155,18 @@ class InternalAuditController extends BaseController
'lead_auditor_role_id' => $leadRoleId,
'conclusion' => $this->nullableStr($b['conclusion'] ?? null),
'created_by' => $this->getCurrentUserId(),
]);
];
$code = null; $id = 0;
for ($attempt = 0; ; $attempt++) {
$code = $this->generateAuditCode($orgId);
try {
$id = (int) Database::insert('internal_audits', ['code' => $code] + $base);
break;
} catch (\PDOException $e) {
if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; }
throw $e;
}
}
$seeded = $this->seedChecklist($id, $orgId);
$this->upsertCalendar($id, $orgId, $code, $title, $planned);
@@ -111,11 +111,11 @@ class ManagementReviewController extends BaseController
$conclusions = $this->nullableStr($b['conclusions'] ?? null);
$year = $reviewDate ? (int) substr($reviewDate, 0, 4) : (int) date('Y');
$code = $this->nextCode($orgId, $year);
$id = Database::insert('management_reviews', [
// Retry-on-duplicate: il codice RD-AAAA-NN è MAX+1 (race su create concorrenti);
// la UNIQUE (organization_id, code) [mig.058] blocca i doppioni e qui si rigenera.
$base = [
'organization_id' => $orgId,
'code' => $code,
'review_date' => $reviewDate,
'period_label' => $period,
'chair_user_id' => $chairId,
@@ -123,7 +123,18 @@ class ManagementReviewController extends BaseController
'status' => 'draft',
'conclusions' => $conclusions,
'created_by' => $this->getCurrentUserId(),
]);
];
$code = null; $id = 0;
for ($attempt = 0; ; $attempt++) {
$code = $this->nextCode($orgId, $year);
try {
$id = (int) Database::insert('management_reviews', ['code' => $code] + $base);
break;
} catch (\PDOException $e) {
if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; }
throw $e;
}
}
$this->logAudit('mgmt_review_created', 'management_review', (int) $id, ['code' => $code]);
$this->jsonSuccess(['id' => (int) $id, 'code' => $code], 'Riesame creato', 201);
}