Add unit tests and routes for department weather targets, including GET/PUT endpoints, thresholds validation, and aggregate status handling. Extend OpenAPI spec with schema mappings for department weather targets and machine relay endpoints.

This commit is contained in:
Jeppe Bundgaard
2026-03-25 11:14:25 +01:00
parent 6fdc8d466e
commit 7fc70184df
16 changed files with 1765 additions and 88 deletions
@@ -5,4 +5,6 @@ namespace modules\selfserve\helpers;
enum selfserve_lane_relay
{
case MACHINE; // Relay that controls the machine power
case MACHINE_PROGRAM_PICKER; // Relay that controls the machine program picker
case MACHINE_CLEANER; // Relay that controls the machine cleaner
}
@@ -358,6 +358,8 @@ Purpose: operational lane control and relay management.
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
@@ -15,9 +15,190 @@ use modules\shelly\helpers\shelly_request_body_get_states;
trait selfserve_lane_relay_controller_t
{
/**
* Get current MACHINE relay status from Shelly.
* @return array{relay_id: string, online: bool, on: bool}
* @throws \Exception
*/
public function getMachineRelayStatus(): array
{
return $this->getRelayStatus(selfserve_lane_relay::MACHINE);
}
/**
* Get current MACHINE_PROGRAM_PICKER relay status from Shelly.
* @return array{relay_id: string, online: bool, on: bool}
* @throws \Exception
*/
public function getMachineProgramPickerRelayStatus(): array
{
return $this->getRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
}
/**
* Get current MACHINE_CLEANER relay status from Shelly.
* @return array{relay_id: string, online: bool, on: bool}
* @throws \Exception
*/
public function getMachineCleanerRelayStatus(): array
{
return $this->getRelayStatus(selfserve_lane_relay::MACHINE_CLEANER);
}
/**
* Get current relay status from Shelly for a specific relay type.
* @param selfserve_lane_relay $relay
* @return array{relay_id: string, online: bool, on: bool}
* @throws \Exception
*/
public function getRelayStatus(selfserve_lane_relay $relay): array
{
$relay_id = $this->getRelayId($relay);
$devices = $this->fetchRelaySwitches($relay_id);
if (count($devices) < 1) {
throw new \Exception("No Shelly device state returned for {$relay->name} relay");
}
$device = $devices[0];
return [
'relay_id' => $relay_id,
'online' => isset($device->online) && (int)$device->online === 1,
'on' => $this->extractRelayOnState($device, $relay),
];
}
/**
* Set MACHINE relay status directly.
* @param bool $on true to turn on, false to turn off
* @return bool
* @throws \Exception
*/
public function setMachineRelayStatus(bool $on): bool
{
return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on);
}
/**
* Set MACHINE_PROGRAM_PICKER relay status directly.
* @param bool $on true to turn on, false to turn off
* @return bool
* @throws \Exception
*/
public function setMachineProgramPickerRelayStatus(bool $on): bool
{
return $this->setRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
}
/**
* Set MACHINE_CLEANER relay status directly.
* @param bool $on true to turn on, false to turn off
* @return bool
* @throws \Exception
*/
public function setMachineCleanerRelayStatus(bool $on): bool
{
return $this->setRelayStatus(selfserve_lane_relay::MACHINE_CLEANER, $on);
}
/**
* Set relay status directly for a specific relay type.
* @param selfserve_lane_relay $relay
* @param bool $on true to turn on, false to turn off
* @return bool
* @throws \Exception
*/
public function setRelayStatus(selfserve_lane_relay $relay, bool $on): bool
{
return $on
? $this->forceTurnOnRelay($relay)
: $this->forceTurnOffRelay($relay);
}
/**
* Resolve relay ID for the current lane.
* @param selfserve_lane_relay $relay
* @throws \Exception
*/
private function getRelayId(selfserve_lane_relay $relay): string
{
if (empty($this->department_lane)) {
throw new \Exception("Department lane object not found for lane ID {$this->id}");
}
$relay_id = match ($relay) {
selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(),
selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(),
selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(),
default => throw new \Exception("Invalid relay type: {$relay->name}"),
};
if ($relay_id === '') {
throw new \Exception("Invalid relay ID for {$relay->name} relay");
}
return $relay_id;
}
/**
* Fetch Shelly switch state objects for a relay ID.
* @param string $relay_id
* @return array<shelly_device_switch>
* @throws \Exception
*/
private function fetchRelaySwitches(string $relay_id): array
{
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
$parameters = new shelly_request_body_get_states();
$parameters->ids = [$relay_id];
$parameters->select = ['status'];
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
if (is_object($result)) {
$result = [$result];
}
if (!is_array($result)) {
return [];
}
return array_map(function ($device) {
return (new shelly_device_switch())->populate($device);
}, $result);
}
/**
* Extract boolean on/off status from a Shelly switch state object.
* @throws \Exception
*/
private function extractRelayOnState(shelly_device_switch $device, selfserve_lane_relay $relay): bool
{
if (isset($device->on)) {
return (bool)$device->on;
}
if (!isset($device->status) || !is_object($device->status)) {
throw new \Exception('Missing status payload from Shelly response');
}
$status = (array)$device->status;
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (!array_key_exists($switch_key, $status)) {
continue;
}
$switch_state = $status[$switch_key];
if (is_object($switch_state) && isset($switch_state->output)) {
return (bool)$switch_state->output;
}
if (is_array($switch_state) && array_key_exists('output', $switch_state)) {
return (bool)$switch_state['output'];
}
}
throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload");
}
/**
* Turn on the lane relay
* @param selfserve_lane_relay $relay The relay to turn on (MACHINE)
* @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER)
* @parm int|null $duration The duration in seconds to keep the relay on (optional)
* @return bool True if the relay was successfully turned on
* @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn on the relay
@@ -30,7 +211,8 @@ trait selfserve_lane_relay_controller_t
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");
// Enforce that MACHINE relay can only be enabled when allowed by current self-serve tasks (self-serve, manual trigger required)
// Gating
if ($relay === selfserve_lane_relay::MACHINE) {
// Allowed services are stored as an array of names in lane cache
$allowed = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
@@ -38,15 +220,10 @@ trait selfserve_lane_relay_controller_t
throw new \Exception("MACHINE relay is not allowed to be enabled at this time");
}
}
// Get the relay ID based on the relay type
$relay_id = match ($relay) {
selfserve_lane_relay::MACHINE => $this->department_lane->relay_machine_id->value(),
default => throw new \Exception("Invalid relay specified, must be MACHINE"),
};
// Make sure relay ID is valid
if (empty($relay_id)) {
throw new \Exception("Invalid relay ID for relay {$relay->name}");
}
$relay_id = $this->getRelayId($relay);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
@@ -77,6 +254,18 @@ trait selfserve_lane_relay_controller_t
* @throws \Exception
*/
public function forceTurnOnMachineRelay(?int $duration = null): bool
{
return $this->forceTurnOnRelay(selfserve_lane_relay::MACHINE, $duration);
}
/**
* Force turn on a specific relay, bypassing allowed services gating.
* @param selfserve_lane_relay $relay
* @param int|null $duration Optional auto-off duration in seconds
* @return bool
* @throws \Exception
*/
public function forceTurnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
@@ -85,10 +274,8 @@ trait selfserve_lane_relay_controller_t
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");
// Directly control Shelly without checking allowed services
$relay_id = $this->department_lane->relay_machine_id->value();
if (empty($relay_id)) {
throw new \Exception("Invalid relay ID for MACHINE relay");
}
$relay_id = $this->getRelayId($relay);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
@@ -118,6 +305,17 @@ trait selfserve_lane_relay_controller_t
* @throws \Exception
*/
public function forceTurnOffMachineRelay(): bool
{
return $this->forceTurnOffRelay(selfserve_lane_relay::MACHINE);
}
/**
* Force turn off a specific relay, bypassing allowed services gating.
* @param selfserve_lane_relay $relay
* @return bool
* @throws \Exception
*/
public function forceTurnOffRelay(selfserve_lane_relay $relay): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
@@ -126,10 +324,8 @@ trait selfserve_lane_relay_controller_t
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");
// Directly control Shelly without checking allowed services
$relay_id = $this->department_lane->relay_machine_id->value();
if (empty($relay_id)) {
throw new \Exception("Invalid relay ID for MACHINE relay");
}
$relay_id = $this->getRelayId($relay);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
@@ -152,7 +348,7 @@ trait selfserve_lane_relay_controller_t
/**
* Turn off the lane relay
* @param selfserve_lane_relay $relay The relay to turn off (MACHINE)
* @param selfserve_lane_relay $relay The relay to turn off (MACHINE or MACHINE_PROGRAM_PICKER)
* @return bool True if the relay was successfully turned off
* @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn off the relay
*/
@@ -164,15 +360,10 @@ trait selfserve_lane_relay_controller_t
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");
// Get the relay ID based on the relay type
$relay_id = match ($relay) {
selfserve_lane_relay::MACHINE => $this->department_lane->relay_machine_id->value(),
default => throw new \Exception("Invalid relay specified, must be MACHINE"),
};
// Make sure relay ID is valid
if (empty($relay_id)) {
throw new \Exception("Invalid relay ID for relay {$relay->name}");
}
$relay_id = $this->getRelayId($relay);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
@@ -192,4 +383,4 @@ trait selfserve_lane_relay_controller_t
}
return true;
}
}
}
@@ -18,6 +18,8 @@ class department_lanes_o extends db
public object_property $relay_in_id; // The Shelly relay for the entrance port (if applicable)
public object_property $relay_out_id; // The Shelly relay for the exit port (if applicable)
public object_property $relay_machine_id; // The Shelly relay for the machine (if applicable)
public object_property $relay_machine_program_picker_id; // The Shelly relay for the machine program picker (if applicable)
public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable)
public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable)
public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable)
public object_property $created_at;
@@ -50,7 +52,7 @@ class department_lanes_o extends db
* @return department_lanes_o
* @throws Exception If the object was not created successfully
*/
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o
{
global /** @var db $db */
$db;
@@ -66,6 +68,12 @@ class department_lanes_o extends db
if (!is_null($relay_machine_id)) {
$relay_machine_id = $db->escape_string($relay_machine_id);
}
if (!is_null($relay_machine_program_picker_id)) {
$relay_machine_program_picker_id = $db->escape_string($relay_machine_program_picker_id);
}
if (!is_null($relay_machine_cleaner_id)) {
$relay_machine_cleaner_id = $db->escape_string($relay_machine_cleaner_id);
}
if (!is_null($dynamic_image_id)) {
$dynamic_image_id = (int)$dynamic_image_id;
if ($dynamic_image_id <= 0) {
@@ -85,6 +93,8 @@ class department_lanes_o extends db
...(!is_null($relay_in_id) ? ['relay_in_id' => $relay_in_id] : []), // If the relay_in_id is null, it will be set to null in the database
...(!is_null($relay_out_id) ? ['relay_out_id' => $relay_out_id] : []), // If the relay_out_id is null, it will be set to null in the database
...(!is_null($relay_machine_id) ? ['relay_machine_id' => $relay_machine_id] : []), // If the relay_machine_id is null, it will be set to null in the database
...(!is_null($relay_machine_program_picker_id) ? ['relay_machine_program_picker_id' => $relay_machine_program_picker_id] : []),
...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []),
...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database
...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []),
]);
@@ -101,6 +111,8 @@ class department_lanes_o extends db
$this->relay_in_id = new object_property($this->table, $this->id, 'relay_in_id', 'string', false);
$this->relay_out_id = new object_property($this->table, $this->id, 'relay_out_id', 'string', false);
$this->relay_machine_id = new object_property($this->table, $this->id, 'relay_machine_id', 'string', false);
$this->relay_machine_program_picker_id = new object_property($this->table, $this->id, 'relay_machine_program_picker_id', 'string', false);
$this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false);
$this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false);
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
@@ -122,6 +134,8 @@ class department_lanes_o extends db
'relay_in_id' => (string)$this->relay_in_id->value(),
'relay_out_id' => (string)$this->relay_out_id->value(),
'relay_machine_id' => (string)$this->relay_machine_id->value(),
'relay_machine_program_picker_id' => (string)$this->relay_machine_program_picker_id->value(),
'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(),
'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()),
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
// Status of the lane
@@ -55,6 +55,8 @@ class departmentLanesRoute
'relay_in_id',
'relay_out_id',
'relay_machine_id',
'relay_machine_program_picker_id',
'relay_machine_cleaner_id',
'dynamic_image_id',
'machine_type_id',
])
@@ -242,6 +244,9 @@ class departmentLanesRoute
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
if ($dynamic_image_id !== null) {
@@ -261,7 +266,7 @@ class departmentLanesRoute
// Check if the required fields are set
if ($name && $department) {
// Add the department lane
(new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $dynamic_image_id, $machine_type_id);
(new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id);
// Return a success message
$response->success('Department lane added');
} else {
@@ -299,6 +304,8 @@ class departmentLanesRoute
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -327,6 +334,12 @@ class departmentLanesRoute
if (self::isParametersSet(['relay_machine_id'])) {
$department_lane->relay_machine_id->set((string)$relay_machine_id);
}
if (self::isParametersSet(['relay_machine_program_picker_id'])) {
$department_lane->relay_machine_program_picker_id->set((string)$relay_machine_program_picker_id);
}
if (self::isParametersSet(['relay_machine_cleaner_id'])) {
$department_lane->relay_machine_cleaner_id->set((string)$relay_machine_cleaner_id);
}
if (self::isParametersSet(['dynamic_image_id'])) {
$param = $response->getRequestParameter('dynamic_image_id');
if ($param === null || $param === '' || (is_string($param) && strtolower($param) === 'null')) {
@@ -177,6 +177,251 @@ class moduleSelfServeRoute
'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$status = $lane->getMachineProgramPickerRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_PROGRAM_PICKER',
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to get MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_program_picker_status_view' => 'Get MACHINE_PROGRAM_PICKER relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->setMachineProgramPickerRelayStatus((bool)$on);
$status = $lane->getMachineProgramPickerRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_PROGRAM_PICKER',
'requested_on' => (bool)$on,
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to set MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_program_picker_status_set' => 'Set MACHINE_PROGRAM_PICKER relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
$this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$status = $lane->getMachineCleanerRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_CLEANER',
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to get MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_cleaner_status_view' => 'Get MACHINE_CLEANER relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->setMachineCleanerRelayStatus((bool)$on);
$status = $lane->getMachineCleanerRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_CLEANER',
'requested_on' => (bool)$on,
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to set MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_cleaner_status_set' => 'Set MACHINE_CLEANER relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE status */
$this->get('/modules/self-serve/lane/relay/machine/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$status = $lane->getMachineRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE',
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to get MACHINE relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_view' => 'Get MACHINE relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
$this->post('/modules/self-serve/lane/relay/machine/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->setMachineRelayStatus((bool)$on);
// Keep lane cache state aligned with the latest explicit relay action
try {
$lane->setLaneState((bool)$on
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF);
} catch (\Throwable $ignored) {}
$status = $lane->getMachineRelayStatus();
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE',
'requested_on' => (bool)$on,
'relay_id' => (string)$status['relay_id'],
'online' => (bool)$status['online'],
'on' => (bool)$status['on'],
]);
} catch (\Exception $e) {
$response->error('Failed to set MACHINE relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_set' => 'Set MACHINE relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration]);
} catch (\Exception $e) {
$response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_enable_machine_program_picker' => 'Manually enable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration]);
} catch (\Exception $e) {
$response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_enable_machine_cleaner' => 'Manually enable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
global $response;
@@ -208,6 +453,100 @@ class moduleSelfServeRoute
'modules_selfserve_lane_relay_enable_machine' => 'Manually enable MACHINE relay for a lane (requires allowed task to be present)'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration]);
} catch (\Exception $e) {
$response->error('Failed to force enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_program_picker_enable' => 'Force enable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'disabled' => true]);
} catch (\Exception $e) {
$response->error('Failed to force disable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_program_picker_disable' => 'Force disable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration]);
} catch (\Exception $e) {
$response->error('Failed to force enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_cleaner_enable' => 'Force enable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER);
$response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'disabled' => true]);
} catch (\Exception $e) {
$response->error('Failed to force disable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_cleaner_disable' => 'Force disable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
$this->post('/modules/self-serve/lane/force/machine/enable', function () {
global $response;
@@ -304,4 +643,4 @@ class moduleSelfServeRoute
'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)'
]);
}
}
}
@@ -9,6 +9,7 @@ use classes\weatherapi;
use classes\workfeed;
use DateInterval;
use DateTime;
use DateTimeZone;
use Exception;
use Throwable;
use objects\departments_o;
@@ -24,6 +25,13 @@ class moduleWeatherAPIRoute
private const DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY = 'departments_weather:refresh_queue:v1';
private const DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX = 'departments_weather:hot_descriptor:v1:';
private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:';
private const DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY = 'weather_status_degraded_threshold';
private const DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY = 'weather_status_healthy_threshold';
private const DEPARTMENT_WEATHER_STATUS_SEVERITY = [
'healthy' => 1,
'degraded' => 2,
'unhealthy' => 3,
];
public function run(): void
{
@@ -106,12 +114,13 @@ class moduleWeatherAPIRoute
$selected_date_range['date_to'] ?? null
);
$departments = self::loadDepartmentsByIds($department_ids);
$status_targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments);
$coordinates = self::resolveWeatherCoordinates($departments);
$department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
$timeline = $this->withCachedDepartmentWeatherTimeline(
$department_ids,
$timeline_range,
function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user, $status_targets_by_department): array {
$weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days);
if (is_string($forecast_result['fallback_reason'])) {
@@ -123,8 +132,16 @@ class moduleWeatherAPIRoute
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message);
}
return $this->buildDepartmentWeatherTimeline($department_ids, $departments, $forecast_result['forecast'], $timeline_range);
}
return $this->buildDepartmentWeatherTimeline(
$department_ids,
$departments,
$forecast_result['forecast'],
$timeline_range,
$status_targets_by_department
);
},
true,
$status_targets_by_department
);
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
@@ -133,6 +150,96 @@ class moduleWeatherAPIRoute
'departments_weather_get' => 'Get department weather timeline with washes and productivity status',
'department_access_:id' => 'Access weather timeline for one or more specific departments',
]);
$this->get('/departments/weather/targets', function () {
global $response;
self::requirePermission('departments_weather_targets_get');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$department_ids = self::parseDepartmentIdsFromRequest();
foreach ($department_ids as $department_id) {
self::requireDepartmentAccess((string)$department_id);
}
$departments = self::loadDepartmentsByIds($department_ids);
$targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments);
$department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
$data = [];
foreach ($department_ids as $department_id) {
$target = $this->normalizeDepartmentWeatherTarget($targets_by_department[$department_id] ?? null);
$data[] = self::buildDepartmentWeatherTargetResponse($department_id, $target);
}
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_GET', 'Department weather targets fetched');
$response->success($data, 200);
}, [
'departments_weather_targets_get' => 'Get department weather productivity thresholds',
'department_access_:id' => 'Access weather targets for one or more specific departments',
]);
$this->put('/departments/weather/targets', function () {
global $response;
self::requirePermission('departments_weather_targets_manage');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['department_id', 'degraded_threshold', 'healthy_threshold']);
$department_id_raw = self::getParameter('department_id');
if (!is_scalar($department_id_raw)) {
$response->error('Invalid department_id value', 400);
}
$department_id_value = trim((string)$department_id_raw);
if (!preg_match('/^\d+$/', $department_id_value)) {
$response->error('Invalid department_id: ' . $department_id_value, 400);
}
$department_id = (int)$department_id_value;
if ($department_id < 1) {
$response->error('department_id must be at least 1', 400);
}
self::requireDepartmentAccess((string)$department_id);
$degraded_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('degraded_threshold'));
if ($degraded_threshold === null) {
$response->error('degraded_threshold must be a non-negative number', 400);
}
$healthy_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('healthy_threshold'));
if ($healthy_threshold === null) {
$response->error('healthy_threshold must be a non-negative number', 400);
}
if ($healthy_threshold < $degraded_threshold) {
$response->error('healthy_threshold must be greater than or equal to degraded_threshold', 400);
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
}
$department->variables->set(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY, self::formatDepartmentWeatherThreshold($degraded_threshold));
$department->variables->set(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY, self::formatDepartmentWeatherThreshold($healthy_threshold));
$target = [
'degraded_threshold' => $degraded_threshold,
'healthy_threshold' => $healthy_threshold,
];
(new logs_o())->add('modules_weatherapi', (string)$department_id, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_UPDATE', 'Department weather targets updated');
$response->success(self::buildDepartmentWeatherTargetResponse($department_id, $target), 200);
}, [
'departments_weather_targets_manage' => 'Manage department weather productivity thresholds',
'department_access_:id' => 'Manage weather targets for a specific department',
]);
}
/**
@@ -173,12 +280,13 @@ class moduleWeatherAPIRoute
return max(1, (int)$raw);
}
private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range): string
private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range, array $status_targets_by_department = []): string
{
$normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int {
return (int)$department_id;
}, $department_ids)));
sort($normalized_ids, SORT_NUMERIC);
$normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($normalized_ids, $status_targets_by_department);
$start = ($timeline_range['start'] ?? null) instanceof DateTime
? $timeline_range['start']->format('Y-m-d H:i:s')
@@ -187,10 +295,11 @@ class moduleWeatherAPIRoute
? $timeline_range['endExclusive']->format('Y-m-d H:i:s')
: '';
return 'departments_weather:timeline:v1:' . md5((string)json_encode([
return 'departments_weather:timeline:v2:' . md5((string)json_encode([
'department_ids' => $normalized_ids,
'start' => $start,
'end_exclusive' => $end_exclusive,
'targets' => $this->buildDepartmentWeatherTargetsCacheKeyPayload($normalized_ids, $normalized_targets),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
@@ -367,7 +476,13 @@ class moduleWeatherAPIRoute
* @param callable():array $resolver
* @return array
*/
private function withCachedDepartmentWeatherTimeline(array $department_ids, array $timeline_range, callable $resolver, bool $record_hot_key = true): array
private function withCachedDepartmentWeatherTimeline(
array $department_ids,
array $timeline_range,
callable $resolver,
bool $record_hot_key = true,
array $status_targets_by_department = []
): array
{
$fresh_ttl = $this->getDepartmentWeatherCacheTtl();
if ($fresh_ttl <= 0 || !defined('redis')) {
@@ -375,7 +490,7 @@ class moduleWeatherAPIRoute
}
$stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl);
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range);
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range, $status_targets_by_department);
if ($record_hot_key) {
$this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range);
}
@@ -533,23 +648,31 @@ class moduleWeatherAPIRoute
}
$departments = $route->loadDepartmentsByIds($normalized_ids);
$status_targets_by_department = $route->loadDepartmentWeatherTargetsByDepartmentId($departments);
$coordinates = $route->resolveWeatherCoordinates($departments);
$timeline = $route->withCachedDepartmentWeatherTimeline(
$normalized_ids,
$timeline_range,
function () use ($coordinates, $normalized_ids, $departments, $timeline_range): array {
$weather_days = self::resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = self::fetchDepartmentForecastOrFallback($coordinates, $weather_days);
function () use ($coordinates, $normalized_ids, $departments, $timeline_range, $status_targets_by_department, $route): array {
$weather_days = $route->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = $route->fetchDepartmentForecastOrFallback($coordinates, $weather_days);
return self::buildDepartmentWeatherTimeline($normalized_ids, $departments, $forecast_result['forecast'], $timeline_range);
return $route->buildDepartmentWeatherTimeline(
$normalized_ids,
$departments,
$forecast_result['forecast'],
$timeline_range,
$status_targets_by_department
);
},
false
false,
$status_targets_by_department
);
return [
'warmed' => true,
'department_ids' => $normalized_ids,
'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range),
'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range, $status_targets_by_department),
'entries' => count($timeline),
];
}
@@ -702,6 +825,157 @@ class moduleWeatherAPIRoute
return $departments;
}
private static function normalizeDepartmentWeatherThresholdValue(mixed $value): ?float
{
if (is_string($value)) {
$value = trim($value);
if ($value === '') {
return null;
}
}
if (!is_numeric($value)) {
return null;
}
$threshold = (float)$value;
if (!is_finite($threshold) || $threshold < 0) {
return null;
}
return round($threshold, 6);
}
private static function formatDepartmentWeatherThreshold(float $threshold): string
{
$formatted = number_format($threshold, 6, '.', '');
$trimmed = rtrim(rtrim($formatted, '0'), '.');
return $trimmed === '' ? '0' : $trimmed;
}
private function normalizeDepartmentWeatherTarget(mixed $target): ?array
{
if (!is_array($target)) {
return null;
}
$degraded_threshold = self::normalizeDepartmentWeatherThresholdValue($target['degraded_threshold'] ?? null);
$healthy_threshold = self::normalizeDepartmentWeatherThresholdValue($target['healthy_threshold'] ?? null);
if ($degraded_threshold === null || $healthy_threshold === null) {
return null;
}
if ($healthy_threshold < $degraded_threshold) {
return null;
}
return [
'degraded_threshold' => $degraded_threshold,
'healthy_threshold' => $healthy_threshold,
];
}
private function normalizeDepartmentWeatherTargetsByDepartmentId(array $department_ids, array $status_targets_by_department): array
{
$normalized = [];
foreach ($department_ids as $department_id) {
$normalized_department_id = (int)$department_id;
if ($normalized_department_id < 1) {
continue;
}
$target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
if ($target !== null) {
$normalized[$normalized_department_id] = $target;
}
}
ksort($normalized, SORT_NUMERIC);
return $normalized;
}
private function loadDepartmentWeatherTargetsByDepartmentId(array $departments): array
{
$targets_by_department = [];
foreach ($departments as $department) {
$department_id = (int)($department->id ?? 0);
if ($department_id < 1) {
continue;
}
$target = $this->normalizeDepartmentWeatherTarget([
'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY),
'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY),
]);
if ($target !== null) {
$targets_by_department[$department_id] = $target;
}
}
ksort($targets_by_department, SORT_NUMERIC);
return $targets_by_department;
}
private static function buildDepartmentWeatherTargetResponse(int $department_id, ?array $target): array
{
return [
'department_id' => $department_id,
'degraded_threshold' => $target['degraded_threshold'] ?? null,
'healthy_threshold' => $target['healthy_threshold'] ?? null,
'configured' => is_array($target),
];
}
private function buildDepartmentWeatherTargetsCacheKeyPayload(array $department_ids, array $status_targets_by_department): array
{
$payload = [];
foreach ($department_ids as $department_id) {
$normalized_department_id = (int)$department_id;
if ($normalized_department_id < 1) {
continue;
}
$target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
$payload[(string)$normalized_department_id] = $target;
}
ksort($payload, SORT_STRING);
return $payload;
}
private static function sumDepartmentHoursForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): float
{
$total = 0.0;
foreach ($department_ids as $department_id) {
$normalized_department_id = (int)$department_id;
if ($normalized_department_id < 1) {
continue;
}
$total += (float)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0);
}
return $total;
}
private static function sumDepartmentWashesForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): int
{
$total = 0;
foreach ($department_ids as $department_id) {
$normalized_department_id = (int)$department_id;
if ($normalized_department_id < 1) {
continue;
}
$total += (int)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0);
}
return $total;
}
private function resolveWeatherCoordinates(array $departments): ?array
{
$lat_sum = 0.0;
@@ -810,7 +1084,13 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(array $department_ids, array $departments, object $forecast, array $timeline_range): array
private function buildDepartmentWeatherTimeline(
array $department_ids,
array $departments,
object $forecast,
array $timeline_range,
array $status_targets_by_department = []
): array
{
$hourly_weather = [];
foreach (($forecast->forecast->forecastday ?? []) as $day) {
@@ -822,23 +1102,26 @@ class moduleWeatherAPIRoute
$timeline_start = $timeline_range['start'];
$timeline_end_exclusive = $timeline_range['endExclusive'];
$normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($department_ids, $status_targets_by_department);
$normalized_department_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
$entries = [];
$slot = clone $timeline_start;
$workfeed_hours_by_slot = $departments === []
$workfeed_hours_by_department_and_slot = $departments === []
? []
: self::loadWorkfeedDepartmentHoursBySlot($departments, $timeline_start, $timeline_end_exclusive);
$washes_by_slot = $department_ids === []
: self::loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive);
$washes_by_department_and_slot = $normalized_department_ids === []
? []
: self::loadDepartmentWashCountsBySlot($department_ids, $timeline_start, $timeline_end_exclusive);
: self::loadDepartmentWashCountsBySlotByDepartment($normalized_department_ids, $timeline_start, $timeline_end_exclusive);
$current_slot_start = new DateTime(date('Y-m-d H:00:00'));
$current_slot_key = $current_slot_start->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 = (int)($washes_by_slot[$slot_key] ?? 0);
$hours = self::sumDepartmentHoursForSlot($normalized_department_ids, $workfeed_hours_by_department_and_slot, $slot_key);
$washes = self::sumDepartmentWashesForSlot($normalized_department_ids, $washes_by_department_and_slot, $slot_key);
$slot_started = $slot <= $current_slot_start;
$entries[] = [
'date' => $slot->format('Y-m-d'),
'time' => $slot->format('H:00'),
@@ -846,7 +1129,14 @@ class moduleWeatherAPIRoute
'weather' => $weather,
'washes' => $washes,
'hours' => $hours,
'status' => self::calculateStatus($washes, $hours, $slot <= $current_slot_start),
'status' => self::calculateAggregatedDepartmentStatus(
$normalized_department_ids,
$washes_by_department_and_slot,
$workfeed_hours_by_department_and_slot,
$slot_key,
$slot_started,
$normalized_targets
),
];
$slot->add(new DateInterval('PT1H'));
}
@@ -854,7 +1144,7 @@ class moduleWeatherAPIRoute
return $entries;
}
private function calculateStatus(int $washes, float $hours, bool $slot_started = true): string
private function calculateStatus(int $washes, float $hours, bool $slot_started = true, ?array $targets = null): string
{
if (!$slot_started) {
return 'unknown';
@@ -864,30 +1154,104 @@ class moduleWeatherAPIRoute
return 'unknown';
}
/**
* Below 1.0 cars/workhour = unhealthy (red)
* 1.0-1.3 cars/workhour = degraded (yellow)
* Above 1.3 cars/workhour = healthy (green)
*/
$normalized_targets = $this->normalizeDepartmentWeatherTarget($targets);
if ($normalized_targets === null) {
return 'unknown';
}
$ratio = $washes / $hours;
if ($ratio >= 1.3) {
if ($ratio >= $normalized_targets['healthy_threshold']) {
return 'healthy';
}
if ($ratio >= 1.0) {
if ($ratio >= $normalized_targets['degraded_threshold']) {
return 'degraded';
}
return 'unhealthy';
}
private function calculateAggregatedDepartmentStatus(
array $department_ids,
array $washes_by_department_and_slot,
array $hours_by_department_and_slot,
string $slot_key,
bool $slot_started,
array $status_targets_by_department
): string
{
if (!$slot_started) {
return 'unknown';
}
$worst_status = 'unknown';
$worst_severity = 0;
$has_evaluable_department = false;
foreach ($department_ids as $department_id) {
$normalized_department_id = (int)$department_id;
if ($normalized_department_id < 1) {
continue;
}
$targets = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
if ($targets === null) {
return 'unknown';
}
$hours = (float)($hours_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0);
if ($hours <= 0) {
continue;
}
$has_evaluable_department = true;
$washes = (int)($washes_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0);
$status = $this->calculateStatus($washes, $hours, true, $targets);
$severity = self::DEPARTMENT_WEATHER_STATUS_SEVERITY[$status] ?? 0;
if ($severity > $worst_severity) {
$worst_severity = $severity;
$worst_status = $status;
}
}
if (!$has_evaluable_department) {
return 'unknown';
}
return $worst_status;
}
/**
* @throws Exception
*/
private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
$hours_by_department_and_slot = $this->loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive);
if ($hours_by_department_and_slot === []) {
return [];
}
$hours_by_slot = [];
foreach ($hours_by_department_and_slot as $department_hours_by_slot) {
foreach ($department_hours_by_slot as $slot_key => $hours) {
if (!isset($hours_by_slot[$slot_key])) {
$hours_by_slot[$slot_key] = 0.0;
}
$hours_by_slot[$slot_key] += (float)$hours;
}
}
return $hours_by_slot;
}
/**
* @throws Exception
*/
private function loadWorkfeedDepartmentHoursBySlotByDepartment(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
try {
$workfeed = new workfeed();
$workfeed_department_ids = self::resolveWorkfeedDepartmentIds($departments, $workfeed);
if ($workfeed_department_ids === []) {
$workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed);
if ($workfeed_department_ids_by_department === []) {
return [];
}
@@ -902,15 +1266,20 @@ class moduleWeatherAPIRoute
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_ids, $slot);
$slot->add(new DateInterval('PT1H'));
$hours_by_department_and_slot = [];
foreach ($workfeed_department_ids_by_department as $department_id => $workfeed_department_id) {
$slot = clone $timeline_start;
while ($slot < $timeline_end_exclusive) {
$slot_key = $slot->format('Y-m-d H:00');
if (!isset($hours_by_department_and_slot[$department_id])) {
$hours_by_department_and_slot[$department_id] = [];
}
$hours_by_department_and_slot[$department_id][$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot);
$slot->add(new DateInterval('PT1H'));
}
}
return $hours_by_slot;
return $hours_by_department_and_slot;
} catch (Exception) {
return [];
}
@@ -947,14 +1316,27 @@ class moduleWeatherAPIRoute
}
private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array
{
$resolved_ids = array_values(array_unique(array_values($this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed))));
sort($resolved_ids, SORT_STRING);
return $resolved_ids;
}
private function resolveWorkfeedDepartmentIdsByDepartmentId(array $departments, workfeed $workfeed): array
{
$resolved_ids = [];
$workfeed_departments = null;
foreach ($departments as $department) {
$department_id = (int)($department->id ?? 0);
if ($department_id < 1) {
continue;
}
$configured_id = self::getConfiguredWorkfeedDepartmentId($department);
if ($configured_id !== null) {
$resolved_ids[] = $configured_id;
$resolved_ids[$department_id] = $configured_id;
continue;
}
@@ -972,11 +1354,11 @@ class moduleWeatherAPIRoute
$matched_id = self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments);
if ($matched_id !== null) {
$resolved_ids[] = $matched_id;
$resolved_ids[$department_id] = $matched_id;
}
}
return array_values(array_unique($resolved_ids));
return $resolved_ids;
}
private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string
@@ -1085,15 +1467,168 @@ class moduleWeatherAPIRoute
private function parseDateTimeValue(mixed $value): ?DateTime
{
if (!is_string($value) || trim($value) === '') {
if (is_string($value)) {
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
return new DateTime($normalized);
} catch (Exception) {
if (!is_numeric($normalized)) {
return null;
}
$value = (float)$normalized;
}
}
if (is_int($value) || is_float($value)) {
if (!is_finite((float)$value)) {
return null;
}
$timestamp = (float)$value;
if ($timestamp > 9999999999) {
$timestamp /= 1000;
}
try {
$date = new DateTime('@' . (string)(int)round($timestamp));
$date->setTimezone(new DateTimeZone('UTC'));
return $date;
} catch (Exception) {
return null;
}
}
$record = self::normalizeWorkfeedRecord($value);
if ($record !== []) {
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
if (!array_key_exists($key, $record)) {
continue;
}
$parsed = self::parseDateTimeValue($record[$key]);
if ($parsed !== null) {
return $parsed;
}
}
}
return null;
}
private function getNestedRecordValue(array $record, string $path): mixed
{
$segments = explode('.', $path);
$current = $record;
foreach ($segments as $segment) {
if (is_array($current)) {
if (!array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
continue;
}
if (is_object($current)) {
if (!property_exists($current, $segment)) {
return null;
}
$current = $current->$segment;
continue;
}
return null;
}
try {
return new DateTime($value);
} catch (Exception) {
return null;
return $current;
}
private function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
{
foreach ($paths as $path) {
$value = self::getNestedRecordValue($record, $path);
$parsed = self::parseDateTimeValue($value);
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
{
$latest = null;
foreach ($paths as $path) {
$value = self::getNestedRecordValue($record, $path);
$parsed = self::parseDateTimeValue($value);
if ($parsed === null) {
continue;
}
if ($latest === null || $parsed->getTimestamp() > $latest->getTimestamp()) {
$latest = $parsed;
}
}
return $latest;
}
private function hasShiftApproval(array $record): bool
{
if (!array_key_exists('approval', $record)) {
return false;
}
$approval = $record['approval'];
if ($approval === null) {
return false;
}
if (is_array($approval)) {
return $approval !== [];
}
if (is_object($approval)) {
return get_object_vars($approval) !== [];
}
return true;
}
private function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime
{
if (self::hasShiftApproval($record)) {
return $shift_end;
}
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
if ($update_time === null) {
return $shift_end;
}
$shift_start_ts = $shift_start->getTimestamp();
$shift_end_ts = $shift_end->getTimestamp();
$update_ts = $update_time->getTimestamp();
if ($update_ts <= $shift_end_ts) {
return $shift_end;
}
// Guard against counting late administrative edits as overtime.
$max_unapproved_extension_seconds = 6 * 3600;
if (($update_ts - $shift_end_ts) > $max_unapproved_extension_seconds) {
return $shift_end;
}
if (($update_ts - $shift_start_ts) > 24 * 3600) {
return $shift_end;
}
return $update_time;
}
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float
@@ -1123,12 +1658,33 @@ class moduleWeatherAPIRoute
}
$record = self::normalizeWorkfeedRecord($shift);
$shift_start = self::parseDateTimeValue($record['start'] ?? null);
$shift_end = self::parseDateTimeValue($record['end'] ?? null);
$shift_start = self::firstShiftDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$shift_end = self::lastShiftDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
'end',
'endTime',
'to',
'approval.originalEnd',
'approval.end',
]);
if ($shift_start === null || $shift_end === null) {
continue;
}
$shift_end = self::resolveEffectiveShiftEnd($record, $shift_start, $shift_end);
$shift_start_ts = $shift_start->getTimestamp();
$shift_end_ts = $shift_end->getTimestamp();
if ($shift_end_ts <= $shift_start_ts) {
@@ -1149,6 +1705,29 @@ class moduleWeatherAPIRoute
* @throws Exception
*/
private function loadDepartmentWashCountsBySlot(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
$counts_by_department_and_slot = $this->loadDepartmentWashCountsBySlotByDepartment($department_ids, $timeline_start, $timeline_end_exclusive);
if ($counts_by_department_and_slot === []) {
return [];
}
$counts = [];
foreach ($counts_by_department_and_slot as $department_counts_by_slot) {
foreach ($department_counts_by_slot as $slot_key => $wash_count) {
if (!isset($counts[$slot_key])) {
$counts[$slot_key] = 0;
}
$counts[$slot_key] += (int)$wash_count;
}
}
return $counts;
}
/**
* @throws Exception
*/
private function loadDepartmentWashCountsBySlotByDepartment(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
$normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
if ($normalized_ids === []) {
@@ -1168,13 +1747,30 @@ class moduleWeatherAPIRoute
$normalized_ids
);
return $this->normalizeDepartmentWashCountRows($rows);
return $this->normalizeDepartmentWashCountRowsByDepartment($rows);
} catch (Exception) {
return [];
}
}
private function normalizeDepartmentWashCountRows(array $rows): array
{
$counts_by_department_and_slot = $this->normalizeDepartmentWashCountRowsByDepartment($rows);
$counts = [];
foreach ($counts_by_department_and_slot as $department_counts_by_slot) {
foreach ($department_counts_by_slot as $slot_key => $wash_count) {
if (!isset($counts[$slot_key])) {
$counts[$slot_key] = 0;
}
$counts[$slot_key] += (int)$wash_count;
}
}
return $counts;
}
private function normalizeDepartmentWashCountRowsByDepartment(array $rows): array
{
$counts = [];
foreach ($rows as $row) {
@@ -1182,6 +1778,11 @@ class moduleWeatherAPIRoute
continue;
}
$department_id = (int)($row['department_id'] ?? $row['departmentId'] ?? $row['department'] ?? 0);
if ($department_id < 1) {
continue;
}
$bucket = trim((string)($row['hour_bucket'] ?? $row['hour'] ?? $row['slot'] ?? ''));
if ($bucket === '') {
continue;
@@ -1198,10 +1799,13 @@ class moduleWeatherAPIRoute
$wash_count = 0;
}
if (!isset($counts[$slot_key])) {
$counts[$slot_key] = 0;
if (!isset($counts[$department_id])) {
$counts[$department_id] = [];
}
$counts[$slot_key] += $wash_count;
if (!isset($counts[$department_id][$slot_key])) {
$counts[$department_id][$slot_key] = 0;
}
$counts[$department_id][$slot_key] += $wash_count;
}
return $counts;
@@ -3,6 +3,8 @@
function selfserve_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 6) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 5) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
@@ -29,6 +31,8 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
expect($content)->toContain('/department/selfserve/vehicle/allowed:');
expect($content)->toContain('/department/selfserve/washes/summary:');
expect($content)->toContain('/relay/button/press/post:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:');
});
it('defines reusable self-serve wash and machine type schemas', function (): void {
@@ -40,4 +44,5 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi
expect($content)->toContain('MachineButtonPressWebhookResponse:');
expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:');
expect($content)->toContain('machine_type_id:');
expect($content)->toContain('SelfServeLaneMachineRelayStatus:');
});
@@ -31,3 +31,13 @@ it('keeps machine type support wired into lanes, tasks, and conditions routes',
->toContain('lane')
->toContain('product');
});
it('wires machine relay status get and set endpoints', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
expect($moduleSelfServeRoute)->toContain('getMachineRelayStatus');
expect($moduleSelfServeRoute)->toContain('setMachineRelayStatus');
});
@@ -93,7 +93,7 @@ it('builds deterministic cache keys for department sets and timeline ranges', fu
expect($keyA)->toBe($keyB);
expect($keyA)->not->toBe($keyC);
expect($keyA)->toStartWith('departments_weather:timeline:v1:');
expect($keyA)->toStartWith('departments_weather:timeline:v2:');
});
it('builds order-insensitive cache keys for equivalent department id sets', function (): void {
@@ -109,6 +109,30 @@ it('builds order-insensitive cache keys for equivalent department id sets', func
expect($ordered)->toBe($shuffled);
});
it('changes weather timeline cache key when department weather targets change', function (): void {
$route = new moduleWeatherAPIRoute();
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$baseTargets = [
1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
3 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
];
$updatedTargets = [
1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.4],
3 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
];
$baseKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3], $range, $baseTargets]);
$sameKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[3, 1], $range, $baseTargets]);
$updatedKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3], $range, $updatedTargets]);
expect($baseKey)->toBe($sameKey);
expect($baseKey)->not->toBe($updatedKey);
});
it('falls back to resolver directly when cache ttl is disabled', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=0');
@@ -0,0 +1,101 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
function weather_status_targets_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('evaluates healthy degraded and unhealthy statuses from configured department targets', function (): void {
$route = new moduleWeatherAPIRoute();
$targets = [
'degraded_threshold' => 1.0,
'healthy_threshold' => 1.3,
];
$healthy = weather_status_targets_invoke_private($route, 'calculateStatus', [13, 10.0, true, $targets]);
$degraded = weather_status_targets_invoke_private($route, 'calculateStatus', [10, 10.0, true, $targets]);
$unhealthy = weather_status_targets_invoke_private($route, 'calculateStatus', [9, 10.0, true, $targets]);
expect($healthy)->toBe('healthy');
expect($degraded)->toBe('degraded');
expect($unhealthy)->toBe('unhealthy');
});
it('returns unknown when configured targets are missing for department status evaluation', function (): void {
$route = new moduleWeatherAPIRoute();
$status = weather_status_targets_invoke_private($route, 'calculateStatus', [10, 10.0, true, null]);
expect($status)->toBe('unknown');
});
it('aggregates multi-department slot statuses using worst severity and unknown fallback rules', function (): void {
$route = new moduleWeatherAPIRoute();
$slotKey = '2026-03-24 10:00';
$targets = [
1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
2 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
];
$worst = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [
[1, 2],
[
1 => [$slotKey => 14],
2 => [$slotKey => 8],
],
[
1 => [$slotKey => 10.0],
2 => [$slotKey => 10.0],
],
$slotKey,
true,
$targets,
]);
expect($worst)->toBe('unhealthy');
$missingTargets = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [
[1, 2],
[
1 => [$slotKey => 14],
2 => [$slotKey => 8],
],
[
1 => [$slotKey => 10.0],
2 => [$slotKey => 10.0],
],
$slotKey,
true,
[
1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3],
],
]);
expect($missingTargets)->toBe('unknown');
$noEvaluableHours = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [
[1, 2],
[
1 => [$slotKey => 0],
2 => [$slotKey => 0],
],
[
1 => [$slotKey => 0.0],
2 => [$slotKey => 0.0],
],
$slotKey,
true,
$targets,
]);
expect($noEvaluableHours)->toBe('unknown');
});
@@ -0,0 +1,41 @@
<?php
function department_weather_targets_openapi_content_or_skip(): string
{
$candidates = [
WD . '/openapi.yaml',
dirname(WD) . '/openapi.yaml',
dirname(WD, 2) . '/openapi.yaml',
];
foreach ($candidates as $candidate) {
if (!is_file($candidate)) {
continue;
}
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
test()->markTestSkipped('openapi.yaml is not mounted in this test container.');
}
it('documents department weather target endpoints and reusable schemas in openapi', function (): void {
$content = department_weather_targets_openapi_content_or_skip();
expect($content)->toContain('/departments/weather/targets:');
expect($content)->toContain('operationId: getDepartmentWeatherTargets');
expect($content)->toContain('operationId: upsertDepartmentWeatherTarget');
expect($content)->toContain('DepartmentWeatherTarget:');
expect($content)->toContain('DepartmentWeatherTargetsResponse:');
expect($content)->toContain('DepartmentWeatherTargetUpsertRequest:');
});
it('documents unknown weather status behavior when targets are missing', function (): void {
$content = department_weather_targets_openapi_content_or_skip();
expect($content)->toContain('DepartmentWeatherStatus:');
expect($content)->toContain('missing department weather targets');
expect($content)->toContain('enum: [unknown, healthy, degraded, unhealthy]');
});
@@ -0,0 +1,23 @@
<?php
it('registers department weather target routes with explicit read and manage permissions', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/departments/weather/targets');
expect($content)->toContain("requirePermission('departments_weather_targets_get')");
expect($content)->toContain("requirePermission('departments_weather_targets_manage')");
expect($content)->toContain("'department_access_:id'");
});
it('persists department weather targets using canonical department variable keys', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('weather_status_degraded_threshold');
expect($content)->toContain('weather_status_healthy_threshold');
expect($content)->toContain('loadDepartmentWeatherTargetsByDepartmentId');
expect($content)->toContain('buildDepartmentWeatherTargetResponse');
});
@@ -74,9 +74,17 @@ it('resolves forecast day count for a given timeline range with sane limits', fu
it('marks non-started slots as unknown regardless of wash-hour ratio', function (): void {
$route = new moduleWeatherAPIRoute();
$futureStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, false]);
$targets = [
'degraded_threshold' => 1.0,
'healthy_threshold' => 1.3,
];
$futureStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, false, $targets]);
expect($futureStatus)->toBe('unknown');
$startedStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true]);
$missingTargetStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true]);
expect($missingTargetStatus)->toBe('unknown');
$startedStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true, $targets]);
expect($startedStatus)->toBe('healthy');
});
@@ -108,6 +108,64 @@ it('calculates workfeed employee hours across multiple departments for one hour
expect($hours)->toBe(2.0);
});
it('counts overtime minutes when a shift carries an extended approval end timestamp', 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:00:00+00:00',
'approval' => (object)[
'originalEnd' => '2026-03-23T18:17:00+00:00',
],
],
];
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
expect($hours)->toBe(0.28);
});
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('normalizes department id input from scalar csv and nested array values', function (): void {
$route = new moduleWeatherAPIRoute();