397 lines
15 KiB
PHP
397 lines
15 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 $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('public function getBookingSummaryForDepartments');
|
|
});
|