505 lines
19 KiB
PHP
505 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Non-Conformity & CAPA Controller
|
|
*
|
|
* Gestione non conformità e azioni correttive/preventive.
|
|
* Predisposizione integrazione SistemiG.agile.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class NonConformityController extends BaseController
|
|
{
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// NCR CRUD
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* POST /api/ncr/create
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
$this->validateRequired(['title', 'source']);
|
|
|
|
$orgId = $this->getCurrentOrgId();
|
|
$ncrCode = $this->generateCode('NCR');
|
|
|
|
$ncrId = Database::insert('non_conformities', [
|
|
'organization_id' => $orgId,
|
|
'ncr_code' => $ncrCode,
|
|
'title' => trim($this->getParam('title')),
|
|
'description' => $this->getParam('description'),
|
|
'source' => $this->getParam('source'),
|
|
'source_entity_type' => $this->getParam('source_entity_type'),
|
|
'source_entity_id' => $this->getParam('source_entity_id'),
|
|
'severity' => $this->getParam('severity', 'minor'),
|
|
'category' => $this->getParam('category'),
|
|
'nis2_article' => $this->getParam('nis2_article'),
|
|
'identified_by' => $this->getCurrentUserId(),
|
|
'assigned_to' => $this->getParam('assigned_to'),
|
|
'target_close_date' => $this->getParam('target_close_date'),
|
|
]);
|
|
|
|
$this->logAudit('ncr_created', 'non_conformity', $ncrId, [
|
|
'ncr_code' => $ncrCode,
|
|
'source' => $this->getParam('source'),
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'id' => $ncrId,
|
|
'ncr_code' => $ncrCode,
|
|
], 'Non conformita\' creata', 201);
|
|
}
|
|
|
|
/**
|
|
* GET /api/ncr/list
|
|
*/
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$pagination = $this->getPagination();
|
|
$page = $pagination['page'];
|
|
$perPage = $pagination['per_page'];
|
|
$offset = $pagination['offset'];
|
|
|
|
// Filters
|
|
$where = 'n.organization_id = ?';
|
|
$params = [$orgId];
|
|
|
|
$status = $this->getParam('status');
|
|
if ($status) {
|
|
$where .= ' AND n.status = ?';
|
|
$params[] = $status;
|
|
}
|
|
|
|
$severity = $this->getParam('severity');
|
|
if ($severity) {
|
|
$where .= ' AND n.severity = ?';
|
|
$params[] = $severity;
|
|
}
|
|
|
|
$source = $this->getParam('source');
|
|
if ($source) {
|
|
$where .= ' AND n.source = ?';
|
|
$params[] = $source;
|
|
}
|
|
|
|
$total = Database::count('non_conformities n', $where, $params);
|
|
|
|
$ncrs = Database::fetchAll(
|
|
"SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name,
|
|
(SELECT COUNT(*) FROM capa_actions WHERE ncr_id = n.id) as capa_count
|
|
FROM non_conformities n
|
|
LEFT JOIN users u1 ON u1.id = n.identified_by
|
|
LEFT JOIN users u2 ON u2.id = n.assigned_to
|
|
WHERE {$where}
|
|
ORDER BY n.created_at DESC
|
|
LIMIT {$perPage} OFFSET {$offset}",
|
|
$params
|
|
);
|
|
|
|
$this->jsonPaginated($ncrs, $total, $page, $perPage);
|
|
}
|
|
|
|
/**
|
|
* GET /api/ncr/{id}
|
|
*/
|
|
public function get(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$ncr = Database::fetchOne(
|
|
'SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name
|
|
FROM non_conformities n
|
|
LEFT JOIN users u1 ON u1.id = n.identified_by
|
|
LEFT JOIN users u2 ON u2.id = n.assigned_to
|
|
WHERE n.id = ? AND n.organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$ncr) {
|
|
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
|
|
}
|
|
|
|
// Load CAPA actions
|
|
$ncr['capa_actions'] = Database::fetchAll(
|
|
'SELECT ca.*, u1.full_name as responsible_name, u2.full_name as verified_by_name
|
|
FROM capa_actions ca
|
|
LEFT JOIN users u1 ON u1.id = ca.responsible_user_id
|
|
LEFT JOIN users u2 ON u2.id = ca.verified_by
|
|
WHERE ca.ncr_id = ?
|
|
ORDER BY ca.created_at ASC',
|
|
[$id]
|
|
);
|
|
|
|
$this->jsonSuccess($ncr);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/ncr/{id}
|
|
*/
|
|
public function update(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
|
|
$ncr = Database::fetchOne(
|
|
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$ncr) {
|
|
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
|
|
}
|
|
|
|
$allowedFields = [
|
|
'title', 'description', 'severity', 'category', 'nis2_article',
|
|
'status', 'assigned_to', 'target_close_date', 'actual_close_date',
|
|
'root_cause_analysis', 'root_cause_method',
|
|
];
|
|
|
|
$updates = [];
|
|
foreach ($allowedFields as $field) {
|
|
if ($this->hasParam($field)) {
|
|
$updates[$field] = $this->getParam($field);
|
|
}
|
|
}
|
|
|
|
if (empty($updates)) {
|
|
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
|
}
|
|
|
|
// Auto-set close date when status becomes closed
|
|
if (isset($updates['status']) && $updates['status'] === 'closed' && !isset($updates['actual_close_date'])) {
|
|
$updates['actual_close_date'] = date('Y-m-d');
|
|
}
|
|
|
|
Database::update('non_conformities', $updates, 'id = ?', [$id]);
|
|
|
|
$this->logAudit('ncr_updated', 'non_conformity', $id, $updates);
|
|
|
|
$this->jsonSuccess($updates, 'Non conformita\' aggiornata');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// CAPA ACTIONS
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* POST /api/ncr/{id}/capa
|
|
*/
|
|
public function addCapa(int $ncrId): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
$this->validateRequired(['title', 'action_type']);
|
|
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
// Verify NCR belongs to org
|
|
$ncr = Database::fetchOne(
|
|
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
|
|
[$ncrId, $orgId]
|
|
);
|
|
|
|
if (!$ncr) {
|
|
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
|
|
}
|
|
|
|
$capaCode = $this->generateCode('CAPA');
|
|
|
|
$capaId = Database::insert('capa_actions', [
|
|
'ncr_id' => $ncrId,
|
|
'organization_id' => $orgId,
|
|
'capa_code' => $capaCode,
|
|
'action_type' => $this->getParam('action_type', 'corrective'),
|
|
'title' => trim($this->getParam('title')),
|
|
'description' => $this->getParam('description'),
|
|
'responsible_user_id' => $this->getParam('responsible_user_id'),
|
|
'due_date' => $this->getParam('due_date'),
|
|
'notes' => $this->getParam('notes'),
|
|
]);
|
|
|
|
// Update NCR status to action_planned if still open/investigating
|
|
$ncrStatus = Database::fetchOne('SELECT status FROM non_conformities WHERE id = ?', [$ncrId]);
|
|
if ($ncrStatus && in_array($ncrStatus['status'], ['open', 'investigating'])) {
|
|
Database::update('non_conformities', ['status' => 'action_planned'], 'id = ?', [$ncrId]);
|
|
}
|
|
|
|
$this->logAudit('capa_created', 'capa_action', $capaId, [
|
|
'ncr_id' => $ncrId,
|
|
'capa_code' => $capaCode,
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'id' => $capaId,
|
|
'capa_code' => $capaCode,
|
|
], 'Azione CAPA creata', 201);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/ncr/capa/{subId}
|
|
*/
|
|
public function updateCapa(int $capaId): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
|
|
$capa = Database::fetchOne(
|
|
'SELECT id, ncr_id FROM capa_actions WHERE id = ? AND organization_id = ?',
|
|
[$capaId, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$capa) {
|
|
$this->jsonError('Azione CAPA non trovata', 404, 'CAPA_NOT_FOUND');
|
|
}
|
|
|
|
$allowedFields = [
|
|
'title', 'description', 'action_type', 'status',
|
|
'responsible_user_id', 'due_date', 'completion_date',
|
|
'verification_date', 'verified_by', 'effectiveness_review',
|
|
'is_effective', 'notes',
|
|
];
|
|
|
|
$updates = [];
|
|
foreach ($allowedFields as $field) {
|
|
if ($this->hasParam($field)) {
|
|
$updates[$field] = $this->getParam($field);
|
|
}
|
|
}
|
|
|
|
if (empty($updates)) {
|
|
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
|
}
|
|
|
|
// Auto-set completion date
|
|
if (isset($updates['status']) && $updates['status'] === 'completed' && !isset($updates['completion_date'])) {
|
|
$updates['completion_date'] = date('Y-m-d');
|
|
}
|
|
|
|
Database::update('capa_actions', $updates, 'id = ?', [$capaId]);
|
|
|
|
$this->logAudit('capa_updated', 'capa_action', $capaId, $updates);
|
|
|
|
$this->jsonSuccess($updates, 'Azione CAPA aggiornata');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// BULK CREATE FROM ASSESSMENT
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* POST /api/ncr/fromAssessment
|
|
* Genera NCR dai gap (risposte not_implemented/partial) di un assessment
|
|
*/
|
|
public function fromAssessment(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$this->validateRequired(['assessment_id']);
|
|
|
|
$orgId = $this->getCurrentOrgId();
|
|
$assessmentId = (int) $this->getParam('assessment_id');
|
|
|
|
// Verify assessment belongs to org
|
|
$assessment = Database::fetchOne(
|
|
'SELECT id, title FROM assessments WHERE id = ? AND organization_id = ?',
|
|
[$assessmentId, $orgId]
|
|
);
|
|
|
|
if (!$assessment) {
|
|
$this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND');
|
|
}
|
|
|
|
// Check for existing NCRs from this assessment to avoid duplicates
|
|
$existingCount = Database::count(
|
|
'non_conformities',
|
|
'organization_id = ? AND source = ? AND source_entity_type = ? AND source_entity_id IN (SELECT id FROM assessment_responses WHERE assessment_id = ?)',
|
|
[$orgId, 'assessment', 'assessment_response', $assessmentId]
|
|
);
|
|
|
|
if ($existingCount > 0) {
|
|
$this->jsonError(
|
|
"Esistono gia' {$existingCount} non conformita' generate da questo assessment",
|
|
409,
|
|
'NCR_ALREADY_GENERATED'
|
|
);
|
|
}
|
|
|
|
// Find gaps
|
|
$gaps = Database::fetchAll(
|
|
'SELECT * FROM assessment_responses
|
|
WHERE assessment_id = ?
|
|
AND response_value IN ("not_implemented", "partial")
|
|
ORDER BY category, question_code',
|
|
[$assessmentId]
|
|
);
|
|
|
|
if (empty($gaps)) {
|
|
$this->jsonSuccess(['created' => [], 'total' => 0], 'Nessun gap trovato nell\'assessment');
|
|
return;
|
|
}
|
|
|
|
$created = [];
|
|
$assessmentTitle = $assessment['title'];
|
|
|
|
foreach ($gaps as $gap) {
|
|
$severity = $gap['response_value'] === 'not_implemented' ? 'major' : 'minor';
|
|
$ncrCode = $this->generateCode('NCR');
|
|
|
|
$questionText = $gap['question_text'] ?? '';
|
|
$title = mb_strlen($questionText) > 200
|
|
? 'Gap: ' . mb_substr($questionText, 0, 197) . '...'
|
|
: 'Gap: ' . $questionText;
|
|
|
|
$ncrId = Database::insert('non_conformities', [
|
|
'organization_id' => $orgId,
|
|
'ncr_code' => $ncrCode,
|
|
'title' => $title,
|
|
'description' => "Gap identificato nell'assessment \"{$assessmentTitle}\": {$questionText}",
|
|
'source' => 'assessment',
|
|
'source_entity_type' => 'assessment_response',
|
|
'source_entity_id' => $gap['id'],
|
|
'severity' => $severity,
|
|
'category' => $gap['category'] ?? null,
|
|
'nis2_article' => $gap['nis2_article'] ?? null,
|
|
'identified_by' => $this->getCurrentUserId(),
|
|
'status' => 'open',
|
|
]);
|
|
|
|
$created[] = [
|
|
'id' => $ncrId,
|
|
'ncr_code' => $ncrCode,
|
|
'severity' => $severity,
|
|
'category' => $gap['category'] ?? null,
|
|
];
|
|
}
|
|
|
|
$this->logAudit('ncr_bulk_created', 'assessment', $assessmentId, [
|
|
'count' => count($created),
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'created' => $created,
|
|
'total' => count($created),
|
|
], count($created) . ' non conformita\' generate dall\'assessment');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// STATISTICS
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* GET /api/ncr/stats
|
|
*/
|
|
public function stats(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
// NCR counts by status
|
|
$byStatus = Database::fetchAll(
|
|
'SELECT status, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY status',
|
|
[$orgId]
|
|
);
|
|
|
|
// NCR counts by severity
|
|
$bySeverity = Database::fetchAll(
|
|
'SELECT severity, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY severity',
|
|
[$orgId]
|
|
);
|
|
|
|
// CAPA counts by status
|
|
$capaByStatus = Database::fetchAll(
|
|
'SELECT status, COUNT(*) as count FROM capa_actions WHERE organization_id = ? GROUP BY status',
|
|
[$orgId]
|
|
);
|
|
|
|
// Overdue NCRs
|
|
$overdueNcr = Database::count(
|
|
'non_conformities',
|
|
'organization_id = ? AND target_close_date < CURDATE() AND status NOT IN ("closed", "cancelled")',
|
|
[$orgId]
|
|
);
|
|
|
|
// Overdue CAPAs
|
|
$overdueCapa = Database::count(
|
|
'capa_actions',
|
|
'organization_id = ? AND due_date < CURDATE() AND status NOT IN ("completed", "verified")',
|
|
[$orgId]
|
|
);
|
|
|
|
$totalNcr = Database::count('non_conformities', 'organization_id = ?', [$orgId]);
|
|
$openNcr = Database::count('non_conformities', 'organization_id = ? AND status NOT IN ("closed", "cancelled")', [$orgId]);
|
|
|
|
$this->jsonSuccess([
|
|
'total_ncr' => $totalNcr,
|
|
'open_ncr' => $openNcr,
|
|
'overdue_ncr' => $overdueNcr,
|
|
'overdue_capa' => $overdueCapa,
|
|
'ncr_by_status' => $byStatus,
|
|
'ncr_by_severity' => $bySeverity,
|
|
'capa_by_status' => $capaByStatus,
|
|
]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// EXTERNAL INTEGRATION (SistemiG.agile - Future)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* POST /api/ncr/{id}/sync
|
|
* Sincronizza NCR con sistema esterno (SistemiG.agile)
|
|
*/
|
|
public function syncExternal(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
$ncr = Database::fetchOne(
|
|
'SELECT * FROM non_conformities WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$ncr) {
|
|
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
|
|
}
|
|
|
|
$targetUrl = defined('SISTEMIG_API_URL') ? SISTEMIG_API_URL : '';
|
|
if (empty($targetUrl)) {
|
|
$this->jsonError('Integrazione SistemiG non configurata', 501, 'INTEGRATION_NOT_CONFIGURED');
|
|
}
|
|
|
|
// Placeholder: in future this will POST to SistemiG API
|
|
$this->jsonSuccess([
|
|
'ncr_id' => $id,
|
|
'ncr_code' => $ncr['ncr_code'],
|
|
'sync_status' => 'not_implemented',
|
|
'message' => 'Integrazione SistemiG.agile in fase di sviluppo',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/ncr/webhook
|
|
* Webhook per ricezione aggiornamenti da sistema esterno
|
|
* Autenticato via API key (X-SistemiG-Key header), non JWT
|
|
*/
|
|
public function webhook(): void
|
|
{
|
|
// Authenticate via API key instead of JWT
|
|
$apiKey = $_SERVER['HTTP_X_SISTEMIG_KEY'] ?? '';
|
|
$expectedKey = defined('SISTEMIG_API_KEY') ? SISTEMIG_API_KEY : '';
|
|
|
|
if (empty($expectedKey) || $apiKey !== $expectedKey) {
|
|
$this->jsonError('Chiave API non valida', 401, 'INVALID_API_KEY');
|
|
}
|
|
|
|
// Placeholder: process incoming webhook payload
|
|
$payload = $this->getJsonBody();
|
|
|
|
$this->jsonSuccess([
|
|
'received' => true,
|
|
'message' => 'Webhook ricevuto. Integrazione SistemiG.agile in fase di sviluppo.',
|
|
'payload_keys' => array_keys($payload),
|
|
]);
|
|
}
|
|
}
|