> $entries @return list> */ 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> $entries @return list */ public function aggregate(array $entries): array { $totals = []; foreach ($entries as $entry) { $id = (int)($entry['client_id'] ?? 0); $key = (string)$id; if (!isset($totals[$key])) { $totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0]; } $totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0)); } $rows = array_values($totals); foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2); unset($row); usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id'])); return $rows; } public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); } }