Files
api/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php
T

355 lines
13 KiB
PHP

<?php
app_require('classes/department_outside_hours_statistics_service.php');
app_require('routes/departmentDailyReportsRoute.php');
use classes\department_outside_hours_statistics_service;
use routes\departmentDailyReportsRoute;
function department_daily_reports_route_invoke_private(object $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass(departmentDailyReportsRoute::class);
$target = $reflection->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 $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;
}
}
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'][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('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('/superuser/departments/{id}/overview');
expect($routeContent)->toContain('superuser_fetch_department');
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('public function getBookingSummaryForDepartments');
});