Add unit tests for Workfeed weather timeline range, schema conformance, and employee hours calculations. Enhance Workfeed API with improved department ID resolution, timeline range configuration, and workfeed shift handling. Update route logic to remove opening hours dependency and integrate Workfeed data.

This commit is contained in:
Jeppe Bundgaard
2026-03-24 12:50:01 +01:00
parent 28b571e2e6
commit c714af6c9e
4 changed files with 390 additions and 38 deletions
@@ -6,10 +6,10 @@ use classes\authentication;
use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use DateInterval;
use DateTime;
use Exception;
use objects\department_time_bookings_opening_hours_o;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
@@ -102,11 +102,8 @@ class moduleWeatherAPIRoute
return;
}
$opening_hours = new department_time_bookings_opening_hours_o();
$opening_hours->selectByDepartment((int)self::getParameter('id'));
$weather = (new weatherapi())->forecast($lat . ',' . $lon, 2);
$timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $weather, $opening_hours);
$timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $department, $weather);
(new logs_o())->add('modules_weatherapi', (int)self::getParameter('id'), 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
$response->success($timeline, 200);
@@ -119,7 +116,7 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(int $department_id, object $forecast, department_time_bookings_opening_hours_o $opening_hours): array
private function buildDepartmentWeatherTimeline(int $department_id, departments_o $department, object $forecast): array
{
$hourly_weather = [];
foreach (($forecast->forecast->forecastday ?? []) as $day) {
@@ -129,18 +126,24 @@ class moduleWeatherAPIRoute
}
}
$entries = [];
$slot = new DateTime(date('Y-m-d H:00:00'));
$slot->add(new DateInterval('PT1H'));
$timeline_range = self::getDepartmentWeatherTimelineRange();
$timeline_start = $timeline_range['start'];
$timeline_end_exclusive = $timeline_range['endExclusive'];
for ($i = 0; $i < 24; $i++) {
$entries = [];
$slot = clone $timeline_start;
$workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($department, $timeline_start, $timeline_end_exclusive);
$current_slot_key = (new DateTime(date('Y-m-d H:00:00')))->format('Y-m-d H:00');
while ($slot < $timeline_end_exclusive) {
$slot_key = $slot->format('Y-m-d H:00');
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
$is_open = self::isDepartmentOpenAtHour($opening_hours, $slot);
$hours = $is_open ? self::getDailyOpenHours($opening_hours, $slot) : 0;
$hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0);
$washes = self::countWashesForHour($department_id, $slot);
$entries[] = [
'date' => $slot->format('Y-m-d'),
'time' => $slot->format('H:00'),
'current' => $slot_key === $current_slot_key,
'weather' => $weather,
'washes' => $washes,
'hours' => $hours,
@@ -152,7 +155,7 @@ class moduleWeatherAPIRoute
return $entries;
}
private function calculateStatus(int $washes, int $hours): string
private function calculateStatus(int $washes, float $hours): string
{
if ($hours <= 0) {
return 'unknown';
@@ -171,39 +174,226 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function isDepartmentOpenAtHour(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): bool
private function loadWorkfeedDepartmentHoursBySlot(departments_o $department, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
$weekday = strtolower($date_time->format('l'));
$start = $opening_hours->{"{$weekday}_start"}->value();
$end = $opening_hours->{"{$weekday}_end"}->value();
if ($start === null || $end === null) {
return false;
try {
$workfeed = new workfeed();
$workfeed_department_id = self::resolveWorkfeedDepartmentId($department, $workfeed);
if ($workfeed_department_id === null) {
return [];
}
$query_start = clone $timeline_start;
$query_start->sub(new DateInterval('P1D'));
$shifts_response = $workfeed->listShifts([
'startFrom' => $query_start->format(DateTime::ATOM),
'startTo' => $timeline_end_exclusive->format(DateTime::ATOM),
]);
$shifts = self::normalizeWorkfeedCollection($shifts_response);
if ($shifts === []) {
return [];
}
$hours_by_slot = [];
$slot = clone $timeline_start;
while ($slot < $timeline_end_exclusive) {
$slot_key = $slot->format('Y-m-d H:00');
$hours_by_slot[$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot);
$slot->add(new DateInterval('PT1H'));
}
return $hours_by_slot;
} catch (Exception) {
return [];
}
$current = $date_time->format('H:i');
$start_hour = (new DateTime((string)$start))->format('H:i');
$end_hour = (new DateTime((string)$end))->format('H:i');
return $current >= $start_hour && $current < $end_hour;
}
/**
* @throws Exception
*/
private function getDailyOpenHours(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): int
private function getDepartmentWeatherTimelineRange(): array
{
$weekday = strtolower($date_time->format('l'));
$start = $opening_hours->{"{$weekday}_start"}->value();
$end = $opening_hours->{"{$weekday}_end"}->value();
if ($start === null || $end === null) {
return 0;
$start = new DateTime(date('Y-m-d 00:00:00'));
$start->sub(new DateInterval('P1D'));
$end_exclusive = new DateTime(date('Y-m-d 00:00:00'));
$end_exclusive->add(new DateInterval('P1D'));
return [
'start' => $start,
'endExclusive' => $end_exclusive,
];
}
private function resolveWorkfeedDepartmentId(departments_o $department, workfeed $workfeed): ?string
{
$configured_id = self::getConfiguredWorkfeedDepartmentId($department);
if ($configured_id !== null) {
return $configured_id;
}
$start_dt = new DateTime((string)$start);
$end_dt = new DateTime((string)$end);
$hours = ((int)$end_dt->format('U') - (int)$start_dt->format('U')) / 3600;
$department_name = trim((string)$department->name->value());
if ($department_name === '') {
return null;
}
return max(0, (int)round($hours));
$workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments());
if ($workfeed_departments === []) {
return null;
}
return self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments);
}
private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string
{
$keys = [
'workfeed_department_id',
'workfeedDepartmentId',
'workfeed_departmentID',
'workfeed_department',
];
foreach ($keys as $key) {
$value = $department->variables->getVariable($key);
if (!is_string($value)) {
continue;
}
$trimmed = trim($value);
if ($trimmed !== '') {
return $trimmed;
}
}
return null;
}
private function matchWorkfeedDepartmentIdByName(string $department_name, array $workfeed_departments): ?string
{
$needle = self::normalizeDepartmentName($department_name);
foreach ($workfeed_departments as $entry) {
$record = self::normalizeWorkfeedRecord($entry);
$name = trim((string)($record['name'] ?? ''));
$id = trim((string)($record['id'] ?? ''));
if ($name === '' || $id === '') {
continue;
}
if (self::normalizeDepartmentName($name) === $needle) {
return $id;
}
}
return null;
}
private function normalizeDepartmentName(string $name): string
{
$collapsed = preg_replace('/\s+/', ' ', trim($name));
return strtolower($collapsed ?? trim($name));
}
private function normalizeWorkfeedCollection(array|object $payload): array
{
if (is_array($payload)) {
return $payload;
}
foreach (['data', 'items', 'results', 'shifts', 'departments'] as $key) {
if (!isset($payload->$key)) {
continue;
}
$value = $payload->$key;
if (is_array($value)) {
return $value;
}
if (is_object($value)) {
return array_values(get_object_vars($value));
}
}
return [];
}
private function normalizeWorkfeedRecord(mixed $record): array
{
if (is_array($record)) {
return $record;
}
if (is_object($record)) {
return get_object_vars($record);
}
return [];
}
private function extractWorkfeedDepartmentId(mixed $shift): ?string
{
$record = self::normalizeWorkfeedRecord($shift);
$department_id = $record['departmentID'] ?? $record['departmentId'] ?? null;
if (($department_id === null || $department_id === '') && isset($record['department'])) {
$department = self::normalizeWorkfeedRecord($record['department']);
$department_id = $department['id'] ?? $department['departmentID'] ?? $department['departmentId'] ?? null;
}
if ($department_id === null) {
return null;
}
$normalized = trim((string)$department_id);
return $normalized === '' ? null : $normalized;
}
private function parseDateTimeValue(mixed $value): ?DateTime
{
if (!is_string($value) || trim($value) === '') {
return null;
}
try {
return new DateTime($value);
} catch (Exception) {
return null;
}
}
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string $workfeed_department_id, DateTime $slot_start): float
{
$slot_end = clone $slot_start;
$slot_end->add(new DateInterval('PT1H'));
$slot_start_ts = $slot_start->getTimestamp();
$slot_end_ts = $slot_end->getTimestamp();
$hours = 0.0;
foreach ($shifts as $shift) {
$shift_department_id = self::extractWorkfeedDepartmentId($shift);
if ($shift_department_id === null || $shift_department_id !== $workfeed_department_id) {
continue;
}
$record = self::normalizeWorkfeedRecord($shift);
$shift_start = self::parseDateTimeValue($record['start'] ?? null);
$shift_end = self::parseDateTimeValue($record['end'] ?? null);
if ($shift_start === null || $shift_end === null) {
continue;
}
$shift_start_ts = $shift_start->getTimestamp();
$shift_end_ts = $shift_end->getTimestamp();
if ($shift_end_ts <= $shift_start_ts) {
continue;
}
$overlap_start = max($slot_start_ts, $shift_start_ts);
$overlap_end = min($slot_end_ts, $shift_end_ts);
if ($overlap_end > $overlap_start) {
$hours += ($overlap_end - $overlap_start) / 3600;
}
}
return round($hours, 2);
}
/**
@@ -0,0 +1,38 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
function weather_timeline_range_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('builds a timeline range from start of yesterday to end of today', function (): void {
$route = new moduleWeatherAPIRoute();
$range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange');
$expectedStart = new DateTime(date('Y-m-d 00:00:00'));
$expectedStart->sub(new DateInterval('P1D'));
$expectedEndExclusive = new DateTime(date('Y-m-d 00:00:00'));
$expectedEndExclusive->add(new DateInterval('P1D'));
expect($range['start'] instanceof DateTime)->toBeTrue();
expect($range['endExclusive'] instanceof DateTime)->toBeTrue();
expect($range['start']->format('Y-m-d H:i:s'))->toBe($expectedStart->format('Y-m-d H:i:s'));
expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe($expectedEndExclusive->format('Y-m-d H:i:s'));
$hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600);
expect($hourCount)->toBe(48);
});
@@ -0,0 +1,36 @@
<?php
it('includes a date key in department weather timeline entries', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = (string)file_get_contents($routeFile);
expect($content)->toContain("'date' => \$slot->format('Y-m-d')");
expect($content)->toContain("'current' => \$slot_key === \$current_slot_key");
});
it('documents the date key in the openapi department weather timeline schema', function (): void {
$candidates = [
WD . '/openapi.yaml',
dirname(WD) . '/openapi.yaml',
dirname(WD, 2) . '/openapi.yaml',
];
$openApiFile = null;
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$openApiFile = $candidate;
break;
}
}
if ($openApiFile === null) {
$this->markTestSkipped('openapi.yaml is not mounted in this test container.');
}
$content = (string)file_get_contents($openApiFile);
expect($content)->toContain('DepartmentWeatherTimelineEntry:');
expect($content)->toContain('format: date');
expect($content)->toContain('type: boolean');
expect($content)->toContain('required: [date, time, current, weather, washes, hours, status]');
});
@@ -0,0 +1,88 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
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',
'start' => '2026-03-24T13:00:00+00:00',
'end' => '2026-03-24T14:00:00+00:00',
],
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-24T13:30:00+00:00',
'end' => '2026-03-24T15:00:00+00:00',
],
(object)[
'departmentID' => 'dep_other',
'start' => '2026-03-24T13:00:00+00:00',
'end' => '2026-03-24T14:00:00+00:00',
],
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-24T14: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('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');
});