568 lines
18 KiB
PHP
568 lines
18 KiB
PHP
<?php
|
|
|
|
app_require('routes/moduleWeatherAPIRoute.php');
|
|
|
|
use classes\workfeed;
|
|
use classes\redis;
|
|
use routes\moduleWeatherAPIRoute;
|
|
|
|
function weather_route_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
|
|
{
|
|
$reflection = new ReflectionClass($route);
|
|
$target = $reflection->getMethod($method);
|
|
$target->setAccessible(true);
|
|
|
|
return $target->invokeArgs($route, $args);
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
$_SERVER['REQUEST_URI'] = '/departments/weather';
|
|
});
|
|
|
|
it('calculates workfeed employee hours for the hour slot based on overlap', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:30:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T15:00:00+00:00'],
|
|
'end' => '2026-03-24T15:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_other',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'end' => '2026-03-24T13:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(1.5);
|
|
});
|
|
|
|
it('calculates weather hour contributions grouped per employee for a slot', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'employeeID' => 'emp_1',
|
|
'employeeName' => 'Alice',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'employeeID' => 'emp_1',
|
|
'employeeName' => 'Alice',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T13:30:00+00:00'],
|
|
'end' => '2026-03-24T13:30:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'employeeID' => 'emp_2',
|
|
'employeeName' => 'Bob',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:15:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_other',
|
|
'employeeID' => 'emp_3',
|
|
'employeeName' => 'Ignored',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($details)->toBe([
|
|
[
|
|
'employee_id' => 'emp_1',
|
|
'employee_name' => 'Alice',
|
|
'hours' => 1.5,
|
|
],
|
|
[
|
|
'employee_id' => 'emp_2',
|
|
'employee_name' => 'Bob',
|
|
'hours' => 0.75,
|
|
],
|
|
]);
|
|
});
|
|
|
|
it('calculates weather hour contributions for canonical nested workfeed employee schema', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'employee' => (object)[
|
|
'id' => '005FnnP0fHohybM1f3tx',
|
|
'firstname' => 'Michael',
|
|
'lastname' => 'Stenbæk Stampe',
|
|
],
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($details)->toBe([
|
|
[
|
|
'employee_id' => '005FnnP0fHohybM1f3tx',
|
|
'employee_name' => 'Michael Stenbæk Stampe',
|
|
'hours' => 1.0,
|
|
],
|
|
]);
|
|
});
|
|
|
|
it('extracts weather employee identity from supported shift payload shapes', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$fromTopLevel = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employeeID' => 'emp_1',
|
|
'employeeName' => 'Alice',
|
|
],
|
|
]);
|
|
|
|
$fromNested = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employee' => (object)[
|
|
'id' => 'emp_2',
|
|
'firstName' => 'Bob',
|
|
'lastName' => 'Builder',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$fromCanonicalNestedSchema = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employee' => (object)[
|
|
'id' => 'emp_3',
|
|
'firstname' => 'Charlie',
|
|
'lastname' => 'Day',
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($fromTopLevel)->toBe([
|
|
'id' => 'emp_1',
|
|
'name' => 'Alice',
|
|
]);
|
|
expect($fromNested)->toBe([
|
|
'id' => 'emp_2',
|
|
'name' => 'Bob Builder',
|
|
]);
|
|
expect($fromCanonicalNestedSchema)->toBe([
|
|
'id' => 'emp_3',
|
|
'name' => 'Charlie Day',
|
|
]);
|
|
});
|
|
|
|
it('prefers canonical workfeed employee schema fields over generic employee names', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employee' => (object)[
|
|
'id' => 'emp_4',
|
|
'firstname' => 'Dana',
|
|
'lastname' => 'Scully',
|
|
'name' => 'Wrong Name',
|
|
],
|
|
'employeeName' => 'Also Wrong',
|
|
],
|
|
]);
|
|
|
|
expect($identity)->toBe([
|
|
'id' => 'emp_4',
|
|
'name' => 'Dana Scully',
|
|
]);
|
|
});
|
|
|
|
it('does not use workfeed schema name fields as employee ids', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employee' => (object)[
|
|
'firstname' => 'Fox',
|
|
'lastname' => 'Mulder',
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($identity)->toBe([
|
|
'id' => null,
|
|
'name' => 'Fox Mulder',
|
|
]);
|
|
});
|
|
|
|
it('returns a null employee name when no workfeed employee name is available', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
|
|
(object)[
|
|
'employeeID' => 'emp_99',
|
|
],
|
|
]);
|
|
|
|
expect($identity)->toBe([
|
|
'id' => 'emp_99',
|
|
'name' => null,
|
|
]);
|
|
});
|
|
|
|
it('resolves missing workfeed employee names from the employees endpoint', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$workfeed = new class extends workfeed {
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
public function listEmployees(array $filters = []): array|object
|
|
{
|
|
return [
|
|
(object)[
|
|
'id' => 'emp_42',
|
|
'firstname' => 'Jane',
|
|
'lastname' => 'Doe',
|
|
],
|
|
];
|
|
}
|
|
|
|
public function getEmployee(string $id): object
|
|
{
|
|
return (object)[];
|
|
}
|
|
};
|
|
|
|
$resolvedNames = [];
|
|
$employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[
|
|
[
|
|
'employee_id' => 'emp_42',
|
|
'employee_name' => null,
|
|
'hours' => 1.0,
|
|
],
|
|
], null, &$resolvedNames, $workfeed]);
|
|
|
|
expect($employees)->toBe([[
|
|
'employee_id' => 'emp_42',
|
|
'employee_name' => 'Jane Doe',
|
|
'hours' => 1.0,
|
|
]]);
|
|
});
|
|
|
|
it('filters employee hour rows that cannot be resolved to a workfeed schema name', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$resolvedNames = [];
|
|
$employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[
|
|
[
|
|
'employee_id' => 'emp_missing',
|
|
'employee_name' => null,
|
|
'hours' => 1.0,
|
|
],
|
|
], null, &$resolvedNames, null]);
|
|
|
|
expect($employees)->toBe([]);
|
|
});
|
|
|
|
it('resolves employee display name from cache when shift payload lacks a name', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$cache = new class([
|
|
'emp_42' => 'Jane Doe',
|
|
]) extends redis {
|
|
private array $namesByEmployeeId;
|
|
|
|
public function __construct(array $namesByEmployeeId)
|
|
{
|
|
$this->namesByEmployeeId = $namesByEmployeeId;
|
|
}
|
|
|
|
public function get_workfeed_employee_name(string $employeeId): string|null
|
|
{
|
|
return $this->namesByEmployeeId[$employeeId] ?? null;
|
|
}
|
|
};
|
|
|
|
$resolved_name = weather_route_invoke_private($route, 'fetchCachedWorkfeedEmployeeDisplayName', [$cache, 'emp_42']);
|
|
|
|
expect($resolved_name)->toBe('Jane Doe');
|
|
});
|
|
|
|
it('extracts department id from supported workfeed shift shapes', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$idFromNested = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [
|
|
(object)[
|
|
'department' => (object)['id' => 'dep_nested'],
|
|
'start' => '2026-03-24T12:45:00+00:00',
|
|
'end' => '2026-03-24T13:15:00+00:00',
|
|
],
|
|
]);
|
|
|
|
$idFromCamelCase = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [[
|
|
'departmentId' => 'dep_camel',
|
|
'start' => '2026-03-24T12:45:00+00:00',
|
|
'end' => '2026-03-24T13:15:00+00:00',
|
|
]]);
|
|
|
|
expect($idFromNested)->toBe('dep_nested');
|
|
expect($idFromCamelCase)->toBe('dep_camel');
|
|
});
|
|
|
|
it('normalizes wrapped workfeed collections from common response keys', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$wrapped = (object)[
|
|
'data' => [
|
|
(object)['id' => 'a'],
|
|
(object)['id' => 'b'],
|
|
],
|
|
];
|
|
|
|
$items = weather_route_invoke_private($route, 'normalizeWorkfeedCollection', [$wrapped]);
|
|
|
|
expect($items)->toHaveCount(2);
|
|
expect($items[0]->id ?? null)->toBe('a');
|
|
expect($items[1]->id ?? null)->toBe('b');
|
|
});
|
|
|
|
it('calculates workfeed employee hours across multiple departments for one hour slot', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
(object)[
|
|
'departmentID' => 'dep_2',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, ['dep_1', 'dep_2'], $slot]);
|
|
|
|
expect($hours)->toBe(2.0);
|
|
});
|
|
|
|
it('does not count shifts without punches when checkIn/checkOut are null', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => null,
|
|
'checkOut' => null,
|
|
'start' => '2026-03-24T13:00:00+00:00',
|
|
'end' => '2026-03-24T14:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(0.0);
|
|
});
|
|
|
|
it('counts checked-in shifts without checkOut up to occurredUntil', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
$occurredUntil = new DateTime('2026-03-24T13:40:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:10:00+00:00'],
|
|
'checkOut' => null,
|
|
'end' => '2026-03-24T16:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot, $occurredUntil]);
|
|
|
|
expect($hours)->toBe(0.5);
|
|
});
|
|
|
|
it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-23T18:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'start' => '2026-03-23T10:00:00+00:00',
|
|
'end' => '2026-03-23T18:17:00+00:00',
|
|
'approval' => (object)[
|
|
'originalEnd' => '2026-03-23T18:00:00+00:00',
|
|
],
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(0.28);
|
|
});
|
|
|
|
it('does not count removed approved time when a saved shift end is shortened', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'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',
|
|
],
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(0.0);
|
|
});
|
|
|
|
it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'start' => '2026-03-23T09:00:00+00:00',
|
|
'end' => '2026-03-23T17:00:00+00:00',
|
|
'approval' => null,
|
|
'updateTime' => '2026-03-23T17:15:00+00:00',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(0.25);
|
|
});
|
|
|
|
it('does not treat late unapproved administrative edits as overtime', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'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',
|
|
],
|
|
];
|
|
|
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
|
|
|
expect($hours)->toBe(0.0);
|
|
});
|
|
|
|
it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$currentSlot = new DateTime('2026-03-24T13:00:00+00:00');
|
|
$futureSlot = new DateTime('2026-03-24T14:00:00+00:00');
|
|
$occurredUntil = new DateTime('2026-03-24T13:15:00+00:00');
|
|
|
|
$shifts = [
|
|
(object)[
|
|
'departmentID' => 'dep_1',
|
|
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
|
|
'checkOut' => null,
|
|
'end' => '2026-03-24T15:00:00+00:00',
|
|
],
|
|
];
|
|
|
|
$currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]);
|
|
$futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]);
|
|
|
|
expect($currentHours)->toBe(0.25);
|
|
expect($futureHours)->toBe(0.0);
|
|
});
|
|
|
|
it('normalizes department id input from scalar csv and nested array values', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
|
|
$single = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['7']);
|
|
$csv = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['1, 2,3']);
|
|
$nested = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', [[1, '2,3', [4, '5']]]);
|
|
|
|
expect($single)->toBe(['7']);
|
|
expect($csv)->toBe(['1', '2', '3']);
|
|
expect($nested)->toBe([1, '2', '3', 4, '5']);
|
|
});
|
|
|
|
it('normalizes batched wash count rows into hourly slot totals', function (): void {
|
|
$route = new moduleWeatherAPIRoute();
|
|
$rows = [
|
|
[
|
|
'department_id' => 1,
|
|
'hour_bucket' => '2026-03-24 08:00:00',
|
|
'wash_count' => 3,
|
|
],
|
|
[
|
|
'department_id' => 2,
|
|
'hour_bucket' => '2026-03-24 08:15:00',
|
|
'wash_count' => 2,
|
|
],
|
|
[
|
|
'department_id' => 2,
|
|
'hour_bucket' => '2026-03-24 09:00:00',
|
|
'wash_count' => 4,
|
|
],
|
|
];
|
|
|
|
$counts = weather_route_invoke_private($route, 'normalizeDepartmentWashCountRows', [$rows]);
|
|
|
|
expect($counts)->toBe([
|
|
'2026-03-24 08:00' => 5,
|
|
'2026-03-24 09:00' => 4,
|
|
]);
|
|
});
|
|
|
|
it('wires department weather route to use batched wash aggregation', function (): void {
|
|
$routeContent = (string)file_get_contents(app_path('routes/moduleWeatherAPIRoute.php'));
|
|
$ordersContent = (string)file_get_contents(app_path('objects/orders_o.php'));
|
|
|
|
expect($routeContent)->toContain('loadDepartmentWashCountsBySlot');
|
|
expect($routeContent)->toContain('countWashesByHourForDepartments');
|
|
expect($ordersContent)->toContain('public function countWashesByHourForDepartments');
|
|
expect($ordersContent)->toContain('GROUP BY o.department_id');
|
|
});
|