[FEAT] A4 Fasi 4.4 + 4.5 (A4 COMPLETA 5/5): scadenziario centralizzato review_schedule (mig.044; ReviewScheduleController list/create/update/delete/complete/sync) + stakeholder estesi clienti/partner GV.SC-02 (mig.045 suppliers.stakeholder_type, SupplyChainController::stakeholderMap). Pagine review-schedule.html + stakeholders.html (Bootstrap Italia/AGID). Workflow build+review adversariale (3 major risolti: 045 da DELIMITER -> ALTER bare runner-safe). help GV.SC-04/05 allineati al titolo canonico ACN. Cache-buster ?v=20260620.
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
c7d36c910f
commit
c5b00ccd96
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Scadenziario centralizzato delle revisioni (A4 Fase 4.4)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Registro unico delle scadenze di revisione periodica: ruoli, competenze,
|
||||
* inventario, procedure, rischi, fornitori, misure e voci custom. Lo STATO
|
||||
* (ok/due/overdue) e' calcolato LIVE in base a next_review_date e NON ci si
|
||||
* affida alla colonna persistita (computeStatus()).
|
||||
*
|
||||
* Multi-tenancy ancorata a getCurrentOrgId(): OGNI query filtra organization_id.
|
||||
* Le letture passano requireOrgAccess(); le scritture richiedono
|
||||
* org_admin/compliance_manager (super_admin bypassa; il demo guard gestisce il
|
||||
* read-only/sandbox automaticamente in BaseController). Anti-IDOR: owner_role_id
|
||||
* e' verificato come appartenente all'org (fetchRoleOrFail).
|
||||
*
|
||||
* sync(): upsert idempotente di voci dalle scadenze gia' presenti nel prodotto,
|
||||
* tutte org-scoped, con anti-dup su (entity_type, entity_id):
|
||||
* - policies.next_review_date -> entity_type='procedure'
|
||||
* - compliance_controls.next_review_date -> entity_type='custom'
|
||||
* - risk_treatments.due_date -> entity_type='risk' (JOIN risks: la
|
||||
* tabella risk_treatments NON ha organization_id, si filtra via risks)
|
||||
*
|
||||
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
|
||||
* - GV.PO-02: policy/procedure riesaminate periodicamente
|
||||
* - GV.SC-07: sicurezza fornitori monitorata e rivista
|
||||
* - PR.AT: formazione/sensibilizzazione ricorrenti
|
||||
* - DE.CM: monitoraggio continuo
|
||||
* - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate
|
||||
* Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma;
|
||||
* strumento di supporto, non un parere legale.
|
||||
*
|
||||
* NOTE strutturali (verificate sul codice reale):
|
||||
* - DB API: Database::query/fetchAll/fetchOne/insert/update/delete/count
|
||||
* (NON esiste Database::execute).
|
||||
* - Le AZIONI sono capa_actions (figlie di non_conformities): 4.4 non le usa.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class ReviewScheduleController extends BaseController
|
||||
{
|
||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||
|
||||
private const ENTITY_TYPES = [
|
||||
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom',
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// LETTURA
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/review-schedule/list
|
||||
* Elenco scadenze dell'org, ordinate per prossima revisione. Lo stato e'
|
||||
* ricalcolato LIVE (override del valore in colonna). Include il nome del
|
||||
* ruolo owner e i contatori di sintesi.
|
||||
*/
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT rs.id, rs.entity_type, rs.entity_id, rs.title, rs.owner_role_id,
|
||||
rs.frequency_months, rs.last_reviewed_at, rs.next_review_date,
|
||||
rs.notes, rs.created_at, rs.updated_at,
|
||||
r.role_name AS owner_role_name
|
||||
FROM review_schedule rs
|
||||
LEFT JOIN org_roles r ON r.id = rs.owner_role_id
|
||||
WHERE rs.organization_id = ?
|
||||
ORDER BY rs.next_review_date ASC, rs.id ASC',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$items = [];
|
||||
$counts = ['ok' => 0, 'due' => 0, 'overdue' => 0];
|
||||
foreach ($rows as $r) {
|
||||
$status = $this->computeStatus($r['next_review_date']);
|
||||
$counts[$status]++;
|
||||
$items[] = [
|
||||
'id' => (int) $r['id'],
|
||||
'entity_type' => $r['entity_type'],
|
||||
'entity_id' => $r['entity_id'] !== null ? (int) $r['entity_id'] : null,
|
||||
'title' => $r['title'],
|
||||
'owner_role_id' => $r['owner_role_id'] !== null ? (int) $r['owner_role_id'] : null,
|
||||
'owner_role_name' => $r['owner_role_name'],
|
||||
'frequency_months' => $r['frequency_months'] !== null ? (int) $r['frequency_months'] : null,
|
||||
'last_reviewed_at' => $r['last_reviewed_at'],
|
||||
'next_review_date' => $r['next_review_date'],
|
||||
'status' => $status,
|
||||
'notes' => $r['notes'],
|
||||
'created_at' => $r['created_at'],
|
||||
'updated_at' => $r['updated_at'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'items' => $items,
|
||||
'total' => count($items),
|
||||
'ok' => $counts['ok'],
|
||||
'due' => $counts['due'],
|
||||
'overdue' => $counts['overdue'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SCRITTURA
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/review-schedule/create
|
||||
* Body: {title*, next_review_date*, entity_type?, entity_id?, owner_role_id?,
|
||||
* frequency_months?, notes?}
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$this->validateRequired(['title', 'next_review_date']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$type = $this->validateEntityType($this->getParam('entity_type', 'custom'));
|
||||
$nextDate = $this->validateDate($this->getParam('next_review_date'));
|
||||
$ownerId = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
|
||||
$freq = $this->nullablePositiveInt($this->getParam('frequency_months'));
|
||||
$entityId = $this->nullableInt($this->getParam('entity_id'));
|
||||
|
||||
$id = Database::insert('review_schedule', [
|
||||
'organization_id' => $orgId,
|
||||
'entity_type' => $type,
|
||||
'entity_id' => $entityId,
|
||||
'title' => trim((string) $this->getParam('title')),
|
||||
'owner_role_id' => $ownerId,
|
||||
'frequency_months' => $freq,
|
||||
'last_reviewed_at' => null,
|
||||
'next_review_date' => $nextDate,
|
||||
'status' => $this->computeStatus($nextDate),
|
||||
'notes' => $this->nullableText($this->getParam('notes')),
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
|
||||
$this->logAudit('review_schedule_created', 'review_schedule', $id, [
|
||||
'entity_type' => $type, 'next_review_date' => $nextDate,
|
||||
]);
|
||||
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario creata', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/review-schedule/{id}
|
||||
* Aggiorna solo i campi presenti nel body. Ricalcola lo stato se cambia la
|
||||
* prossima revisione.
|
||||
*/
|
||||
public function update(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$row = $this->fetchEntryOrFail($id, $orgId);
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('title')) {
|
||||
$title = trim((string) $this->getParam('title'));
|
||||
if ($title === '') {
|
||||
$this->jsonError('Il titolo non puo essere vuoto', 422, 'INVALID_TITLE');
|
||||
}
|
||||
$updates['title'] = $title;
|
||||
}
|
||||
if ($this->hasParam('entity_type')) {
|
||||
$updates['entity_type'] = $this->validateEntityType($this->getParam('entity_type'));
|
||||
}
|
||||
if ($this->hasParam('entity_id')) {
|
||||
$updates['entity_id'] = $this->nullableInt($this->getParam('entity_id'));
|
||||
}
|
||||
if ($this->hasParam('owner_role_id')) {
|
||||
$updates['owner_role_id'] = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
|
||||
}
|
||||
if ($this->hasParam('frequency_months')) {
|
||||
$updates['frequency_months'] = $this->nullablePositiveInt($this->getParam('frequency_months'));
|
||||
}
|
||||
if ($this->hasParam('notes')) {
|
||||
$updates['notes'] = $this->nullableText($this->getParam('notes'));
|
||||
}
|
||||
if ($this->hasParam('next_review_date')) {
|
||||
$nextDate = $this->validateDate($this->getParam('next_review_date'));
|
||||
$updates['next_review_date'] = $nextDate;
|
||||
$updates['status'] = $this->computeStatus($nextDate);
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_FIELDS');
|
||||
}
|
||||
|
||||
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
|
||||
$this->logAudit('review_schedule_updated', 'review_schedule', $id, array_keys($updates));
|
||||
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario aggiornata');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/review-schedule/{id}
|
||||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$deleted = Database::delete('review_schedule', 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if ($deleted === 0) {
|
||||
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
|
||||
}
|
||||
|
||||
$this->logAudit('review_schedule_deleted', 'review_schedule', $id, null);
|
||||
$this->jsonSuccess(null, 'Voce di scadenziario eliminata');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/review-schedule/{id}/complete
|
||||
* Segna la voce come revisionata oggi. Se ha una frequenza, avanza la
|
||||
* prossima revisione di frequency_months a partire da oggi.
|
||||
*/
|
||||
public function complete(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$row = $this->fetchEntryOrFail($id, $orgId);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$updates = ['last_reviewed_at' => $today];
|
||||
|
||||
$freq = $row['frequency_months'] !== null ? (int) $row['frequency_months'] : null;
|
||||
if ($freq !== null && $freq > 0) {
|
||||
$nextDate = date('Y-m-d', strtotime("+{$freq} months", strtotime($today)));
|
||||
$updates['next_review_date'] = $nextDate;
|
||||
$updates['status'] = $this->computeStatus($nextDate);
|
||||
} else {
|
||||
// Nessuna cadenza: la prossima revisione resta invariata, ricalcolo lo stato.
|
||||
$updates['status'] = $this->computeStatus($row['next_review_date']);
|
||||
$nextDate = $row['next_review_date'];
|
||||
}
|
||||
|
||||
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
|
||||
$this->logAudit('review_schedule_completed', 'review_schedule', $id, [
|
||||
'last_reviewed_at' => $today, 'next_review_date' => $nextDate,
|
||||
]);
|
||||
$this->jsonSuccess([
|
||||
'id' => $id,
|
||||
'last_reviewed_at' => $today,
|
||||
'next_review_date' => $nextDate,
|
||||
'status' => $updates['status'],
|
||||
], 'Revisione registrata');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/review-schedule/sync
|
||||
* Upsert idempotente dalle scadenze gia' presenti (policy / controlli /
|
||||
* trattamenti rischio), tutte org-scoped. Anti-dup su (entity_type, entity_id):
|
||||
* se la voce esiste e la data sorgente e' cambiata la aggiorna, altrimenti
|
||||
* crea. Le voci create a mano (entity_type='custom', entity_id NULL) non
|
||||
* vengono toccate.
|
||||
*/
|
||||
public function sync(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
// 1. Procedure (policies) con next_review_date impostata.
|
||||
$policies = Database::fetchAll(
|
||||
'SELECT id, title, next_review_date
|
||||
FROM policies
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL AND deleted_at IS NULL',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($policies as $p) {
|
||||
$this->upsertSyncEntry(
|
||||
$orgId, $userId, 'procedure', (int) $p['id'],
|
||||
(string) $p['title'], (string) $p['next_review_date'],
|
||||
'Sincronizzato da procedura/policy',
|
||||
$created, $updated, $skipped
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Controlli di compliance con next_review_date impostata (entity_type='custom').
|
||||
$controls = Database::fetchAll(
|
||||
'SELECT id, control_code, title, next_review_date
|
||||
FROM compliance_controls
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($controls as $c) {
|
||||
$label = trim(((string) ($c['control_code'] ?? '')) . ' — ' . ((string) ($c['title'] ?? '')));
|
||||
$label = trim($label, ' —');
|
||||
if ($label === '') {
|
||||
$label = 'Controllo #' . (int) $c['id'];
|
||||
}
|
||||
$this->upsertSyncEntry(
|
||||
$orgId, $userId, 'custom', (int) $c['id'],
|
||||
$label, (string) $c['next_review_date'],
|
||||
'Sincronizzato da controllo di compliance',
|
||||
$created, $updated, $skipped
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Trattamenti rischio (risk_treatments.due_date) — org via JOIN su risks
|
||||
// (risk_treatments NON ha organization_id).
|
||||
$treatments = Database::fetchAll(
|
||||
'SELECT rt.id, rt.due_date, r.title
|
||||
FROM risk_treatments rt
|
||||
JOIN risks r ON r.id = rt.risk_id
|
||||
WHERE r.organization_id = ? AND rt.due_date IS NOT NULL AND r.deleted_at IS NULL',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($treatments as $t) {
|
||||
$title = (string) ($t['title'] ?? '');
|
||||
if ($title === '') {
|
||||
$title = 'Rischio (trattamento #' . (int) $t['id'] . ')';
|
||||
}
|
||||
$this->upsertSyncEntry(
|
||||
$orgId, $userId, 'risk', (int) $t['id'],
|
||||
$title, (string) $t['due_date'],
|
||||
'Sincronizzato da trattamento del rischio',
|
||||
$created, $updated, $skipped
|
||||
);
|
||||
}
|
||||
|
||||
$this->logAudit('review_schedule_synced', 'review_schedule', null, [
|
||||
'created' => $created, 'updated' => $updated, 'skipped' => $skipped,
|
||||
]);
|
||||
$this->jsonSuccess([
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
], 'Sincronizzazione completata');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PRIVATI
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Stato calcolato LIVE: scaduto (overdue) se la data e' passata; in scadenza
|
||||
* (due) se entro 30 giorni; altrimenti ok. Confronto a granularita' giorno.
|
||||
*/
|
||||
private function computeStatus(string $nextDate): string
|
||||
{
|
||||
$next = strtotime($nextDate);
|
||||
$today = strtotime(date('Y-m-d'));
|
||||
if ($next === false) {
|
||||
return 'ok';
|
||||
}
|
||||
if ($next < $today) {
|
||||
return 'overdue';
|
||||
}
|
||||
if ($next <= strtotime('+30 days', $today)) {
|
||||
return 'due';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Voce di scadenziario org-scoped oppure 404 (anti-IDOR). */
|
||||
private function fetchEntryOrFail(int $id, int $orgId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM review_schedule WHERE id = ? AND organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$row) {
|
||||
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* owner_role_id opzionale: NULL/'' => null; altrimenti il ruolo deve
|
||||
* appartenere all'org corrente (anti-IDOR), 404 se non trovato.
|
||||
*/
|
||||
private function resolveOwnerRoleId($raw): ?int
|
||||
{
|
||||
if ($raw === null || $raw === '' || (int) $raw === 0) {
|
||||
return null;
|
||||
}
|
||||
$roleId = (int) $raw;
|
||||
$exists = Database::count(
|
||||
'org_roles', 'id = ? AND organization_id = ?', [$roleId, $this->getCurrentOrgId()]
|
||||
);
|
||||
if ($exists === 0) {
|
||||
$this->jsonError('Ruolo owner non trovato in questa organizzazione', 404, 'ROLE_NOT_FOUND');
|
||||
}
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
private function validateEntityType($t): string
|
||||
{
|
||||
$t = strtolower((string) $t);
|
||||
if ($t === '') {
|
||||
return 'custom';
|
||||
}
|
||||
if (!in_array($t, self::ENTITY_TYPES, true)) {
|
||||
$this->jsonError('Tipo entita non valido', 422, 'INVALID_ENTITY_TYPE');
|
||||
}
|
||||
return $t;
|
||||
}
|
||||
|
||||
/** Valida data in formato Y-m-d (rifiuta valori non-data o impossibili). */
|
||||
private function validateDate($raw): string
|
||||
{
|
||||
$d = trim((string) $raw);
|
||||
$dt = DateTime::createFromFormat('Y-m-d', $d);
|
||||
if (!$dt || $dt->format('Y-m-d') !== $d) {
|
||||
$this->jsonError('Data non valida (atteso formato AAAA-MM-GG)', 422, 'INVALID_DATE');
|
||||
}
|
||||
return $d;
|
||||
}
|
||||
|
||||
private function nullableInt($v): ?int
|
||||
{
|
||||
if ($v === null || $v === '') {
|
||||
return null;
|
||||
}
|
||||
return (int) $v;
|
||||
}
|
||||
|
||||
private function nullablePositiveInt($v): ?int
|
||||
{
|
||||
if ($v === null || $v === '') {
|
||||
return null;
|
||||
}
|
||||
$i = (int) $v;
|
||||
return $i > 0 ? $i : null;
|
||||
}
|
||||
|
||||
private function nullableText($v): ?string
|
||||
{
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
$s = trim((string) $v);
|
||||
return $s === '' ? null : $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert idempotente di una voce di sync su (entity_type, entity_id):
|
||||
* - non esiste -> insert (status calcolato, owner/frequenza null)
|
||||
* - esiste con data diversa -> update next_review_date + status
|
||||
* - esiste con stessa data -> skip
|
||||
*/
|
||||
private function upsertSyncEntry(
|
||||
int $orgId, ?int $userId, string $type, int $entityId,
|
||||
string $title, string $nextDate, string $note,
|
||||
int &$created, int &$updated, int &$skipped
|
||||
): void {
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id, next_review_date FROM review_schedule
|
||||
WHERE organization_id = ? AND entity_type = ? AND entity_id = ?',
|
||||
[$orgId, $type, $entityId]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
if ((string) $existing['next_review_date'] !== $nextDate) {
|
||||
Database::update('review_schedule', [
|
||||
'next_review_date' => $nextDate,
|
||||
'status' => $this->computeStatus($nextDate),
|
||||
], 'id = ? AND organization_id = ?', [(int) $existing['id'], $orgId]);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Database::insert('review_schedule', [
|
||||
'organization_id' => $orgId,
|
||||
'entity_type' => $type,
|
||||
'entity_id' => $entityId,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'owner_role_id' => null,
|
||||
'frequency_months' => null,
|
||||
'last_reviewed_at' => null,
|
||||
'next_review_date' => $nextDate,
|
||||
'status' => $this->computeStatus($nextDate),
|
||||
'notes' => $note,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class SupplyChainController extends BaseController
|
||||
$supplierId = Database::insert('suppliers', [
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'name' => trim($this->getParam('name')),
|
||||
'stakeholder_type' => $this->validateStakeholderType($this->getParam('stakeholder_type', 'supplier')),
|
||||
'vat_number' => $this->getParam('vat_number'),
|
||||
'contact_email' => $this->getParam('contact_email'),
|
||||
'contact_name' => $this->getParam('contact_name'),
|
||||
@@ -76,6 +77,11 @@ class SupplyChainController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// stakeholder_type (GV.SC-02): gestito a parte per validare l'enum
|
||||
if ($this->hasParam('stakeholder_type')) {
|
||||
$updates['stakeholder_type'] = $this->validateStakeholderType($this->getParam('stakeholder_type'));
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
$this->logAudit('supplier_updated', 'supplier', $id, $updates);
|
||||
@@ -146,6 +152,58 @@ class SupplyChainController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// STAKEHOLDER ESTESI (A4 Fase 4.5) — GV.SC-02: fornitori + clienti + partner
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/supply-chain/stakeholder-map — mappa di sintesi degli stakeholder
|
||||
* dell'org raggruppati per tipo (supplier/customer/partner). RIUSA la tabella
|
||||
* suppliers (nessun duplicato): il dettaglio fornitori resta nel modulo Supply
|
||||
* Chain. Ancoraggio GV.SC-02 (ruoli/responsabilita verso fornitori, clienti E
|
||||
* partner) + GV.SC-04/05/07. Org-scoped, esclude i soft-deleted.
|
||||
*/
|
||||
public function stakeholderMap(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, name, stakeholder_type, service_type, criticality, status,
|
||||
contact_email, contact_name, risk_score
|
||||
FROM suppliers
|
||||
WHERE organization_id = ? AND deleted_at IS NULL
|
||||
ORDER BY stakeholder_type, criticality DESC, name',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$groups = ['supplier' => [], 'customer' => [], 'partner' => []];
|
||||
foreach ($rows as $r) {
|
||||
$t = $r['stakeholder_type'] ?? 'supplier';
|
||||
if (!isset($groups[$t])) {
|
||||
$t = 'supplier';
|
||||
}
|
||||
$groups[$t][] = $r;
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'groups' => $groups,
|
||||
'counts' => [
|
||||
'supplier' => count($groups['supplier']),
|
||||
'customer' => count($groups['customer']),
|
||||
'partner' => count($groups['partner']),
|
||||
],
|
||||
'total' => count($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Normalizza/valida il tipo stakeholder (default 'supplier' se non valido). */
|
||||
private function validateStakeholderType($t): string
|
||||
{
|
||||
$t = strtolower((string) $t);
|
||||
return in_array($t, ['supplier', 'customer', 'partner'], true) ? $t : 'supplier';
|
||||
}
|
||||
|
||||
private function calculateSupplierRiskScore(array $responses): int
|
||||
{
|
||||
if (empty($responses)) return 0;
|
||||
|
||||
Reference in New Issue
Block a user