## Summary - add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories - persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract - add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering ## Validation - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` ## Notes - verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape - excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #193
662 lines
25 KiB
PHP
662 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\OperationRun;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class BaselineCompareEvidenceGapDetails
|
|
{
|
|
public static function fromOperationRun(?OperationRun $run): array
|
|
{
|
|
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
|
return self::empty();
|
|
}
|
|
|
|
return self::fromContext($run->context);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public static function fromContext(array $context): array
|
|
{
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
|
|
if (! is_array($baselineCompare)) {
|
|
return self::empty();
|
|
}
|
|
|
|
return self::fromBaselineCompare($baselineCompare);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineCompare
|
|
*/
|
|
public static function fromBaselineCompare(array $baselineCompare): array
|
|
{
|
|
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
|
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
|
|
|
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
|
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
|
|
|
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
|
|
if (! array_key_exists($reasonCode, $byReason)) {
|
|
$byReason[$reasonCode] = count($subjects);
|
|
}
|
|
}
|
|
|
|
$count = self::normalizeTotalCount(
|
|
$evidenceGaps['count'] ?? null,
|
|
$byReason,
|
|
$normalizedSubjects['subjects'],
|
|
);
|
|
$detailState = self::detailState($count, $normalizedSubjects);
|
|
$buckets = [];
|
|
|
|
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
|
|
$rows = $detailState === 'structured_details_recorded'
|
|
? array_map(
|
|
static fn (array $subject): array => self::projectSubjectRow($subject),
|
|
$normalizedSubjects['subjects'][$reasonCode] ?? [],
|
|
)
|
|
: [];
|
|
$reasonCount = $byReason[$reasonCode] ?? count($rows);
|
|
|
|
if ($reasonCount <= 0 && $rows === []) {
|
|
continue;
|
|
}
|
|
|
|
$recordedCount = count($rows);
|
|
$structuralCount = count(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
|
|
));
|
|
$transientCount = count(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
|
|
));
|
|
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
|
|
|
|
$searchText = trim(implode(' ', array_filter([
|
|
Str::lower($reasonCode),
|
|
Str::lower(self::reasonLabel($reasonCode)),
|
|
...array_map(
|
|
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
|
$rows,
|
|
),
|
|
])));
|
|
|
|
$buckets[] = [
|
|
'reason_code' => $reasonCode,
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'count' => $reasonCount,
|
|
'recorded_count' => $recordedCount,
|
|
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
|
'structural_count' => $structuralCount,
|
|
'operational_count' => $operationalCount,
|
|
'transient_count' => $transientCount,
|
|
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
|
|
'search_text' => $searchText,
|
|
'rows' => $rows,
|
|
];
|
|
}
|
|
|
|
$recordedSubjectsTotal = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$structuralCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$operationalCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$transientCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$legacyMode = $detailState === 'legacy_broad_reason';
|
|
|
|
return [
|
|
'summary' => [
|
|
'count' => $count,
|
|
'by_reason' => $byReason,
|
|
'detail_state' => $detailState,
|
|
'recorded_subjects_total' => $recordedSubjectsTotal,
|
|
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
|
'structural_count' => $structuralCount,
|
|
'operational_count' => $operationalCount,
|
|
'transient_count' => $transientCount,
|
|
'legacy_mode' => $legacyMode,
|
|
'requires_regeneration' => $legacyMode,
|
|
],
|
|
'buckets' => $buckets,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineCompare
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function diagnosticsPayload(array $baselineCompare): array
|
|
{
|
|
return array_filter([
|
|
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
|
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
|
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
|
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
|
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
|
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
|
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
public static function reasonLabel(string $reason): string
|
|
{
|
|
$reason = trim($reason);
|
|
|
|
return match ($reason) {
|
|
'ambiguous_match' => 'Ambiguous inventory match',
|
|
'policy_record_missing' => 'Policy record missing',
|
|
'inventory_record_missing' => 'Inventory record missing',
|
|
'foundation_not_policy_backed' => 'Foundation not policy-backed',
|
|
'invalid_subject' => 'Invalid subject',
|
|
'duplicate_subject' => 'Duplicate subject',
|
|
'capture_failed' => 'Evidence capture failed',
|
|
'retryable_capture_failure' => 'Retryable evidence capture failure',
|
|
'budget_exhausted' => 'Capture budget exhausted',
|
|
'throttled' => 'Graph throttled',
|
|
'invalid_support_config' => 'Invalid support configuration',
|
|
'missing_current' => 'Missing current evidence',
|
|
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
|
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
|
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
|
'rollout_disabled' => 'Rollout disabled',
|
|
'policy_not_found' => 'Legacy policy not found',
|
|
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
|
};
|
|
}
|
|
|
|
public static function subjectClassLabel(string $subjectClass): string
|
|
{
|
|
return match (trim($subjectClass)) {
|
|
SubjectClass::PolicyBacked->value => 'Policy-backed',
|
|
SubjectClass::InventoryBacked->value => 'Inventory-backed',
|
|
SubjectClass::FoundationBacked->value => 'Foundation-backed',
|
|
default => 'Derived',
|
|
};
|
|
}
|
|
|
|
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
|
|
{
|
|
return match (trim($resolutionOutcome)) {
|
|
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
|
|
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
|
|
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
|
|
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
|
|
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
|
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
|
|
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
|
|
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
|
|
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
|
|
ResolutionOutcome::Throttled->value => 'Graph throttled',
|
|
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
|
|
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
|
|
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
|
|
};
|
|
}
|
|
|
|
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
|
|
{
|
|
return match (trim($operatorActionCategory)) {
|
|
OperatorActionCategory::Retry->value => 'Retry',
|
|
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
|
|
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
|
|
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
|
|
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
|
|
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
|
|
default => 'No action',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
|
*/
|
|
public static function topReasons(array $byReason, int $limit = 5): array
|
|
{
|
|
$normalized = self::normalizeCounts($byReason);
|
|
arsort($normalized);
|
|
|
|
return array_map(
|
|
static fn (string $reason, int $count): array => [
|
|
'reason_code' => $reason,
|
|
'reason_label' => self::reasonLabel($reason),
|
|
'count' => $count,
|
|
],
|
|
array_slice(array_keys($normalized), 0, $limit),
|
|
array_slice(array_values($normalized), 0, $limit),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $buckets
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function tableRows(array $buckets): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach ($buckets as $bucket) {
|
|
if (! is_array($bucket)) {
|
|
continue;
|
|
}
|
|
|
|
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
|
|
|
foreach ($bucketRows as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
|
|
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
|
|
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
|
|
|
|
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
|
|
'reason_code' => $reasonCode,
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => $subjectClass,
|
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
|
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
|
|
'resolution_outcome' => $resolutionOutcome,
|
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
|
'operator_action_category' => $operatorActionCategory,
|
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
|
'structural' => (bool) ($row['structural'] ?? false),
|
|
'retryable' => (bool) ($row['retryable'] ?? false),
|
|
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function reasonFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['reason_code'] => (string) $row['reason_label'],
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function policyTypeFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->pluck('policy_type')
|
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->mapWithKeys(fn (string $value): array => [$value => $value])
|
|
->sortKeysUsing('strnatcasecmp')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function subjectClassFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function actionCategoryFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
private static function empty(): array
|
|
{
|
|
return [
|
|
'summary' => [
|
|
'count' => 0,
|
|
'by_reason' => [],
|
|
'detail_state' => 'no_gaps',
|
|
'recorded_subjects_total' => 0,
|
|
'missing_detail_count' => 0,
|
|
'structural_count' => 0,
|
|
'operational_count' => 0,
|
|
'transient_count' => 0,
|
|
'legacy_mode' => false,
|
|
'requires_regeneration' => false,
|
|
],
|
|
'buckets' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private static function normalizeCounts(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($value as $reason => $count) {
|
|
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
|
continue;
|
|
}
|
|
|
|
$intCount = (int) $count;
|
|
|
|
if ($intCount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[trim($reason)] = $intCount;
|
|
}
|
|
|
|
arsort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* subjects: array<string, list<array<string, mixed>>>,
|
|
* legacy_mode: bool
|
|
* }
|
|
*/
|
|
private static function normalizeSubjects(mixed $value): array
|
|
{
|
|
if ($value === null) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => false,
|
|
];
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
if (! array_is_list($value)) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
$subjects = [];
|
|
|
|
foreach ($value as $item) {
|
|
$normalized = self::normalizeStructuredSubject($item);
|
|
|
|
if ($normalized === null) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
$subjects[$normalized['reason_code']][] = $normalized;
|
|
}
|
|
|
|
foreach ($subjects as &$bucket) {
|
|
usort($bucket, static function (array $left, array $right): int {
|
|
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
|
|
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
|
|
});
|
|
}
|
|
unset($bucket);
|
|
|
|
ksort($subjects);
|
|
|
|
return [
|
|
'subjects' => $subjects,
|
|
'legacy_mode' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function normalizeStructuredSubject(mixed $value): ?array
|
|
{
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
$policyType = self::stringOrNull($value['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
|
|
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
|
|
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
|
|
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
|
|
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
|
|
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
|
|
|
|
if ($policyType === null
|
|
|| $subjectKey === null
|
|
|| $subjectClass === null
|
|
|| $resolutionPath === null
|
|
|| $resolutionOutcome === null
|
|
|| $reasonCode === null
|
|
|| $operatorActionCategory === null) {
|
|
return null;
|
|
}
|
|
|
|
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|
|
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|
|
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|
|
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
|
|
return null;
|
|
}
|
|
|
|
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
|
|
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
|
|
|
|
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
|
|
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
|
|
|
|
return [
|
|
'policy_type' => $policyType,
|
|
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => $subjectClass,
|
|
'resolution_path' => $resolutionPath,
|
|
'resolution_outcome' => $resolutionOutcome,
|
|
'reason_code' => $reasonCode,
|
|
'operator_action_category' => $operatorActionCategory,
|
|
'structural' => self::boolOrFalse($value['structural'] ?? null),
|
|
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
|
|
'source_model_expected' => $sourceModelExpected,
|
|
'source_model_found' => $sourceModelFound,
|
|
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @param array<string, list<array<string, mixed>>> $subjects
|
|
* @return list<string>
|
|
*/
|
|
private static function orderedReasons(array $byReason, array $subjects): array
|
|
{
|
|
$reasons = array_keys($byReason);
|
|
|
|
foreach (array_keys($subjects) as $reason) {
|
|
if (! in_array($reason, $reasons, true)) {
|
|
$reasons[] = $reason;
|
|
}
|
|
}
|
|
|
|
return $reasons;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @param array<string, list<array<string, mixed>>> $subjects
|
|
*/
|
|
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
|
{
|
|
if (is_numeric($count)) {
|
|
$intCount = (int) $count;
|
|
|
|
if ($intCount >= 0) {
|
|
return $intCount;
|
|
}
|
|
}
|
|
|
|
$byReasonCount = array_sum($byReason);
|
|
|
|
if ($byReasonCount > 0) {
|
|
return $byReasonCount;
|
|
}
|
|
|
|
return array_sum(array_map(
|
|
static fn (array $rows): int => count($rows),
|
|
$subjects,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
|
|
*/
|
|
private static function detailState(int $count, array $subjects): string
|
|
{
|
|
if ($count <= 0) {
|
|
return 'no_gaps';
|
|
}
|
|
|
|
if ($subjects['legacy_mode']) {
|
|
return 'legacy_broad_reason';
|
|
}
|
|
|
|
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
|
|
}
|
|
|
|
private static function bucketDetailState(string $detailState, int $recordedCount): string
|
|
{
|
|
if ($detailState === 'legacy_broad_reason') {
|
|
return 'legacy_broad_reason';
|
|
}
|
|
|
|
if ($recordedCount > 0) {
|
|
return 'structured_details_recorded';
|
|
}
|
|
|
|
return 'details_not_recorded';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function projectSubjectRow(array $subject): array
|
|
{
|
|
$reasonCode = (string) $subject['reason_code'];
|
|
$subjectClass = (string) $subject['subject_class'];
|
|
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
|
$operatorActionCategory = (string) $subject['operator_action_category'];
|
|
|
|
return array_merge($subject, [
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
|
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
|
$reasonCode,
|
|
self::reasonLabel($reasonCode),
|
|
(string) ($subject['policy_type'] ?? ''),
|
|
(string) ($subject['subject_key'] ?? ''),
|
|
$subjectClass,
|
|
self::subjectClassLabel($subjectClass),
|
|
(string) ($subject['resolution_path'] ?? ''),
|
|
$resolutionOutcome,
|
|
self::resolutionOutcomeLabel($resolutionOutcome),
|
|
$operatorActionCategory,
|
|
self::operatorActionCategoryLabel($operatorActionCategory),
|
|
(string) ($subject['subject_external_id'] ?? ''),
|
|
])))),
|
|
]);
|
|
}
|
|
|
|
private static function stringOrNull(mixed $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim($value);
|
|
|
|
return $trimmed !== '' ? $trimmed : null;
|
|
}
|
|
|
|
private static function intOrNull(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
|
|
private static function boolOrFalse(mixed $value): bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_int($value) || is_float($value) || is_string($value)) {
|
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|