2026-09-01 18:54:47 +02:00
|
|
|
<?php
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-09-01 21:47:35 +02:00
|
|
|
require_once __DIR__ . '/ReportFilters.php';
|
2026-09-01 18:54:47 +02:00
|
|
|
require_once __DIR__ . '/ReportDataMapper.php';
|
2026-09-01 21:47:35 +02:00
|
|
|
require_once __DIR__ . '/ReportQuery.php';
|
|
|
|
|
require_once __DIR__ . '/ReportAudience.php';
|
2026-09-01 18:54:47 +02:00
|
|
|
|
2026-09-01 21:47:35 +02:00
|
|
|
/** Deterministic hours aggregation grouped by client. */
|
|
|
|
|
final class HoursPerClientReport implements ReportQuery
|
2026-09-01 18:54:47 +02:00
|
|
|
{
|
2026-09-01 21:47:35 +02:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly ?ReportFilters $filters = null,
|
|
|
|
|
private readonly ?ReportDataMapper $mapper = null,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/** @param list<array<string,mixed>> $entries @return list<array<string,mixed>> */
|
|
|
|
|
public function build(array $entries, string $audience = ReportAudience::CLIENT): array
|
|
|
|
|
{
|
|
|
|
|
ReportAudience::validate($audience);
|
|
|
|
|
$filters = $this->filters ?? new ReportFilters();
|
|
|
|
|
$selected = array_values(array_filter($entries, static fn (array $entry): bool => $filters->matches($entry)));
|
|
|
|
|
$rows = $this->aggregate($selected);
|
|
|
|
|
// This report contains no technician or internal-note fields, so the same
|
|
|
|
|
// stable projection is safe for both audiences.
|
|
|
|
|
return $rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param list<array<string,mixed>> $entries @return list<array{client_id:int,client_name:string,hours:float}> */
|
2026-09-01 18:54:47 +02:00
|
|
|
public function aggregate(array $entries): array
|
|
|
|
|
{
|
|
|
|
|
$totals = [];
|
|
|
|
|
foreach ($entries as $entry) {
|
|
|
|
|
$id = (int)($entry['client_id'] ?? 0);
|
|
|
|
|
$key = (string)$id;
|
|
|
|
|
if (!isset($totals[$key])) {
|
2026-09-01 21:47:35 +02:00
|
|
|
$totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0];
|
2026-09-01 18:54:47 +02:00
|
|
|
}
|
|
|
|
|
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
|
|
|
|
|
}
|
|
|
|
|
$rows = array_values($totals);
|
2026-09-01 21:47:35 +02:00
|
|
|
foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2);
|
2026-09-01 18:54:47 +02:00
|
|
|
unset($row);
|
2026-09-01 21:47:35 +02:00
|
|
|
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id']));
|
2026-09-01 18:54:47 +02:00
|
|
|
return $rows;
|
|
|
|
|
}
|
2026-09-01 21:47:35 +02:00
|
|
|
|
|
|
|
|
public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); }
|
2026-09-01 18:54:47 +02:00
|
|
|
}
|