Files
breadcrumb-the-shire/modules/security/lib/Module/Security/Service/SecurityCheckProcess.php

271 lines
10 KiB
PHP
Raw Normal View History

2026-06-22 13:19:05 +02:00
<?php
namespace MintyPHP\Module\Security\Service;
/**
* The fixed internal security-check process.
*
* Identical for every check, so it lives in code (not the DB). Step labels and
* descriptions are translation keys call t() at render time. The tech-checklist
* step (perform_check) expands into the per-product items from the check's
* tech_schema_snapshot.
*
* Pure logic only (no DB, no HTTP) fully unit-testable.
*
* @api Consumed by Security pages, views and SecurityCheckService.
*/
final class SecurityCheckProcess
{
public const STATUS_OPEN = 'open';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STEP_INFORMATION = 'information_gathering';
public const STEP_PERFORM_CHECK = 'perform_check';
public const STEP_REPORT = 'report';
public const KIND_STEP = 'step';
public const KIND_INFO = 'info';
public const KIND_TECH_CHECKLIST = 'tech_checklist';
public const FIELD_TEXTAREA = 'textarea';
public const FIELD_DATETIME = 'datetime';
/**
* Ordered fixed process steps.
*
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool}>}>
*/
public function steps(): array
{
return [
[
'key' => 'beauftragung',
'label' => 'Order received',
'description' => 'The security check was sold for one or more customer projects; sales briefs the security officer with all details.',
'kind' => self::KIND_STEP,
],
[
'key' => self::STEP_INFORMATION,
'label' => 'Gather information',
'description' => 'Collect all relevant information from Customer Care / the project lead.',
'kind' => self::KIND_INFO,
'fields' => [
['key' => 'technical_contact', 'label' => 'Technical contact at customer', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'deployment', 'label' => 'Deployment location & type', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'system_info', 'label' => 'Technical system information', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'external_systems', 'label' => 'External systems / API endpoints (exact URLs)', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'special_notes', 'label' => 'Special notes', 'type' => self::FIELD_TEXTAREA, 'required' => false],
],
],
[
'key' => 'scheduling',
'label' => 'Scheduling',
'description' => 'Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'golive_start', 'label' => 'Go-live window start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'golive_end', 'label' => 'Go-live window end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => 'blocker_appointment',
'label' => 'Internal blocker appointment',
'description' => 'Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'blocker_start', 'label' => 'Blocker start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'blocker_end', 'label' => 'Blocker end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => self::STEP_PERFORM_CHECK,
'label' => 'Perform security check',
'description' => 'Work through the product-specific technical checklist.',
'kind' => self::KIND_TECH_CHECKLIST,
],
[
'key' => 'report',
'label' => 'Create report & notify customer',
'description' => 'Create the report for the customer and notify them.',
'kind' => self::KIND_STEP,
],
];
}
/**
* Keys of steps completed manually (everything except the tech-checklist step).
*
* @return list<string>
*/
public function manualStepKeys(): array
{
$keys = [];
foreach ($this->steps() as $step) {
if ($step['kind'] !== self::KIND_TECH_CHECKLIST) {
$keys[] = $step['key'];
}
}
return $keys;
}
/**
* Structured fields captured on a given step (info textareas, datetimes, ).
*
* @return list<array{key: string, label: string, type: string, required: bool}>
*/
public function stepFields(string $stepKey): array
{
foreach ($this->steps() as $step) {
if ($step['key'] === $stepKey) {
return $step['fields'] ?? [];
}
}
return [];
}
/**
* Keys of a step's fields that must be filled before it can be completed.
* (E.g. "Special notes" and the free-text note stay optional.)
*
* @return list<string>
*/
public function requiredFieldKeys(string $stepKey): array
{
$keys = [];
foreach ($this->stepFields($stepKey) as $field) {
if ($field['required']) {
$keys[] = $field['key'];
}
}
return $keys;
}
/**
* Extract the checkable items from a tech-schema snapshot.
*
* @param array<string, mixed> $techSnapshot
* @return list<array{key: string, label: string}>
*/
public static function techCheckItems(array $techSnapshot): array
{
$items = is_array($techSnapshot['items'] ?? null) ? $techSnapshot['items'] : [];
$checks = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
if (($item['type'] ?? '') === 'check' && trim((string) ($item['key'] ?? '')) !== '') {
$checks[] = ['key' => (string) $item['key'], 'label' => (string) ($item['label'] ?? '')];
}
}
return $checks;
}
/**
* Compute progress + derived status purely from state.
*
* @param array<string, mixed> $processState per-step state keyed by step key
* @param array<string, mixed> $techSnapshot frozen tech-schema snapshot
* @param array<string, mixed> $techState per-item state keyed by item key
* @return array{done: int, total: int, percent: int, manual_done: int, manual_total: int, tech_done: int, tech_total: int, step6_done: bool, status: string}
*/
public function computeProgress(array $processState, array $techSnapshot, array $techState, bool $archived = false): array
{
$manualKeys = $this->manualStepKeys();
$manualTotal = count($manualKeys);
$manualDone = 0;
foreach ($manualKeys as $key) {
if (!empty($processState[$key]['done'])) {
$manualDone++;
}
}
$techItems = self::techCheckItems($techSnapshot);
$techTotal = count($techItems);
$techDone = 0;
foreach ($techItems as $item) {
if (!empty($techState[$item['key']]['done'])) {
$techDone++;
}
}
$step6Done = $techTotal === 0 ? true : ($techDone === $techTotal);
$total = $manualTotal + $techTotal;
$done = $manualDone + $techDone;
$percent = $total > 0 ? (int) floor(($done / $total) * 100) : 0;
if ($archived) {
$status = self::STATUS_ARCHIVED;
} elseif ($total > 0 && $done >= $total) {
$status = self::STATUS_COMPLETED;
} elseif ($done > 0) {
$status = self::STATUS_IN_PROGRESS;
} else {
$status = self::STATUS_OPEN;
}
return [
'done' => $done,
'total' => $total,
'percent' => $percent,
'manual_done' => $manualDone,
'manual_total' => $manualTotal,
'tech_done' => $techDone,
'tech_total' => $techTotal,
'step6_done' => $step6Done,
'status' => $status,
];
}
/**
* Whether every step preceding the final "report" step is complete: all
* manual steps except the report itself are done AND the technical checklist
* is fully ticked. Gates the "Generate PDF customer report" action so the
* report can only be produced once the check has actually been carried out.
*
* @param array<string, mixed> $processState
* @param array<string, mixed> $techSnapshot
* @param array<string, mixed> $techState
*/
public function reportPrerequisitesMet(array $processState, array $techSnapshot, array $techState): bool
{
foreach ($this->manualStepKeys() as $key) {
if ($key === self::STEP_REPORT) {
continue;
}
if (empty($processState[$key]['done'])) {
return false;
}
}
return $this->computeProgress($processState, $techSnapshot, $techState)['step6_done'];
}
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_OPEN => 'Open',
self::STATUS_IN_PROGRESS => 'In progress',
self::STATUS_COMPLETED => 'Completed',
self::STATUS_ARCHIVED => 'Archived',
default => $status,
};
}
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_COMPLETED => 'success',
default => 'neutral',
};
}
}