TenantAtlas/app/Support/Inventory/InventoryCoverage.php
ahmido f52d52540c feat: implement inventory coverage truth (#208)
## Summary
- implement Spec 177 inventory coverage truth across resolver, badges, KPIs, coverage page, and operation run detail surfaces
- add repo-native spec artifacts for the feature under `specs/177-inventory-coverage-truth`
- add unit, feature, and browser coverage for truth derivation, continuity, and inventory item filter/pagination smoke paths

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused Spec 177 browser smoke file passed with 2 tests / 57 assertions
- extended inventory-focused test pack passed with 52 tests / 434 assertions

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #208
2026-04-05 12:35:20 +00:00

251 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Inventory;
final readonly class InventoryCoverage
{
public const string StatusSucceeded = 'succeeded';
public const string StatusFailed = 'failed';
public const string StatusSkipped = 'skipped';
/**
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $policyTypes
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $foundationTypes
*/
public function __construct(
public array $policyTypes,
public array $foundationTypes,
) {}
public static function fromContext(mixed $context): ?self
{
if (! is_array($context)) {
return null;
}
$inventory = $context['inventory'] ?? null;
if (! is_array($inventory)) {
return null;
}
$coverage = $inventory['coverage'] ?? null;
if (! is_array($coverage)) {
return null;
}
$policyTypes = self::normalizeCoverageMap($coverage['policy_types'] ?? null);
$foundationTypes = self::normalizeCoverageMap($coverage['foundation_types'] ?? null);
if ($policyTypes === [] && $foundationTypes === []) {
return null;
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function coveredTypes(): array
{
$covered = [];
foreach (array_merge($this->policyTypes, $this->foundationTypes) as $type => $meta) {
if (($meta['status'] ?? null) === self::StatusSucceeded) {
$covered[] = $type;
}
}
sort($covered, SORT_STRING);
return array_values(array_unique($covered));
}
/**
* @return array<string, array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }>
*/
public function rows(): array
{
$rows = [];
foreach ($this->policyTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'policy',
'type' => $type,
]);
}
foreach ($this->foundationTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'foundation',
'type' => $type,
]);
}
ksort($rows);
return $rows;
}
/**
* @return array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }|null
*/
public function row(string $type): ?array
{
return $this->rows()[$type] ?? null;
}
/**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
*
* @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
* @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/
public static function buildPayload(array $statusByType, array $foundationTypes): array
{
$foundationTypes = array_values(array_unique(array_filter($foundationTypes, fn (mixed $type): bool => is_string($type) && $type !== '')));
$foundationLookup = array_fill_keys($foundationTypes, true);
$policy = [];
$foundations = [];
foreach ($statusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$row = self::normalizeBuildRow($status);
if ($row === null) {
continue;
}
if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row;
continue;
}
$policy[$type] = $row;
}
ksort($policy);
ksort($foundations);
return [
'policy_types' => $policy,
'foundation_types' => $foundations,
];
}
/**
* @return array{status: string, item_count?: int, error_code?: string|null}|null
*/
private static function normalizeBuildRow(mixed $value): ?array
{
if (is_string($value)) {
$status = self::normalizeStatus($value);
return $status === null ? null : ['status' => $status];
}
if (! is_array($value)) {
return null;
}
$status = self::normalizeStatus($value['status'] ?? null);
if ($status === null) {
return null;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $value) && is_int($value['item_count'])) {
$row['item_count'] = $value['item_count'];
}
if (array_key_exists('error_code', $value) && (is_string($value['error_code']) || $value['error_code'] === null)) {
$row['error_code'] = $value['error_code'];
}
return $row;
}
private static function normalizeStatus(mixed $status): ?string
{
if (! is_string($status)) {
return null;
}
return match ($status) {
self::StatusSucceeded, self::StatusFailed, self::StatusSkipped => $status,
default => null,
};
}
/**
* @return array<string, array{status: string, item_count?: int, error_code?: string|null}>
*/
private static function normalizeCoverageMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $type => $meta) {
if (! is_string($type) || $type === '') {
continue;
}
if (! is_array($meta)) {
continue;
}
$status = self::normalizeStatus($meta['status'] ?? null);
if ($status === null) {
continue;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $meta) && is_int($meta['item_count'])) {
$row['item_count'] = $meta['item_count'];
}
if (array_key_exists('error_code', $meta) && (is_string($meta['error_code']) || $meta['error_code'] === null)) {
$row['error_code'] = $meta['error_code'];
}
$normalized[$type] = $row;
}
ksort($normalized);
return $normalized;
}
}