[FIX] P2/P3: aggiunti i metodi+route realmente mancanti (commit precedenti incompleti)
I commit 56ce97d/1a5db30/14c06c8 contenevano migrazioni+HTML ma gli Edit dei metodi controller e delle route erano falliti silenziosamente (ancore errate). Ora presenti e testati E2E in produzione: - DashboardController::sectorBenchmark (era 501) - SupplyChainController: sendQuestionnaire/publicQuestionnaire/submitPublicQuestionnaire/questionnaireStatus/resolveQuestionnaire + route 'supply-chain' (era 404) - PolicyController: attest/attestations/versions/diff/pendingAttestations + snapshot in approve + route (era 404) Test: benchmark 200, supplier flow send->submit(score 61)->dedup 409->DB risk_score=39, policy approve->attest(coverage 50%)->bump v2.0->diff(+2/-1)->pending ricompare. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c15d8db510
commit
31b8a4572c
@ -150,6 +150,90 @@ class DashboardController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/sectorBenchmark
|
||||
* Benchmark settoriale ANONIMIZZATO (P2): confronta lo score di compliance
|
||||
* dell'organizzazione con la media/distribuzione del proprio settore sulla
|
||||
* rete multi-tenant. k-anonymity: aggregati solo se >= MIN_PEERS organizzazioni.
|
||||
*/
|
||||
public function sectorBenchmark(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$MIN_PEERS = 3;
|
||||
|
||||
$org = Database::fetchOne('SELECT sector FROM organizations WHERE id = ?', [$orgId]);
|
||||
if (!$org) {
|
||||
$this->jsonError('Organizzazione non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
$sector = $org['sector'];
|
||||
|
||||
$mine = Database::fetchOne(
|
||||
"SELECT overall_score FROM assessments WHERE organization_id = ? AND status='completed'
|
||||
ORDER BY completed_at DESC LIMIT 1",
|
||||
[$orgId]
|
||||
);
|
||||
$myScore = $mine ? (float) $mine['overall_score'] : null;
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT a.overall_score
|
||||
FROM assessments a
|
||||
JOIN (
|
||||
SELECT organization_id, MAX(completed_at) mx
|
||||
FROM assessments WHERE status='completed'
|
||||
GROUP BY organization_id
|
||||
) last ON last.organization_id = a.organization_id AND last.mx = a.completed_at
|
||||
JOIN organizations o ON o.id = a.organization_id
|
||||
WHERE o.is_active = 1 AND o.sector = ? AND a.status='completed' AND a.overall_score IS NOT NULL",
|
||||
[$sector]
|
||||
);
|
||||
$scores = array_map(fn($r) => (float) $r['overall_score'], $rows);
|
||||
$n = count($scores);
|
||||
|
||||
if ($n < $MIN_PEERS) {
|
||||
$this->jsonSuccess([
|
||||
'sector' => $sector,
|
||||
'sufficient_data' => false,
|
||||
'min_peers_required' => $MIN_PEERS,
|
||||
'peers_available' => $n,
|
||||
'my_score' => $myScore,
|
||||
'message' => 'Dati insufficienti per un benchmark anonimo (servono almeno ' . $MIN_PEERS . ' organizzazioni nel settore).',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
sort($scores);
|
||||
$avg = array_sum($scores) / $n;
|
||||
$median = $n % 2 ? $scores[intdiv($n, 2)] : ($scores[$n / 2 - 1] + $scores[$n / 2]) / 2;
|
||||
$p25 = $scores[(int) floor(0.25 * ($n - 1))];
|
||||
$p75 = $scores[(int) floor(0.75 * ($n - 1))];
|
||||
|
||||
$percentile = null; $position = null;
|
||||
if ($myScore !== null) {
|
||||
$below = count(array_filter($scores, fn($s) => $s < $myScore));
|
||||
$percentile = (int) round($below / $n * 100);
|
||||
$position = $myScore >= $p75 ? 'top_quartile'
|
||||
: ($myScore >= $median ? 'above_median'
|
||||
: ($myScore >= $p25 ? 'below_median' : 'bottom_quartile'));
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'sector' => $sector,
|
||||
'sufficient_data' => true,
|
||||
'peers' => $n,
|
||||
'my_score' => $myScore !== null ? round($myScore, 1) : null,
|
||||
'sector_average' => round($avg, 1),
|
||||
'sector_median' => round($median, 1),
|
||||
'sector_min' => round($scores[0], 1),
|
||||
'sector_max' => round($scores[$n - 1], 1),
|
||||
'sector_p25' => round($p25, 1),
|
||||
'sector_p75' => round($p75, 1),
|
||||
'my_percentile' => $percentile,
|
||||
'my_position' => $position,
|
||||
'delta_vs_average' => $myScore !== null ? round($myScore - $avg, 1) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/upcoming-deadlines
|
||||
*/
|
||||
|
||||
@ -121,9 +121,24 @@ class PolicyController extends BaseController
|
||||
|
||||
$this->logAudit('policy_approved', 'policy', $id);
|
||||
|
||||
$policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]);
|
||||
|
||||
// Snapshot versione per storico/diff/attestation (P3)
|
||||
try {
|
||||
Database::insert('policy_versions', [
|
||||
'policy_id' => $id,
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'version' => $policy['version'] ?? '1.0',
|
||||
'content' => $policy['content'] ?? null,
|
||||
'change_note' => $this->getParam('change_note') ?: 'Approvazione',
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[POLICY_VER] ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch webhook policy.approved
|
||||
try {
|
||||
$policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]);
|
||||
(new WebhookService())->dispatch(
|
||||
$this->getCurrentOrgId(),
|
||||
'policy.approved',
|
||||
@ -181,4 +196,154 @@ class PolicyController extends BaseController
|
||||
|
||||
$this->jsonSuccess($templates);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ATTESTATION + VERSIONING (P3)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/policies/{id}/attest — dipendente prende visione (versione corrente). */
|
||||
public function attest(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$policy = Database::fetchOne(
|
||||
"SELECT id, version, status FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL",
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$policy) {
|
||||
$this->jsonError('Policy non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
if ($policy['status'] !== 'approved') {
|
||||
$this->jsonError('Si puo attestare solo una policy approvata', 422, 'NOT_APPROVED');
|
||||
}
|
||||
$version = $policy['version'] ?? '1.0';
|
||||
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM policy_attestations WHERE policy_id = ? AND user_id = ? AND version = ?',
|
||||
[$id, $this->getCurrentUserId(), $version]
|
||||
);
|
||||
if ($existing) {
|
||||
$this->jsonSuccess(['already' => true, 'version' => $version], 'Presa visione gia registrata');
|
||||
return;
|
||||
}
|
||||
|
||||
Database::insert('policy_attestations', [
|
||||
'policy_id' => $id,
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'user_id' => $this->getCurrentUserId(),
|
||||
'version' => $version,
|
||||
'ip_address' => $this->attClientIp(),
|
||||
]);
|
||||
$this->logAudit('policy_attested', 'policy', $id, ['version' => $version]);
|
||||
$this->jsonSuccess(['version' => $version], 'Presa visione registrata', 201);
|
||||
}
|
||||
|
||||
/** GET /api/policies/{id}/attestations — copertura % presa visione (versione corrente). */
|
||||
public function attestations(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']);
|
||||
$policy = Database::fetchOne(
|
||||
"SELECT id, title, version FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL",
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$policy) {
|
||||
$this->jsonError('Policy non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
$version = $policy['version'] ?? '1.0';
|
||||
|
||||
$members = Database::fetchAll(
|
||||
'SELECT u.id, u.full_name, u.email FROM user_organizations uo
|
||||
JOIN users u ON u.id = uo.user_id
|
||||
WHERE uo.organization_id = ? AND u.is_active = 1',
|
||||
[$this->getCurrentOrgId()]
|
||||
);
|
||||
$attested = Database::fetchAll(
|
||||
'SELECT user_id, attested_at FROM policy_attestations WHERE policy_id = ? AND version = ?',
|
||||
[$id, $version]
|
||||
);
|
||||
$attMap = [];
|
||||
foreach ($attested as $a) { $attMap[(int) $a['user_id']] = $a['attested_at']; }
|
||||
|
||||
$list = array_map(fn($m) => [
|
||||
'user_id' => (int) $m['id'],
|
||||
'full_name' => $m['full_name'],
|
||||
'email' => $m['email'],
|
||||
'attested' => isset($attMap[(int) $m['id']]),
|
||||
'attested_at' => $attMap[(int) $m['id']] ?? null,
|
||||
], $members);
|
||||
|
||||
$total = count($members);
|
||||
$done = count(array_filter($list, fn($x) => $x['attested']));
|
||||
$this->jsonSuccess([
|
||||
'policy_id' => $id,
|
||||
'version' => $version,
|
||||
'total_members' => $total,
|
||||
'attested' => $done,
|
||||
'coverage_percent' => $total ? (int) round($done * 100 / $total) : 0,
|
||||
'members' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/policies/{id}/versions — storico versioni. */
|
||||
public function versions(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, version, change_note, created_by, created_at FROM policy_versions
|
||||
WHERE policy_id = ? AND organization_id = ? ORDER BY created_at DESC',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
$this->jsonSuccess(['versions' => $rows]);
|
||||
}
|
||||
|
||||
/** GET /api/policies/{id}/diff?from={vId}&to={vId} — diff line-by-line. */
|
||||
public function diff(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$fromId = (int) $this->getParam('from');
|
||||
$toId = (int) $this->getParam('to');
|
||||
$a = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$fromId, $id, $this->getCurrentOrgId()]);
|
||||
$b = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$toId, $id, $this->getCurrentOrgId()]);
|
||||
if (!$a || !$b) {
|
||||
$this->jsonError('Versione non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
$linesA = preg_split('/\r\n|\r|\n/', (string) $a['content']);
|
||||
$linesB = preg_split('/\r\n|\r|\n/', (string) $b['content']);
|
||||
$setA = array_count_values(array_map('trim', $linesA));
|
||||
$setB = array_count_values(array_map('trim', $linesB));
|
||||
$added = []; $removed = [];
|
||||
foreach ($linesB as $l) { $t = trim($l); if ($t !== '' && empty($setA[$t])) $added[] = $l; }
|
||||
foreach ($linesA as $l) { $t = trim($l); if ($t !== '' && empty($setB[$t])) $removed[] = $l; }
|
||||
$this->jsonSuccess([
|
||||
'from' => ['id' => $fromId, 'version' => $a['version']],
|
||||
'to' => ['id' => $toId, 'version' => $b['version']],
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'summary' => ['added' => count($added), 'removed' => count($removed)],
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/policies/pending-attestations — policy approvate non ancora attestate dall'utente. */
|
||||
public function pendingAttestations(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT p.id, p.title, p.category, p.version, p.approved_at
|
||||
FROM policies p
|
||||
WHERE p.organization_id = ? AND p.status = 'approved' AND p.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM policy_attestations a
|
||||
WHERE a.policy_id = p.id AND a.user_id = ? AND a.version = p.version
|
||||
)
|
||||
ORDER BY p.approved_at DESC",
|
||||
[$this->getCurrentOrgId(), $this->getCurrentUserId()]
|
||||
);
|
||||
$this->jsonSuccess(['pending' => $rows, 'count' => count($rows)]);
|
||||
}
|
||||
|
||||
private function attClientIp(): ?string
|
||||
{
|
||||
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
if ($fwd) { return trim(explode(',', $fwd)[0]); }
|
||||
return $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,17 +252,33 @@ $actionMap = [
|
||||
'POST:{id}/approve' => 'approve',
|
||||
'POST:aiGenerate' => 'aiGeneratePolicy',
|
||||
'GET:templates' => 'getTemplates',
|
||||
// Attestation + versioning (P3)
|
||||
'GET:pendingAttestations' => 'pendingAttestations',
|
||||
'GET:pending-attestations' => 'pendingAttestations',
|
||||
'POST:{id}/attest' => 'attest',
|
||||
'GET:{id}/attestations' => 'attestations',
|
||||
'GET:{id}/versions' => 'versions',
|
||||
'GET:{id}/diff' => 'diff',
|
||||
],
|
||||
|
||||
// ── SupplyChainController ───────────────────────
|
||||
'supply-chain' => [
|
||||
'GET:list' => 'list',
|
||||
'POST:create' => 'create',
|
||||
'GET:riskOverview' => 'riskOverview',
|
||||
// Self-assessment fornitori (P3)
|
||||
'GET:publicQuestionnaire' => 'publicQuestionnaire', // NO AUTH (token)
|
||||
'GET:public-questionnaire' => 'publicQuestionnaire', // NO AUTH (token)
|
||||
'POST:submitPublicQuestionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token)
|
||||
'POST:submit-public-questionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token)
|
||||
'POST:{id}/sendQuestionnaire' => 'sendQuestionnaire',
|
||||
'POST:{id}/send-questionnaire' => 'sendQuestionnaire',
|
||||
'GET:{id}/questionnaireStatus' => 'questionnaireStatus',
|
||||
'GET:{id}/questionnaire-status' => 'questionnaireStatus',
|
||||
'POST:{id}/assess' => 'assessSupplier',
|
||||
'GET:{id}' => 'get',
|
||||
'PUT:{id}' => 'update',
|
||||
'DELETE:{id}' => 'delete',
|
||||
'POST:{id}/assess' => 'assessSupplier',
|
||||
'GET:riskOverview' => 'riskOverview',
|
||||
],
|
||||
|
||||
// ── TrainingController ──────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user