diff --git a/openapi.yaml b/openapi.yaml index d26cafc9..f4c2a441 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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] diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index 88337159..793cdf4c 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -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 diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php index 54e39f4e..8b1a38b1 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php @@ -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); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index 4197e58c..a13c203c 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -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']); +});