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:
@@ -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. |
|
||||
|
||||
+219
-28
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user