forked from fa/breadcrumb-the-shire
271 lines
10 KiB
PHP
271 lines
10 KiB
PHP
|
|
<?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 2–3h 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 1–2 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',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|