getMethod($method); $target->setAccessible(true); return $target->invokeArgs($route, $args); } final class FakeDailyReportValue { public function __construct(private readonly mixed $current) { } public function value(): mixed { return $this->current; } } final class FakeDailyReportVariables { public function __construct(private readonly array $values = []) { } public function getVariable(string $key): mixed { return $this->values[$key] ?? null; } } final class FakeDailyReportRepository { public array $transaction_summary = [ 'quantity' => 0, 'products' => 0, 'earnings' => 0, 'washes' => 0, 'water_usage' => 0, ]; public array $booking_summary = [ 'completed' => 0, 'total' => 0, ]; public array $product_overview = []; public array $wash_transactions = []; public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array { return $this->transaction_summary; } public function getBookingSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array { return $this->booking_summary; } public function getProductOverviewForDepartments(string $date, array $department_ids, array $product_ids, string $date_to = null): array { return $this->product_overview; } public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array { return $this->wash_transactions; } } final class FakeDailyReportComplaintsRepository { public int $count = 0; public function countForDepartmentsInRange(array $department_ids, string $date, ?string $date_to = null): int { return $this->count; } } final class FakeOutsideHoursStatisticsService extends department_outside_hours_statistics_service { public array $summary = [ 'total' => 0, 'by_source' => [ 'orders' => 0, 'xlvask' => 0, 'selfserve' => 0, ], 'has_missing_opening_hours' => false, 'missing_department_ids' => [], ]; public function __construct() { } public function getSummary(string $date, array|int|string $department_ids, ?string $date_to = null): array { return [ 'department_ids' => is_array($department_ids) ? array_values(array_map('intval', $department_ids)) : [(int)$department_ids], 'date' => $date, 'date_to' => $date_to ?? $date, ...$this->summary, ]; } } final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyReportsRoute { public object $repository; public object $complaints_repository; public array $opening_hours = []; public array $departments = []; public array $product_targets_by_department = []; public array $workfeed_departments = []; public array $workfeed_shifts = []; public department_outside_hours_statistics_service $outside_hours_service; protected function dailyReportRepository(): object { return $this->repository; } protected function dailyReportComplaintsRepository(): object { return $this->complaints_repository; } protected function fetchOpeningHoursByDepartmentId(array $department_ids): array { return $this->opening_hours; } protected function fetchDepartmentsByIds(array $department_ids): array { return $this->departments; } protected function fetchWorkfeedDepartments(): array { return $this->workfeed_departments; } protected function fetchWorkfeedShifts(\DateTime $query_start, \DateTime $range_end_exclusive): array { return $this->workfeed_shifts; } protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service { return $this->outside_hours_service; } protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array { return $this->product_targets_by_department[$department_id] ?? []; } } function fake_daily_report_department(int $id, string $name, array $variables = []): object { return (object)[ 'id' => $id, 'name' => new FakeDailyReportValue($name), 'variables' => new FakeDailyReportVariables($variables), ]; } beforeEach(function (): void { $_SERVER['REQUEST_URI'] = '/departments/daily-reports/overview'; }); it('builds the overview payload from batched repository data with deterministic tile states', function (): void { $repository = new FakeDailyReportRepository(); $complaints_repository = new FakeDailyReportComplaintsRepository(); $repository->transaction_summary = [ 'quantity' => 12, 'products' => 37, 'earnings' => 4900, 'washes' => 14, 'water_usage' => 56, ]; $repository->booking_summary = [ 'completed' => 9, 'total' => 11, ]; $repository->product_overview = [ 24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14], 25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14], ]; $complaints_repository->count = 4; $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = $repository; $route->complaints_repository = $complaints_repository; $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $route->outside_hours_service->summary = [ 'total' => 1, 'by_source' => [ 'orders' => 0, 'xlvask' => 1, 'selfserve' => 0, ], 'has_missing_opening_hours' => false, 'missing_department_ids' => [], ]; $route->departments = [ fake_daily_report_department(1, 'North', ['workfeed_department_id' => 'dep_1']), fake_daily_report_department(2, 'South', ['workfeed_department_id' => 'dep_2']), ]; $route->workfeed_shifts = [ (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T09:00:00+00:00', 'end' => '2026-03-23T17:30:00+00:00', 'approval' => (object)['originalEnd' => '2026-03-23T17:00:00+00:00'], ], (object)[ 'departmentID' => 'dep_2', 'start' => '2026-03-23T10:00:00+00:00', 'end' => '2026-03-23T18:00:00+00:00', 'approval' => null, 'updateTime' => '2026-03-23T18:15:00+00:00', ], ]; $overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[1, 2], '2026-03-23', '2026-03-23']); expect($overview['department_ids'])->toBe([1, 2]); expect($overview['metrics']['transactions']['value'])->toBe(12); expect($overview['metrics']['bookings']['value'])->toBe(9); expect($overview['metrics']['bookings']['out_of'])->toBe(11); expect($overview['metrics']['night_washes']['state'])->toBe('ready'); expect($overview['metrics']['night_washes']['value'])->toBe(1); expect($overview['metrics']['night_washes']['by_source'])->toBe([ 'orders' => 0, 'xlvask' => 1, 'selfserve' => 0, ]); expect($overview['metrics']['overtime']['state'])->toBe('ready'); expect($overview['metrics']['overtime']['value'])->toBe(0.75); expect($overview['metrics']['complaints']['state'])->toBe('ready'); expect($overview['metrics']['complaints']['value'])->toBe(4); expect(count($overview['products']))->toBe(6); expect($overview['products'][0]['slug'])->toBe('spot-free-lastbil'); expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)'); expect($overview['products'][0]['value'])->toBe(3); expect($overview['products'][0]['target_percentage'])->toBeNull(); expect($overview['products'][0]['target_department_id'])->toBeNull(); expect($overview['products'][1]['title'])->toBe('Fælg flex pr. enhed'); expect($overview['products'][1]['value'])->toBe(2); expect(array_column($overview['products'], 'title'))->toBe([ 'Spot Free (Lastbil)', 'Fælg flex pr. enhed', 'Ekstraordinær pr. 10 min inkl. kemi', 'Højglans - Voksforsegling pr. enhed', 'Undervognsskyl pr. enhed', 'Tillæg for Specialsæbe - DD', ]); }); it('adds product targets to single department overview payloads when requested', function (): void { $repository = new FakeDailyReportRepository(); $repository->product_overview = [ 24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14], 25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14], ]; $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = $repository; $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $route->product_targets_by_department = [ 7 => [ 24 => 75.5, 25 => 0.0, ], ]; $overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[7], '2026-03-23', '2026-03-23', true]); $products_by_id = []; foreach ($overview['products'] as $product) { $products_by_id[$product['product_id']] = $product; } expect($products_by_id[24]['target_percentage'])->toBe(75.5); expect($products_by_id[24]['target_department_id'])->toBe(7); expect($products_by_id[25]['target_percentage'])->toBe(0.0); expect($products_by_id[25]['target_department_id'])->toBe(7); expect($products_by_id[27]['target_percentage'])->toBeNull(); expect($products_by_id[27]['target_department_id'])->toBeNull(); }); it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void { $repository = new FakeDailyReportRepository(); $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = $repository; $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $route->departments = [ fake_daily_report_department(1, 'North', ['workfeed_department_id' => 'dep_1']), fake_daily_report_department(2, 'South'), ]; $overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[1, 2], '2026-03-23', '2026-03-23']); expect($overview['metrics']['overtime']['state'])->toBe('unavailable'); expect($overview['metrics']['overtime']['message'])->toContain('Workfeed'); }); it('normalizes department id input from csv strings and nested values', function (): void { $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = new FakeDailyReportRepository(); $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $normalized = department_daily_reports_route_invoke_private($route, 'normalizeDepartmentIdsParameter', [['1, 2', [3, '4'], 2]]); expect($normalized)->toBe([1, 2, 3, 4]); }); it('limits overtime counting to the selected reporting range', function (): void { $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = new FakeDailyReportRepository(); $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ 'start' => '2026-03-22T20:00:00+00:00', 'end' => '2026-03-23T00:30:00+00:00', 'approval' => (object)['originalEnd' => '2026-03-22T23:45:00+00:00'], ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); expect($hours)->toBe(0.5); }); it('does not count negative approved overtime when a saved end shortens the shift', function (): void { $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = new FakeDailyReportRepository(); $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ 'start' => '2026-03-23T10:00:00+00:00', 'end' => '2026-03-23T17:00:00+00:00', 'approval' => (object)['originalEnd' => '2026-03-23T18:00:00+00:00'], ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); expect($hours)->toBe(0.0); }); it('ignores late unapproved administrative edits when calculating overtime', function (): void { $route = new DepartmentDailyReportsOverviewRouteDouble(); $route->repository = new FakeDailyReportRepository(); $route->complaints_repository = new FakeDailyReportComplaintsRepository(); $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ 'start' => '2026-03-23T09:00:00+00:00', 'end' => '2026-03-23T17:00:00+00:00', 'approval' => null, 'updateTime' => '2026-03-24T02:30:00+00:00', ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); expect($hours)->toBe(0.0); }); it('wires the overview route to batched repository methods and overview path', function (): void { $routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php')); $objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php')); expect($routeContent)->toContain('/departments/daily-reports/overview'); expect($routeContent)->toContain('/departments/daily-reports/product-targets'); expect($routeContent)->toContain('/superuser/departments/{id}/overview'); expect($routeContent)->toContain('superuser_fetch_department'); expect($routeContent)->toContain('set_department_daily_report_product_targets'); expect($routeContent)->toContain('/departments/daily-reports/complaints'); expect($routeContent)->toContain('outsideHoursStatisticsService'); expect($routeContent)->toContain('dailyReportComplaintsRepository'); expect($routeContent)->toContain('/departments/daily-reports/outside-hours-trend'); expect($routeContent)->toContain('getTransactionSummaryForDepartments'); expect($routeContent)->toContain('normalizeDepartmentIdsParameter'); expect($objectContent)->toContain('department_wash_count_service'); expect($objectContent)->toContain('countInDateRange'); expect($objectContent)->toContain('listTransactions'); expect($objectContent)->toContain('public function getBookingSummaryForDepartments'); });