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:
+64
-17
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user