Enhance department weather timeline API: support multiple IDs, add timeline date override, improve ID parsing, update schema examples, and expand related tests.

This commit is contained in:
Jeppe Bundgaard
2026-03-24 13:06:40 +01:00
parent c714af6c9e
commit 228efb74fb
4 changed files with 312 additions and 64 deletions
+64 -17
View File
@@ -3268,7 +3268,7 @@ paths:
parameters:
- name: id
in: query
required: false
required: true
schema:
type: integer
- $ref: '#/components/parameters/PageParam'
@@ -7798,31 +7798,63 @@ paths:
tags:
- Departments
summary: Get department weather timeline
description: Returns hourly weather, washes, hours and productivity status for a department
description: Returns 48 hourly entries with weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date` (`YYYY-MM-DD`) to override the anchor day and get start of previous day to end of selected day.
operationId: getDepartmentWeatherTimeline
parameters:
- name: id
in: query
required: true
required: false
schema:
type: integer
minimum: 1
description: Department ID
type: array
items:
type: integer
minimum: 1
minItems: 1
uniqueItems: true
style: form
explode: true
description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`).
- name: ids
in: query
required: false
schema:
type: string
example: '1,2,3'
description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided.
- name: date
in: query
required: false
schema:
type: string
format: date
example: '2026-03-24'
description: Optional date override (`YYYY-MM-DD`). Range becomes start of previous day to end of this date.
responses:
'200':
description: Department weather timeline retrieved successfully
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SuccessResponse'
- type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DepartmentWeatherTimelineEntry'
$ref: '#/components/schemas/DepartmentWeatherTimelineResponse'
example:
success: true
meta: []
includes: []
data:
- date: '2026-03-23'
time: '00:00'
current: false
weather: mostly_cloudy
washes: 1
hours: 2.0
status: degraded
- date: '2026-03-24'
time: '13:00'
current: true
weather: rain
washes: 0
hours: 2.5
status: unhealthy
/modules/entra/users:
get:
@@ -10424,9 +10456,19 @@ components:
DepartmentWeatherTimelineEntry:
type: object
properties:
date:
type: string
format: date
description: Calendar date for the hourly slot (`YYYY-MM-DD`).
example: '2026-03-24'
time:
type: string
description: Hour label for the slot in 24-hour format (`HH:00`).
example: '01:00'
current:
type: boolean
description: True when this slot matches the current server hour.
example: false
weather:
$ref: '#/components/schemas/DepartmentWeatherCondition'
washes:
@@ -10434,12 +10476,14 @@ components:
minimum: 0
example: 0
hours:
type: integer
type: number
format: float
minimum: 0
example: 10
example: 2.5
description: Sum of Workfeed employee-hours in the department for this exact hour slot
status:
$ref: '#/components/schemas/DepartmentWeatherStatus'
required: [time, weather, washes, hours, status]
required: [date, time, current, weather, washes, hours, status]
WeatherApiObjectResponse:
allOf:
@@ -10607,6 +10651,9 @@ components:
properties:
data:
type: array
minItems: 48
maxItems: 48
description: 48 contiguous hourly slots from start of previous day (`00:00`) to end of anchor day (`23:00`). Anchor day is today unless `date` is provided.
items:
$ref: '#/components/schemas/DepartmentWeatherTimelineEntry'
required: [data]
@@ -89,34 +89,160 @@ class moduleWeatherAPIRoute
return;
}
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireDepartmentAccess((int)self::getParameter('id'));
$department_ids = self::parseDepartmentIdsFromRequest();
foreach ($department_ids as $department_id) {
self::requireDepartmentAccess((string)$department_id);
}
$department = (new departments_o())->select((int)self::getParameter('id'));
$lat = (float)$department->latitude->value();
$lon = (float)$department->longitude->value();
if ($lat === 0.0 && $lon === 0.0) {
$response->error('Department does not have GPS coordinates configured', 400);
$selected_date = self::parseSelectedTimelineDateFromRequest();
$departments = self::loadDepartmentsByIds($department_ids);
$coordinates = self::resolveWeatherCoordinates($departments);
if ($coordinates === null) {
$response->error('Selected department(s) do not have GPS coordinates configured', 400);
return;
}
$weather = (new weatherapi())->forecast($lat . ',' . $lon, 2);
$timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $department, $weather);
$weather = (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], 2);
$timeline = self::buildDepartmentWeatherTimeline($department_ids, $departments, $weather, $selected_date);
(new logs_o())->add('modules_weatherapi', (int)self::getParameter('id'), 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
$department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
$response->success($timeline, 200);
}, [
'departments_weather_get' => 'Get department weather timeline with washes and productivity status',
'department_access_:id' => 'Access weather timeline for a specific department',
'department_access_:id' => 'Access weather timeline for one or more specific departments',
]);
}
private function parseDepartmentIdsFromRequest(): array
{
global $response;
$parameters = self::getParametersAsArray();
$raw_ids = [];
if (array_key_exists('id', $parameters)) {
$raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['id']));
}
if (array_key_exists('ids', $parameters)) {
$raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['ids']));
}
if ($raw_ids === []) {
$response->error('Missing required parameters: id', 400);
}
$department_ids = [];
foreach ($raw_ids as $raw_id) {
if (is_array($raw_id) || is_object($raw_id)) {
$response->error('Invalid department id value', 400);
}
$value = trim((string)$raw_id);
if ($value === '') {
continue;
}
if (!preg_match('/^\d+$/', $value)) {
$response->error('Invalid department id: ' . $value, 400);
}
$department_id = (int)$value;
if ($department_id < 1) {
$response->error('Parameter must be at least 1', 400);
}
$department_ids[] = $department_id;
}
$department_ids = array_values(array_unique($department_ids));
if ($department_ids === []) {
$response->error('Missing required parameters: id', 400);
}
return $department_ids;
}
private function normalizeDepartmentIdInput(mixed $value): array
{
if (is_array($value)) {
$result = [];
foreach ($value as $item) {
$result = array_merge($result, self::normalizeDepartmentIdInput($item));
}
return $result;
}
if (is_string($value) && str_contains($value, ',')) {
$parts = array_map('trim', explode(',', $value));
return array_values(array_filter($parts, static function (string $part): bool {
return $part !== '';
}));
}
return [$value];
}
private function parseSelectedTimelineDateFromRequest(): ?string
{
$parameters = self::getParametersAsArray();
if (!array_key_exists('date', $parameters)) {
return null;
}
$date = self::getParameter('date');
if (!is_string($date)) {
global $response;
$response->error('Invalid type. Expected: string Got: ' . gettype($date), 400);
}
$selected_date = trim((string)$date);
self::requireDateFormat($selected_date, self::FORMAT_DATE());
return $selected_date;
}
private function loadDepartmentsByIds(array $department_ids): array
{
$departments = [];
foreach ($department_ids as $department_id) {
$departments[] = (new departments_o())->select((int)$department_id);
}
return $departments;
}
private function resolveWeatherCoordinates(array $departments): ?array
{
$lat_sum = 0.0;
$lon_sum = 0.0;
$count = 0;
foreach ($departments as $department) {
$lat = (float)$department->latitude->value();
$lon = (float)$department->longitude->value();
if ($lat === 0.0 && $lon === 0.0) {
continue;
}
$lat_sum += $lat;
$lon_sum += $lon;
$count++;
}
if ($count === 0) {
return null;
}
return [
'lat' => $lat_sum / $count,
'lon' => $lon_sum / $count,
];
}
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(int $department_id, departments_o $department, object $forecast): array
private function buildDepartmentWeatherTimeline(array $department_ids, array $departments, object $forecast, ?string $selected_date = null): array
{
$hourly_weather = [];
foreach (($forecast->forecast->forecastday ?? []) as $day) {
@@ -126,20 +252,20 @@ class moduleWeatherAPIRoute
}
}
$timeline_range = self::getDepartmentWeatherTimelineRange();
$timeline_range = self::getDepartmentWeatherTimelineRange($selected_date);
$timeline_start = $timeline_range['start'];
$timeline_end_exclusive = $timeline_range['endExclusive'];
$entries = [];
$slot = clone $timeline_start;
$workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($department, $timeline_start, $timeline_end_exclusive);
$workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($departments, $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';
$hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0);
$washes = self::countWashesForHour($department_id, $slot);
$washes = self::countWashesForHour($department_ids, $slot);
$entries[] = [
'date' => $slot->format('Y-m-d'),
'time' => $slot->format('H:00'),
@@ -174,12 +300,12 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function loadWorkfeedDepartmentHoursBySlot(departments_o $department, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
try {
$workfeed = new workfeed();
$workfeed_department_id = self::resolveWorkfeedDepartmentId($department, $workfeed);
if ($workfeed_department_id === null) {
$workfeed_department_ids = self::resolveWorkfeedDepartmentIds($departments, $workfeed);
if ($workfeed_department_ids === []) {
return [];
}
@@ -198,7 +324,7 @@ class moduleWeatherAPIRoute
$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);
$hours_by_slot[$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_ids, $slot);
$slot->add(new DateInterval('PT1H'));
}
@@ -208,12 +334,13 @@ class moduleWeatherAPIRoute
}
}
private function getDepartmentWeatherTimelineRange(): array
private function getDepartmentWeatherTimelineRange(?string $selected_date = null): array
{
$start = new DateTime(date('Y-m-d 00:00:00'));
$anchor_date = $selected_date ?? date('Y-m-d');
$start = new DateTime($anchor_date . ' 00:00:00');
$start->sub(new DateInterval('P1D'));
$end_exclusive = new DateTime(date('Y-m-d 00:00:00'));
$end_exclusive = new DateTime($anchor_date . ' 00:00:00');
$end_exclusive->add(new DateInterval('P1D'));
return [
@@ -222,24 +349,37 @@ class moduleWeatherAPIRoute
];
}
private function resolveWorkfeedDepartmentId(departments_o $department, workfeed $workfeed): ?string
private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array
{
$configured_id = self::getConfiguredWorkfeedDepartmentId($department);
if ($configured_id !== null) {
return $configured_id;
$resolved_ids = [];
$workfeed_departments = null;
foreach ($departments as $department) {
$configured_id = self::getConfiguredWorkfeedDepartmentId($department);
if ($configured_id !== null) {
$resolved_ids[] = $configured_id;
continue;
}
if ($workfeed_departments === null) {
$workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments());
}
if ($workfeed_departments === []) {
continue;
}
$department_name = trim((string)$department->name->value());
if ($department_name === '') {
continue;
}
$matched_id = self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments);
if ($matched_id !== null) {
$resolved_ids[] = $matched_id;
}
}
$department_name = trim((string)$department->name->value());
if ($department_name === '') {
return null;
}
$workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments());
if ($workfeed_departments === []) {
return null;
}
return self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments);
return array_values(array_unique($resolved_ids));
}
private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string
@@ -359,8 +499,20 @@ class moduleWeatherAPIRoute
}
}
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string $workfeed_department_id, DateTime $slot_start): float
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float
{
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
$department_id_lookup = [];
foreach ($department_id_values as $department_id_value) {
$normalized = trim((string)$department_id_value);
if ($normalized !== '') {
$department_id_lookup[$normalized] = true;
}
}
if ($department_id_lookup === []) {
return 0.0;
}
$slot_end = clone $slot_start;
$slot_end->add(new DateInterval('PT1H'));
$slot_start_ts = $slot_start->getTimestamp();
@@ -369,7 +521,7 @@ class moduleWeatherAPIRoute
$hours = 0.0;
foreach ($shifts as $shift) {
$shift_department_id = self::extractWorkfeedDepartmentId($shift);
if ($shift_department_id === null || $shift_department_id !== $workfeed_department_id) {
if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) {
continue;
}
@@ -399,17 +551,23 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function countWashesForHour(int $department_id, DateTime $hour_start): int
private function countWashesForHour(array $department_ids, DateTime $hour_start): int
{
$start = clone $hour_start;
$end = clone $hour_start;
$end->add(new DateInterval('PT59M59S'));
return (new orders_o())->countWashesInDateRange(
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
$department_id
);
$orders = new orders_o();
$total = 0;
foreach ($department_ids as $department_id) {
$total += $orders->countWashesInDateRange(
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
(int)$department_id
);
}
return $total;
}
private function mapWeatherCondition(int $code, string $text): string
@@ -36,3 +36,13 @@ it('builds a timeline range from start of yesterday to end of today', function (
expect($hourCount)->toBe(48);
});
it('builds a timeline range relative to an explicit selected date override', function (): void {
$route = new moduleWeatherAPIRoute();
$range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange', ['2026-03-10']);
expect($range['start']->format('Y-m-d H:i:s'))->toBe('2026-03-09 00:00:00');
expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe('2026-03-11 00:00:00');
$hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600);
expect($hourCount)->toBe(48);
});
@@ -86,3 +86,36 @@ it('normalizes wrapped workfeed collections from common response keys', function
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',
'start' => '2026-03-24T13:00:00+00:00',
'end' => '2026-03-24T14:00:00+00:00',
],
(object)[
'departmentID' => 'dep_2',
'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', 'dep_2'], $slot]);
expect($hours)->toBe(2.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']);
});