Self Serve > Lane > Status */ $this->get('/modules/self-serve/lane/status', function () { global $response; self::requirePermission('modules_selfserve_lane_status_view'); $selfserve = new selfserve(); $lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : 1; $lane = $selfserve->lane($lane_id); $response->success([ 'id' => $lane->id, 'status' => $lane->getLaneStatus()->name, 'mode' => $lane->getLaneMode()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), 'elapsed_wash_time' => $lane->getElapsedWashTime(), 'license_plate' => $lane->getLicensePlate(), 'customer_number' => $lane->getCustomerNumber(), ]); }, [ 'modules_selfserve_lane_status_view' => 'View self-serve lane status', ] ); $this->put('/modules/self-serve/lane/status', function () { global $response; self::requirePermission('modules_selfserve_lane_status_set'); self::requireParameters(['lane_id', 'enabled']); $lane_id = (int)self::getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $department_lane = (new department_lanes_o())->select($lane_id); if (!$department_lane->exists()) { $response->error('Department lane not found', 404); } self::requireDepartmentAccess((int)$department_lane->department->value()); $enabled = $this->requestedBoolean('enabled'); $target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE; $lane = (new selfserve())->lane($lane_id); $lane->setLaneStatus($target_status); $user = (new authentication())->get_user(); $machine_status_audit = [ 'modified_at' => date(DATE_ATOM), 'modified_by_user_id' => $user ? (int)$user->id : null, 'modified_by_name' => $this->machineStatusAuditUserName($user), ]; $lane->setLaneStatusAudit($machine_status_audit); (new logs_o())->add( 'selfserve', 'global', 1, $user ? $user->id : 0, 'SET_LANE_MACHINE_STATUS', 'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name ); $status = (string)$lane->getLaneStatus()->name; $response->success([ 'id' => $lane->id, 'status' => $status, 'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status), 'machine_status_audit' => $machine_status_audit, 'machine_status_modified_at' => $machine_status_audit['modified_at'], 'machine_status_modified_by' => $machine_status_audit['modified_by_name'], 'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'], 'lane' => $department_lane->asArray(), ]); }, [ 'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status', ] ); /** Modules > Self Serve > Lane > Wash > In-progress details */ $this->get('/modules/self-serve/lane/wash/in-progress', function () { global $response; self::requireParameters(['lane_id']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $customer_scope = $this->requireInProgressWashDetailsAccess($lane_id); $build_customer = static function (?int $customer_number): ?array { if ($customer_number === null || $customer_number <= 0) { return null; } $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number); if ($customer_obj->exists()) { return [ 'id' => (int)$customer_obj->id, 'customer_number' => $customer_number, 'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(), 'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(), 'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(), 'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(), ]; } return [ 'id' => null, 'customer_number' => $customer_number, 'display_name' => null, 'email' => null, 'phone_country_code' => null, 'phone' => null, ]; }; $build_vehicle = static function (?int $vehicle_id, ?string $reg): ?array { $vehicle_obj = null; if ($vehicle_id !== null && $vehicle_id > 0) { $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id); if ($tmp_vehicle->exists()) { $vehicle_obj = $tmp_vehicle; } } if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') { $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg)); if ($tmp_vehicle->exists()) { $vehicle_obj = $tmp_vehicle; } } if ($vehicle_obj === null) { return null; } return [ 'id' => (int)$vehicle_obj->id, 'customer_id' => (int)$vehicle_obj->customer_id->value(), 'type' => (int)$vehicle_obj->type->value(), 'reg' => (string)$vehicle_obj->reg->value(), 'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(), ]; }; $format_wash_started_at = static function (?int $wash_start_time): ?string { if ($wash_start_time === null || $wash_start_time <= 0) { return null; } return date('Y-m-d H:i:s', $wash_start_time); }; $selfserve = new selfserve(); $included_minutes_when_machine_enabled = (int)$selfserve ->config ->machine_wash_minutes_included ->getVariableValue(); if ($included_minutes_when_machine_enabled < 0) { $included_minutes_when_machine_enabled = 0; } $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([ 'lane_id' => $lane_id, 'completed_at' => null, 'deleted_at' => null, ], ['id']); $session = null; if ($rows !== []) { usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); $tmp_session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); if ($tmp_session->exists()) { $session = $tmp_session; } } if ($session === null) { $lane = $selfserve->lane($lane_id); $lane_status = $lane->getLaneStatus(); $lane_state = $lane->getLaneState(); $wash_started_at = $format_wash_started_at($lane->getWashStartTime()); $machine_start_triggered = $wash_started_at !== null; $machine_relay_enabled = $machine_start_triggered || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN_QUEUED) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN); $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; $in_progress = $lane_status->equals(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); if (!$in_progress) { $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => false, 'session' => null, 'customer' => null, 'vehicle' => null, ], $customer_scope)); return; } $runtime_customer_number = (int)$lane->getCustomerNumber(); if ($runtime_customer_number <= 0) { $runtime_customer_number = null; } $runtime_reg = trim((string)$lane->getLicensePlate()); if ($runtime_reg === '') { $runtime_reg = null; } $customer = $build_customer($runtime_customer_number); $vehicle = $build_vehicle(null, $runtime_reg); $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => true, 'session' => [ 'id' => null, 'status' => $lane_state->name, 'reg' => $runtime_reg, 'customer_number' => $runtime_customer_number, 'subuser_id' => null, 'vehicle_id' => $vehicle['id'] ?? null, 'vehicle_type_id' => $vehicle['type'] ?? null, 'included_minutes' => $included_minutes, 'machine_type_id' => null, 'machine_relay_enabled' => $machine_relay_enabled, 'machine_relay_enabled_at' => $machine_relay_enabled ? $wash_started_at : null, 'machine_start_triggered' => $machine_start_triggered, 'machine_start_triggered_at' => $wash_started_at, 'wash_started_at' => $wash_started_at, 'created_at' => null, 'updated_at' => null, ], 'customer' => $customer, 'subuser' => null, 'vehicle' => $vehicle, ], $customer_scope)); return; } $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); $subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value(); $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value(); $session_reg = trim((string)$session->reg->value()); if ($session_reg === '') { $session_reg = null; } $customer = $build_customer($customer_number); $vehicle = $build_vehicle($vehicle_id, $session_reg); $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value(); if ($wash_started_at === null) { $wash_started_at = $machine_start_triggered_at; } if ($wash_started_at === null) { $wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime()); } $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; $status = $session->status->value(); $in_progress_statusses = [ selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value, selfserve_wash_session_status::READY_FOR_MACHINE_START->value, selfserve_wash_session_status::MACHINE_STARTED->value, selfserve_wash_session_status::PENDING_QUESTIONS->value, selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value ]; $in_progress = in_array($status, $in_progress_statusses); $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'status' => (string)$session->status->value(), 'in_progress' => $in_progress, 'elapsed_minutes' => $session->getElapsedMinutes(), 'session' => [ 'id' => (int)$session->id, 'status' => (string)$session->status->value(), 'reg' => $session_reg, 'customer_number' => $customer_number, 'subuser_id' => $subuser_id, 'vehicle_id' => $vehicle_id, 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), 'included_minutes' => $included_minutes ?? 0, 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), 'machine_relay_enabled' => $machine_relay_enabled, 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), 'machine_start_triggered_at' => $machine_start_triggered_at, 'wash_started_at' => $wash_started_at, 'created_at' => (string)$session->created_at->value(), 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), ], 'customer' => $customer, 'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id), 'vehicle' => $vehicle, ], $customer_scope)); }, [ 'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane', 'add_own_department_selfserve_vehicle_conditions' => 'Use in-progress self-serve wash details for the authenticated customer', ] ); /** Modules > Self Serve > Lane > Wash > My active wash */ $this->get('/modules/self-serve/lane/wash/my-active-wash', function () { global $response; $principal_scope = $this->requireMyActiveWashPrincipalScope(); $session = $this->findLatestActiveSelfServeSessionForCustomer( (int)$principal_scope['customer_number'], $principal_scope['subuser_id'] ); if (!$session->exists()) { $response->error('No active self-serve wash found.', 404); } $response->success($this->buildActiveSelfServeSessionResponse($session)); }, [ 'add_own_department_selfserve_vehicle_conditions' => 'Use the authenticated customer\'s active self-serve wash', ] ); /** Modules > Self Serve > Sessions */ $this->get('/modules/self-serve/sessions', function () { global $response; self::requirePermission('modules_selfserve_sessions_view'); $sessions = new selfserve_wash_sessions_o(); $sessions->setSearchableFields([ 'id', 'lane_id', 'department_id', 'machine_type_id', 'customer_number', 'subuser_id', 'vehicle_id', 'vehicle_type_id', 'reg', 'status', 'order_id', 'created_at', 'completed_at', ]); $additional_where = null; if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) { $additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')'; } $response->success($sessions->listObjectsWithPaginationIfSet( function (array $row): array { $session = (new selfserve_wash_sessions_o())->select((int)$row['id']); if (!$session->exists()) { return $row; } return [ ...$session->asArray(), 'subuser' => $this->buildInProgressWashSubuserPayload( $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value() ), 'elapsed_minutes' => $session->getElapsedMinutes(), 'open' => $session->isOpen(), ]; }, null, [], $additional_where )); }, [ 'modules_selfserve_sessions_view' => 'View self-serve wash sessions', ] ); /** Modules > Self Serve > Session detail */ $this->get('/modules/self-serve/sessions/{id}', function () { global $response; self::requirePermission('modules_selfserve_sessions_view'); $session_id = (int)$this->fromRoute('id'); self::requireType($session_id, self::type_int()); self::requireMinValue($session_id, 1); try { $response->success((new selfserve_wash_flow())->getSessionSummary($session_id)); } catch (\Throwable $e) { $response->error($e->getMessage(), 404); } }, [ 'modules_selfserve_sessions_view' => 'View self-serve wash session details', ] ); /** Modules > Self Serve > Lane > Force > Stop */ $this->post('/modules/self-serve/lane/force/stop', function () { global $response; self::requirePermission('modules_selfserve_sessions_force_stop'); self::requireParameters(['lane_id', 'bill']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $session_id = null; if ($this->isParametersSet(['session_id']) && $this->getParameter('session_id') !== null && $this->getParameter('session_id') !== '') { $session_id = (int)$this->getParameter('session_id'); self::requireType($session_id, self::type_int()); self::requireMinValue($session_id, 1); } $bill = $this->requestedBoolean('bill'); if ($bill) { self::requirePermission('modules_selfserve_sessions_force_stop_bill'); } $reason = null; if ($this->isParametersSet(['reason'])) { $reason = trim((string)$this->getParameter('reason')); $reason = $reason === '' ? null : $reason; } $user = (new authentication())->get_user(); $user_id = $user instanceof users_o ? (int)$user->id : null; try { $response->success((new selfserve_wash_flow())->forceStopLane($lane_id, $session_id, $bill, $reason, $user_id)); } catch (\Throwable $e) { $response->error($e->getMessage(), 409); } }, [ 'modules_selfserve_sessions_force_stop' => 'Force stop a self-serve wash session without relay or gate signaling', 'modules_selfserve_sessions_force_stop_bill' => 'Bill elapsed minutes when force stopping a self-serve wash session', ] ); /** Modules > Self Serve > Lane > Command */ $this->post('/modules/self-serve/lane/command', function () { global $response; $selfserve = new selfserve(); // Get the request user $user = (new authentication())->get_user(); $param_lane_id = 'lane_id'; $param_command = 'command'; // Validate parameters self::requireParameters([$param_lane_id, $param_command]); $lane_id = (int)$this->getParameter($param_lane_id); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $commandParam = (string)$this->getParameter($param_command); self::requireType($commandParam, self::type_string()); $command = selfserve_lane_command::tryFrom($commandParam); if ($command === null) { $response->error("Invalid command: " . $commandParam); } // Get the lane and command $lane = $selfserve->lane($lane_id); // Preserve not-found behavior before evaluating elevated/customer alternatives. if (empty($lane->department_lane) || empty($lane->department_lane->department)) { $response->error('Lane department not found', 404); } $customer_number = $this->resolveEffectiveCustomerNumber(); $customer_number = $customer_number === null ? 0 : (int)$customer_number; [ $allow_customer_self_serve, $requires_active_wash, $allow_department_active_wash ] = $this->customerSelfServeCommandAccessRequirements($command); $this->requireSelfServeLaneDepartmentOrCustomerAccess( $lane, $customer_number, $allow_customer_self_serve, $requires_active_wash, $allow_department_active_wash ); // If the user has the bypass permission, set the lane to bypass customer number validation if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) { $lane->setBypassCustomerNumberValidation(true); } // Require permissions for specific commands switch ($command) { case selfserve_lane_command::START: $this->requireSelfServeLaneCommandPermission( $lane, $customer_number, 'modules_selfserve_lane_command_execute_start', true ); break; case selfserve_lane_command::STOP: $this->requireSelfServeLaneCommandPermission( $lane, $customer_number, 'modules_selfserve_lane_command_execute_stop', true, true, false ); break; case selfserve_lane_command::RESERVE: $this->requireOperatorLaneCommandPermission( $lane, 'modules_selfserve_lane_command_execute_reserve' ); break; case selfserve_lane_command::RELEASE: $this->requireOperatorLaneCommandPermission( $lane, 'modules_selfserve_lane_command_execute_release' ); break; case selfserve_lane_command::RESET: $this->requireOperatorLaneCommandPermission( $lane, 'modules_selfserve_lane_command_execute_reset' ); break; case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE: $this->requirePropertyGateCommandPermission( 'modules_selfserve_lane_command_execute_open_property_access_gate', $lane, $customer_number ); break; case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: $this->requirePropertyGateCommandPermission( 'modules_selfserve_lane_command_execute_open_property_exit_gate', $lane, $customer_number ); break; } // Execute the command try { $this->applyShellyTransportOverride($lane); $subuser = (new authentication())->get_subuser(); $args = new \modules\selfserve\classes\selfserve_lane_command_arguments(); $args->setParameters([ ...$this->getParametersAsArray(), // Pass all parameters 'customer_number' => $customer_number, // Get customer number from request user 'subuser_id' => $subuser === false ? null : (int)$subuser->id, ]); $lane->execute($command, $args); if ($command === selfserve_lane_command::START) { try { $license_plate = selfserve::standardize_registration( (string)($args->license_plate ?: $lane->getLicensePlate()) ); if ($license_plate !== '') { (new selfserve_wash_flow())->synchronizeSession( $lane_id, $license_plate, $customer_number > 0 ? $customer_number : null, false, null, false, ['subuser_id' => $args->subuser_id] ); } } catch (\Throwable $e) { error_log( 'Failed to persist self-serve START session for lane ' . $lane_id . ': ' . $e->getMessage() ); } } $response->success([ 'id' => $lane->id, 'status' => $lane->getLaneStatus()->name, 'mode' => $lane->getLaneMode()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), 'elapsed_wash_time' => $lane->getElapsedWashTime(), 'license_plate' => $lane->getLicensePlate(), 'customer_number' => $lane->getCustomerNumber(), 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { if ( $command === selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE || $command === selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE ) { error_log( 'Failed to execute self-serve property gate command ' . $command->name . ' for lane ' . $lane_id . ': ' . $e->getMessage() ); } $response->error("Failed to execute command: " . $e->getMessage()); } }, [ 'modules_selfserve_lane_command_execute' => 'Execute self-serve lane command. This is required together with specific command permissions below.', 'modules_selfserve_lane_command_execute_start' => 'Execute self-serve lane START command', 'modules_selfserve_lane_command_execute_stop' => 'Execute self-serve lane STOP command', 'modules_selfserve_lane_command_execute_reserve' => 'Execute self-serve lane RESERVE command', 'modules_selfserve_lane_command_execute_release' => 'Execute self-serve lane RELEASE command (Release the lane reservation and reset its state)', 'modules_selfserve_lane_command_execute_reset' => 'Execute self-serve lane RESET command', 'modules_selfserve_lane_command_execute_open_property_access_gate' => 'Execute self-serve lane OPEN_PROPERTY_ACCESS_GATE command', 'modules_selfserve_lane_command_execute_open_property_exit_gate' => 'Execute self-serve lane OPEN_PROPERTY_EXIT_GATE command', 'modules_selfserve_lane_command_bypass_customer_number_validation' => 'Bypass customer number validation when executing commands', ] ); /** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */ $this->post('/modules/self-serve/lane/services/allowed', function () { global $response; $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); // Parse task_ids from request (optional, may be empty to clear) $task_ids_param = $this->isParametersSet(['task_ids']) ? $this->getParameter('task_ids') : []; if (is_string($task_ids_param)) { $decoded = json_decode($task_ids_param, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $task_ids_param = $decoded; } else { $task_ids_param = array_filter(array_map(fn($s) => (int)trim($s), explode(',', $task_ids_param)), fn($v) => $v > 0); } } if (!is_array($task_ids_param)) { $response->error('Invalid format for task_ids. Expected array, JSON array, or comma-separated string of IDs.', 400); } $task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param))); // Build allowed services from provided tasks $lane = $selfserve->lane($lane_id); $customer_number = $this->resolveEffectiveCustomerNumber(); $this->requireSelfServeLaneAccess( $lane, $customer_number === null ? 0 : (int)$customer_number, ['modules_selfserve_lane_services_set_allowed'], false ); $session_task_services = []; $latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane( $lane_id, $customer_number === null || (int)$customer_number <= 0 ? null : (int)$customer_number ); if (!$latest_session->exists() && $customer_number !== null && (int)$customer_number > 0) { $latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane($lane_id); } if ($latest_session->exists()) { foreach ((new selfserve_wash_session_tasks_o())->listBySession((int)$latest_session->id) as $session_task) { $task_id = (int)($session_task['task_id'] ?? 0); if ($task_id > 0) { $services = $session_task['services'] ?? []; if (is_string($services)) { $decoded_services = json_decode($services, true); $services = json_last_error() === JSON_ERROR_NONE && is_array($decoded_services) ? $decoded_services : []; } $session_task_services[$task_id] = is_array($services) ? $services : []; } } } $allowed_services = []; $published_config_task_services = $this->publishedConfigTaskServicesForLane($lane, $task_ids); $merge_services = static function (array $services) use (&$allowed_services): void { foreach ($services as $srv) { $name = strtoupper((string)$srv); if (!in_array($name, $allowed_services, true)) { $allowed_services[] = $name; } } }; foreach ($task_ids as $tid) { if ($tid <= 0) continue; if (array_key_exists($tid, $session_task_services)) { $merge_services($session_task_services[$tid]); continue; } if (array_key_exists($tid, $published_config_task_services)) { $merge_services($published_config_task_services[$tid]); continue; } $t = new \objects\department_selfserve_tasks_o(); $t->select($tid); if (!$t->exists()) continue; // ignore unknown ids // Validate the task belongs to the same lane if ((int)$t->lane->value() !== $lane_id) continue; // Merge services (if any) $merge_services((array)$t->services->value()); } if (!$this->isMachineWashEnabled()) { $allowed_services = array_values(array_filter( $allowed_services, static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name )); } // Persist on lane cache (overwrites previous allowed services) try { $relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services); $response->success([ 'lane_id' => $lane_id, 'allowed_services' => $allowed_services, 'relay_sync' => $relay_sync, 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $response->error('Failed to set allowed services: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)' ]); /** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */ $this->post('/modules/self-serve/lane/gate/open', function () { global $response; self::requirePermission('modules_selfserve_lane_gate_open'); $selfserve = new selfserve(); self::requireParameters(['lane_id', 'gate']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $gate_name = strtoupper(trim((string)$this->getParameter('gate'))); $gate = match ($gate_name) { 'ENTRANCE' => selfserve_lane_port::ENTRANCE, 'EXIT' => selfserve_lane_port::EXIT, default => null, }; if ($gate === null) { $response->error('Invalid gate value. Expected ENTRANCE or EXIT.', 400); } $toggle_after = $this->requestedRelayToggleAfter(1); $lane = $selfserve->lane($lane_id); try { $this->applyShellyTransportOverride($lane); $queued_gate_command = null; if ($this->requestedShellyTransportOverride() === 'local') { $queued_gate_command = $this->queueLocalLaneGateOpen($lane, $gate, $toggle_after); } else { $lane->open($gate, $toggle_after); } $response->success([ 'lane_id' => $lane_id, 'gate' => $gate->name, 'opened' => true, 'queued' => $queued_gate_command !== null, 'batch' => $queued_gate_command, 'toggle_after' => $toggle_after, 'state' => $lane->getLaneState()->name, 'transport' => $this->requestedShellyTransportOverride(), ], $queued_gate_command !== null ? 202 : 200); } catch (\Exception $e) { error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage()); $response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400); } }, [ 'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane' ]); /** Modules > Self Serve > Lane > Hardware > Batch status */ $this->post('/modules/self-serve/lane/hardware/batch/status', function () { global $response; $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 { $this->applyShellyTransportOverride($lane); $targets = self::isParametersSet(['targets']) ? (array)$this->getParameter('targets') : [ 'MACHINE', 'PROGRAM_PICKER', 'CLEANER', 'ENTRANCE', 'EXIT', ]; $requests = $this->buildLaneHardwareBatchRequests($lane, $targets, false); $user = (new authentication())->get_user(); $result = (new edge_gateway_manager())->queueRelayStatusBatch( $this->departmentIdForLane($lane), $requests, $user instanceof users_o ? (int)$user->id : null, $this->requestedShellyTransportOverride() === 'local' ); $response->success($result, 202); } catch (\Exception $e) { $response->error('Failed to queue lane hardware status batch: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_relay_machine_status_view' => 'Queue self-serve lane hardware status batch' ]); /** Modules > Self Serve > Lane > Hardware > Batch set/open */ $this->post('/modules/self-serve/lane/hardware/batch/set', function () { global $response; $selfserve = new selfserve(); self::requireParameters(['lane_id', 'commands']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); $commands = (array)$this->getParameter('commands'); $lane = $selfserve->lane($lane_id); try { $this->applyShellyTransportOverride($lane); $requests = $this->buildLaneHardwareBatchRequests($lane, $commands, true); $user = (new authentication())->get_user(); $result = (new edge_gateway_manager())->queueRelaySwitchBatch( $this->departmentIdForLane($lane), $requests, $user instanceof users_o ? (int)$user->id : null, $this->requestedShellyTransportOverride() === 'local' ); $response->success($result, 202); } catch (\Exception $e) { $response->error('Failed to queue lane hardware command batch: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_relay_machine_status_set' => 'Queue self-serve lane hardware command batch' ]); /** Modules > Self Serve > Lane > Hardware > Batch poll */ $this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () { global $response; self::requirePermission('modules_selfserve_lane_relay_machine_status_view'); try { $response->success((new edge_gateway_manager())->relayBatchStatus((string)$this->fromRoute('batch_id'))); } catch (\Exception $e) { $response->error('Failed to get lane hardware batch status: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_relay_machine_status_view' => 'Poll self-serve lane hardware batch' ]); /** 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 { $this->applyShellyTransportOverride($lane); $status = $lane->getMachineProgramPickerRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status)); } 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 { $this->applyShellyTransportOverride($lane); $lane->setMachineProgramPickerRelayStatus((bool)$on); $status = $lane->getMachineProgramPickerRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status, [ 'requested_on' => (bool)$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 { $this->applyShellyTransportOverride($lane); $status = $lane->getMachineCleanerRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status)); } 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 { $this->applyShellyTransportOverride($lane); $lane->setMachineCleanerRelayStatus((bool)$on); $status = $lane->getMachineCleanerRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status, [ 'requested_on' => (bool)$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 { $this->applyShellyTransportOverride($lane); $status = $lane->getMachineRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status)); } 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 { $this->applyShellyTransportOverride($lane); $lane->setMachineRelayStatus((bool)$on); // Keep lane cache state aligned with the latest explicit relay action try { $machine_relay_id = ''; if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); } $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); $lane->setLaneState((bool)$on ? ($is_demo_machine_relay ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON) : ($is_demo_machine_relay ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF) ); } catch (\Throwable $ignored) {} $status = $lane->getMachineRelayStatus(); $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status, [ 'requested_on' => (bool)$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 { $this->applyShellyTransportOverride($lane); $lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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 { $this->applyShellyTransportOverride($lane); $lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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; $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); $customer_number = $this->resolveEffectiveCustomerNumber(); self::requirePermission('modules_selfserve_lane_relay_enable_machine'); try { $this->applyShellyTransportOverride($lane); $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); // A started wash should also turn on cleaner when configured. try { if ( !empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_cleaner_id) && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' ) { $lane->setMachineCleanerRelayStatusHard(true); } } catch (\Throwable $ignored) {} $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE', 'enabled' => true, 'duration' => $duration, 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $msg = $e->getMessage(); // If gating prevented activation, respond with 403 if (str_contains(strtoupper($msg), 'NOT ALLOWED')) { $response->error($msg, 403); } $response->error('Failed to enable MACHINE relay: ' . $msg, 400); } }, [ '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 { $this->applyShellyTransportOverride($lane); $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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 { $this->applyShellyTransportOverride($lane); $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'disabled' => true, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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 { $this->applyShellyTransportOverride($lane); $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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 { $this->applyShellyTransportOverride($lane); $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER); $response->success([ 'lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'disabled' => true, 'transport' => $this->requestedShellyTransportOverride(), ]); } 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; self::requirePermission('modules_selfserve_lane_force_machine_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); } $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null; $lane = $selfserve->lane($lane_id); try { $this->applyShellyTransportOverride($lane); // If lane looks idle/available, mark as occupied and in wash, set timers if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) { $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); } if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); } if ($license_plate !== null && $license_plate !== '') { $lane->setLicensePlate($license_plate); } if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } // A started wash should always have cleaner relay enabled when configured. try { if ( !empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_cleaner_id) && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' ) { $lane->setMachineCleanerRelayStatusHard(true); } } catch (\Throwable $ignored) {} // Force enable the machine relay (bypass gating) $lane->forceTurnOnMachineRelay($duration); // Reflect relay state explicitly try { $machine_relay_id = ''; if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); } $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); $lane->setLaneState( $is_demo_machine_relay ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON ); } catch (\Throwable $ignored) {} $response->success([ 'lane_id' => $lane_id, 'forced' => true, 'machine' => 'ENABLED', 'duration' => $duration, 'status' => $lane->getLaneStatus()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $response->error('Failed to force-enable MACHINE: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_force_machine_enable' => 'Force enable MACHINE relay and mark lane as in-wash (superusers only)' ]); /** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */ $this->post('/modules/self-serve/lane/force/machine/disable', function () { global $response; self::requirePermission('modules_selfserve_lane_force_machine_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); $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null; $lane = $selfserve->lane($lane_id); try { $this->applyShellyTransportOverride($lane); // Ensure lane reflects an ongoing wash, but with machine off if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) { $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); } if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); } if ($license_plate !== null && $license_plate !== '') { $lane->setLicensePlate($license_plate); } if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } // A started wash should always have cleaner relay enabled when configured. try { if ( !empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_cleaner_id) && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' ) { $lane->setMachineCleanerRelayStatusHard(true); } } catch (\Throwable $ignored) {} // Turn off the machine relay (do not swallow errors) $lane->forceTurnOffMachineRelay(); // Reflect relay state explicitly try { $machine_relay_id = ''; if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); } $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); $lane->setLaneState( $is_demo_machine_relay ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF ); } catch (\Throwable $ignored) {} $response->success([ 'lane_id' => $lane_id, 'forced' => true, 'machine' => 'DISABLED', 'status' => $lane->getLaneStatus()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $response->error('Failed to force-disable MACHINE: ' . $e->getMessage(), 400); } }, [ 'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)' ]); } private function requireInProgressWashDetailsAccess(int $lane_id): ?array { global $response; if (!$this->hasAuthenticatedUserOrSubuser()) { $response->error('Authentication failed. Invalid or missing token.', 401); } if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) { $department_lane = (new department_lanes_o())->select($lane_id); if (!$department_lane->exists()) { $response->error('Department lane not found', 404); } self::requireDepartmentAccess((string)$department_lane->department->value()); return null; } $customer_number = $this->resolveEffectiveCustomerNumber(); if ( $customer_number !== null && $customer_number > 0 && self::hasPermission($this->customerSelfServeUsePermission(), $customer_number) ) { return [ 'customer_number' => (int)$customer_number, 'subuser_id' => $this->authenticatedSubuserId(), ]; } $this->emitForbidden([ 'modules_selfserve_lane_wash_in_progress_view', $this->customerSelfServeUsePermission(), ]); return null; } /** * Customers poll all visible lanes to restore their own active wash. Keep that * poll successful without exposing another customer's session details. * * @param array $payload * @return array */ protected function scopeInProgressWashResponseForCustomer(array $payload, mixed $customer_scope): array { $scope = $this->normalizeCustomerScope($customer_scope); $customer_number = $scope['customer_number']; $subuser_id = $scope['subuser_id']; if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) { return $payload; } $session_customer_number = $this->extractInProgressWashCustomerNumber($payload); $session_subuser_id = $this->extractInProgressWashSubuserId($payload); if ($session_customer_number === $customer_number && ($subuser_id === null || $session_subuser_id === $subuser_id)) { return $payload; } return [ 'lane_id' => (int)($payload['lane_id'] ?? 0), 'in_progress' => true, 'session' => null, 'customer' => null, 'vehicle' => null, ]; } /** * @return array{customer_number:?int,subuser_id:?int} */ private function normalizeCustomerScope(mixed $customer_scope): array { if (is_array($customer_scope)) { return [ 'customer_number' => $this->normalizePositiveInt($customer_scope['customer_number'] ?? null), 'subuser_id' => $this->normalizePositiveInt($customer_scope['subuser_id'] ?? null), ]; } return [ 'customer_number' => $this->normalizePositiveInt($customer_scope), 'subuser_id' => null, ]; } /** * @param array $payload */ private function extractInProgressWashCustomerNumber(array $payload): ?int { $candidates = [ $payload['session']['customer_number'] ?? null, $payload['customer']['customer_number'] ?? null, ]; foreach ($candidates as $candidate) { if ($candidate === null || $candidate === '') { continue; } $customer_number = (int)$candidate; if ($customer_number > 0) { return $customer_number; } } return null; } /** * @param array $payload */ private function extractInProgressWashSubuserId(array $payload): ?int { $candidates = [ $payload['session']['subuser_id'] ?? null, $payload['session']['subuser']['id'] ?? null, $payload['subuser']['id'] ?? null, ]; foreach ($candidates as $candidate) { $subuser_id = $this->normalizePositiveInt($candidate); if ($subuser_id !== null) { return $subuser_id; } } return null; } /** * @return array{customer_number:int,subuser_id:?int} */ private function requireMyActiveWashPrincipalScope(): array { global $response; if (!$this->hasAuthenticatedUserOrSubuser()) { $response->error('Authentication failed. Invalid or missing token.', 401); } $customer_number = $this->resolveEffectiveCustomerNumber(); if (!self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)) { $this->emitForbidden([$this->customerSelfServeUsePermission()]); } if ($customer_number === null || $customer_number <= 0) { $response->error('No customer number found for authenticated user.', 404); } return [ 'customer_number' => (int)$customer_number, 'subuser_id' => $this->authenticatedSubuserId(), ]; } private function customerSelfServePermission(): \classes\permission_node { return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST); } private function customerSelfServeUsePermission(): \classes\permission_node { return self::definePermission(self::CUSTOMER_SELFSERVE_USE_PERMISSION, subusers_permission_node_key::SELFSERVE_ADD); } private function authenticatedSubuserId(): ?int { try { $subuser = (new authentication())->get_subuser(); return $subuser === false ? null : (int)$subuser->id; } catch (\Throwable) { return null; } } private function normalizePositiveInt(mixed $value): ?int { if ($value === null || $value === '') { return null; } $normalized = (int)$value; return $normalized > 0 ? $normalized : null; } private function hasAuthenticatedUserOrSubuser(): bool { $auth = new authentication(); return $auth->get_user() !== false || $auth->get_subuser() !== false; } private function findLatestActiveSelfServeSessionForCustomer(int $customer_number, ?int $subuser_id = null): selfserve_wash_sessions_o { if ($customer_number <= 0) { return new selfserve_wash_sessions_o(); } $filters = [ 'customer_number' => $customer_number, 'completed_at' => null, 'deleted_at' => null, ]; if ($subuser_id !== null) { $filters['subuser_id'] = $subuser_id; } $rows = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']); $active_statuses = $this->activeSelfServeWashSessionStatusValues(); $rows = array_values(array_filter( $rows, static fn(array $row): bool => in_array((string)($row['status'] ?? ''), $active_statuses, true) )); if ($rows === []) { return new selfserve_wash_sessions_o(); } usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); } /** * @return array */ private function activeSelfServeWashSessionStatusValues(): array { return array_map( static fn(selfserve_wash_session_status $status): string => $status->value, [ selfserve_wash_session_status::MACHINE_RELAY_ENABLED, selfserve_wash_session_status::READY_FOR_MACHINE_START, selfserve_wash_session_status::MACHINE_STARTED, selfserve_wash_session_status::PENDING_QUESTIONS, selfserve_wash_session_status::MACHINE_NOT_ALLOWED, ] ); } /** * @return array */ private function buildActiveSelfServeSessionResponse(selfserve_wash_sessions_o $session): array { $lane_id = (int)$session->lane_id->value(); $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); $subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value(); $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value(); $session_reg = trim((string)$session->reg->value()); if ($session_reg === '') { $session_reg = null; } $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value(); if ($wash_started_at === null) { $wash_started_at = $machine_start_triggered_at; } $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); $included_minutes = null; if ($machine_relay_enabled) { $included_minutes = (int)(new selfserve())->config->machine_wash_minutes_included->getVariableValue(); if ($included_minutes < 0) { $included_minutes = 0; } } return [ 'lane_id' => $lane_id, 'status' => (string)$session->status->value(), 'in_progress' => true, 'elapsed_minutes' => $session->getElapsedMinutes(), 'session' => [ 'id' => (int)$session->id, 'status' => (string)$session->status->value(), 'department_id' => $session->department_id->value() === null ? null : (int)$session->department_id->value(), 'lane_id' => $lane_id, 'reg' => $session_reg, 'customer_number' => $customer_number, 'subuser_id' => $subuser_id, 'vehicle_id' => $vehicle_id, 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), 'included_minutes' => $included_minutes ?? 0, 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), 'machine_relay_enabled' => $machine_relay_enabled, 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), 'machine_start_triggered_at' => $machine_start_triggered_at, 'wash_started_at' => $wash_started_at, 'created_at' => (string)$session->created_at->value(), 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), ], 'customer' => $this->buildInProgressWashCustomerPayload($customer_number), 'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id), 'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg), ]; } /** * @return array|null */ private function buildInProgressWashCustomerPayload(?int $customer_number): ?array { if ($customer_number === null || $customer_number <= 0) { return null; } $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number); if ($customer_obj->exists()) { return [ 'id' => (int)$customer_obj->id, 'customer_number' => $customer_number, 'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(), 'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(), 'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(), 'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(), ]; } return [ 'id' => null, 'customer_number' => $customer_number, 'display_name' => null, 'email' => null, 'phone_country_code' => null, 'phone' => null, ]; } /** * @return array|null */ private function buildInProgressWashSubuserPayload(?int $subuser_id): ?array { if ($subuser_id === null || $subuser_id <= 0) { return null; } try { $subuser = (new subusers_o())->select($subuser_id); if (!$subuser->exists()) { return [ 'id' => $subuser_id, ]; } return [ 'id' => (int)$subuser->id, 'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(), 'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(), ]; } catch (\Throwable) { return [ 'id' => $subuser_id, ]; } } /** * @return array|null */ private function buildInProgressWashVehiclePayload(?int $vehicle_id, ?string $reg): ?array { $vehicle_obj = null; if ($vehicle_id !== null && $vehicle_id > 0) { $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id); if ($tmp_vehicle->exists()) { $vehicle_obj = $tmp_vehicle; } } if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') { $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg)); if ($tmp_vehicle->exists()) { $vehicle_obj = $tmp_vehicle; } } if ($vehicle_obj === null) { return null; } return [ 'id' => (int)$vehicle_obj->id, 'customer_id' => (int)$vehicle_obj->customer_id->value(), 'type' => (int)$vehicle_obj->type->value(), 'reg' => (string)$vehicle_obj->reg->value(), 'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(), ]; } /** * @param array $status * @param array $extra * @return array */ private function buildRelayStatusResponse(int $lane_id, string $relay, array $status, array $extra = []): array { $on = (bool)($status['on'] ?? false); $switch_status = is_array($status['status'] ?? null) ? $status['status'] : []; if (!isset($switch_status['switch:0']) || !is_array($switch_status['switch:0'])) { $switch_status['switch:0'] = []; } $switch_status['switch:0']['output'] = (bool)($switch_status['switch:0']['output'] ?? $on); $payload = [ 'lane_id' => $lane_id, 'relay' => $relay, ...$extra, 'relay_id' => (string)($status['relay_id'] ?? ''), 'online' => (bool)($status['online'] ?? false), 'on' => $on, 'status' => $switch_status, 'transport' => $this->requestedShellyTransportOverride(), ]; foreach (['binding', 'execution', 'raw'] as $metadata_key) { if (isset($status[$metadata_key]) && is_array($status[$metadata_key]) && $status[$metadata_key] !== []) { $payload[$metadata_key] = $status[$metadata_key]; } } return $payload; } /** * @return array * @throws \Exception */ private function queueLocalLaneGateOpen(selfserve_lane $lane, selfserve_lane_port $gate, int $toggleAfter): array { $requests = $this->buildLaneHardwareBatchRequests($lane, [[ 'target' => $gate->name, 'action' => 'OPEN', 'toggle_after' => $toggleAfter, ]], true); $user = (new authentication())->get_user(); return (new edge_gateway_manager())->queueRelaySwitchBatch( $this->departmentIdForLane($lane), $requests, $user instanceof users_o ? (int)$user->id : null, true ); } /** * @param array $items * @return array> * @throws \Exception */ private function buildLaneHardwareBatchRequests(selfserve_lane $lane, array $items, bool $mutation): array { if (empty($lane->department_lane)) { throw new \Exception("Department lane object not found for lane ID {$lane->id}"); } $requests = []; $seen = []; foreach ($items as $item) { $command = is_array($item) ? $item : ['target' => $item]; $target = strtoupper(trim((string)($command['target'] ?? $command['relay'] ?? ''))); if ($target === '') { throw new \Exception('Lane hardware batch commands require a target'); } if (isset($seen[$target])) { throw new \Exception('Duplicate lane hardware target: ' . $target); } $seen[$target] = true; $relayId = $this->relayIdForLaneHardwareTarget($lane, $target); if ($relayId === '') { throw new \Exception('No relay is configured for lane hardware target ' . $target); } if ($mutation) { $this->requireLaneHardwareMutationPermission($target); } else { $this->requireLaneHardwareStatusPermission($target); } $request = [ 'target' => $target, 'relay_id' => $relayId, 'relayId' => $relayId, 'lane_id' => (int)$lane->id, 'laneId' => (int)$lane->id, ]; if ($mutation) { if (in_array($target, ['ENTRANCE', 'EXIT'], true)) { $action = strtoupper(trim((string)($command['action'] ?? 'OPEN'))); if ($action !== 'OPEN') { throw new \Exception($target . ' only supports action=OPEN'); } $request['on'] = true; $request['toggle_after'] = $this->normalizeBatchToggleAfter( $command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'] ?? 1, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS ); } else { if (!array_key_exists('on', $command)) { throw new \Exception($target . ' relay commands require an on boolean'); } $on = is_bool($command['on']) ? $command['on'] : filter_var($command['on'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($on === null) { throw new \Exception('Invalid on value for ' . $target . '. Expected boolean true/false.'); } $request['on'] = (bool)$on; if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) { $request['toggle_after'] = $this->normalizeBatchToggleAfter( $command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'], 86400 ); } } } $requests[] = $request; } if ($requests === []) { throw new \Exception('At least one lane hardware target is required'); } if (count($requests) > 5) { throw new \Exception('Lane hardware batches are limited to five targets'); } return $requests; } private function relayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string { $relayId = trim((string)match ($target) { 'MACHINE' => $lane->department_lane->relay_machine_id->value(), 'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => $lane->department_lane->relay_machine_program_picker_id->value(), 'CLEANER', 'MACHINE_CLEANER' => $lane->department_lane->relay_machine_cleaner_id->value(), 'ENTRANCE' => $lane->department_lane->relay_in_id->value(), 'EXIT' => $lane->department_lane->relay_out_id->value(), default => throw new \Exception('Unsupported lane hardware target: ' . $target), }); if ($relayId !== '') { return $relayId; } return $this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target); } private function edgeGatewayRelayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string { $departmentId = $this->departmentIdForLane($lane); if ($departmentId <= 0) { return ''; } $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); $laneScoped = []; $unscoped = []; foreach ($rows as $row) { $binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']); if (!$binding->exists()) { continue; } $payload = $binding->asArray(); if (!$this->edgeGatewayBindingMatchesHardwareTarget($payload, $target)) { continue; } if ($this->edgeGatewayBindingMatchesLane($payload, (int)$lane->id, $target)) { $laneScoped[] = (string)$payload['relay_id']; continue; } if ($this->edgeGatewayBindingHasConsumers($payload)) { continue; } $unscoped[] = (string)$payload['relay_id']; } $candidates = $laneScoped !== [] ? array_values(array_unique($laneScoped)) : array_values(array_unique($unscoped)); if (count($candidates) === 1) { return $candidates[0]; } if (count($candidates) > 1) { throw new \Exception('Multiple edge gateway relay bindings match lane hardware target ' . $target); } return ''; } /** * @param array $binding */ private function edgeGatewayBindingMatchesHardwareTarget(array $binding, string $target): bool { $accepted = match ($target) { 'MACHINE' => ['MACHINE'], 'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'], 'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'], 'ENTRANCE' => ['ENTRANCE', 'ENTRY'], 'EXIT' => ['EXIT'], default => [], }; if ($accepted === []) { return false; } $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $values = [ $binding['role'] ?? null, $binding['service'] ?? null, $binding['slot'] ?? null, $metadata['role'] ?? null, $metadata['relay_role'] ?? null, $metadata['service'] ?? null, $metadata['slot'] ?? null, ]; foreach ((array)($binding['services'] ?? $metadata['services'] ?? []) as $service) { $values[] = $service; } foreach ($values as $value) { if (in_array($this->normalizeLaneHardwareTargetRole($value), $accepted, true)) { return true; } } return false; } /** * @param array $binding */ private function edgeGatewayBindingMatchesLane(array $binding, int $laneId, string $target): bool { $targetRoles = match ($target) { 'MACHINE' => ['MACHINE'], 'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'], 'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'], 'ENTRANCE' => ['ENTRANCE', 'ENTRY'], 'EXIT' => ['EXIT'], default => [], }; $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? []; if (!is_array($consumers)) { return false; } foreach ($consumers as $consumer) { if (!is_array($consumer)) { continue; } if (strtolower(trim((string)($consumer['type'] ?? ''))) !== 'lane') { continue; } if ((int)($consumer['id'] ?? 0) !== $laneId) { continue; } $slot = $this->normalizeLaneHardwareTargetRole($consumer['slot'] ?? ''); if ($slot === '' || in_array($slot, $targetRoles, true)) { return true; } } return false; } /** * @param array $binding */ private function edgeGatewayBindingHasConsumers(array $binding): bool { $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? []; return is_array($consumers) && $consumers !== []; } private function normalizeLaneHardwareTargetRole(mixed $value): string { return strtoupper(str_replace(['-', ' '], '_', trim((string)($value ?? '')))); } private function requireLaneHardwareStatusPermission(string $target): void { match ($target) { 'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_view'), 'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'), 'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'), 'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'), default => throw new \Exception('Unsupported lane hardware target: ' . $target), }; } private function requireLaneHardwareMutationPermission(string $target): void { match ($target) { 'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_set'), 'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'), 'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'), 'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'), default => throw new \Exception('Unsupported lane hardware target: ' . $target), }; } private function normalizeBatchToggleAfter(mixed $value, int $max): int { $seconds = (int)$value; if ($seconds <= 0) { throw new \Exception('toggle_after must be greater than zero'); } return min($seconds, $max); } /** * @throws \Exception */ private function applyShellyTransportOverride(object $lane): void { $transport = $this->requestedShellyTransportOverride(); if ($transport === null) { return; } if (!method_exists($lane, 'setShellyTransportOverride')) { throw new \Exception('This lane does not support Shelly transport overrides.'); } $lane->setShellyTransportOverride($transport); } /** * @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed] */ private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array { return match ($command) { selfserve_lane_command::START => [true, false, false], selfserve_lane_command::STOP => [true, true, false], selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true], selfserve_lane_command::RESERVE, selfserve_lane_command::RELEASE, selfserve_lane_command::RESET => [false, false, false], }; } private function requireSelfServeLaneDepartmentOrCustomerAccess( selfserve_lane $lane, int $customer_number, bool $allow_customer_self_serve, bool $requires_active_wash = false, bool $allow_department_active_wash = false ): void { $department_id = $this->departmentIdForLane($lane); if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) { return; } if ($allow_customer_self_serve) { if ($requires_active_wash && $allow_department_active_wash) { $customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number); } else { $customer_allowed = $requires_active_wash ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false) : $this->canCustomerUseSelfServeLane($lane, $customer_number); } if ($customer_allowed) { return; } } $missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : []; if ($allow_customer_self_serve) { $missing_permissions[] = $requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(); } $this->emitForbidden($missing_permissions); } /** * @param array $permissions */ private function hasAllPermissions(array $permissions): bool { foreach ($permissions as $permission) { if (!$this->hasPermission($permission)) { return false; } } return true; } /** * @param array $elevated_permissions */ private function requireSelfServeLaneAccess( selfserve_lane $lane, int $customer_number, array $elevated_permissions, bool $requires_active_wash = false, bool $requires_operational_lane = true ): void { if ($this->hasAllPermissions($elevated_permissions)) { return; } if ($requires_active_wash) { $customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) && (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane)); } else { $customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number); } if ($customer_allowed) { return; } $this->emitForbidden([ ...$elevated_permissions, $requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(), ]); } private function requireSelfServeLaneCommandPermission( selfserve_lane $lane, int $customer_number, string $command_permission, bool $allow_customer_self_serve, bool $requires_active_wash = false, bool $allow_department_active_wash = false ): void { $elevated_permissions = [ 'modules_selfserve_lane_command_execute', $command_permission, ]; if ($this->hasAllPermissions($elevated_permissions)) { return; } if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) { $customer_allowed = $requires_active_wash ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash) : $this->canCustomerUseSelfServeLane($lane, $customer_number); if ($customer_allowed) { return; } } $this->emitForbidden( $allow_customer_self_serve ? [ ...$elevated_permissions, $requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(), ] : $elevated_permissions ); } private function requireOperatorLaneCommandPermission(selfserve_lane $lane, string $command_permission): void { if (empty($lane->department_lane) || empty($lane->department_lane->department)) { global $response; $response->error('Lane department not found', 404); } self::requireDepartmentAccess((string)$lane->department_lane->department->value()); self::requirePermission('modules_selfserve_lane_command_execute'); self::requirePermission($command_permission); } private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void { $elevated_permissions = [ 'modules_selfserve_lane_command_execute', $permission, ]; if ($this->hasAllPermissions($elevated_permissions)) { return; } if ($this->canCustomerUsePropertyGateForLane($lane, $customer_number)) { return; } $this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]); } protected function isSelfServeModuleEnabled(): bool { try { return (new selfserve())->config->enabled->isTrue(); } catch (\Throwable) { return false; } } protected function isMachineWashEnabled(): bool { try { return (new selfserve())->config->machine_wash_enabled->isTrue(); } catch (\Throwable) { return true; } } protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool { return $customer_number > 0 && $this->hasPermission($this->customerSelfServeUsePermission(), $customer_number) && $this->isLaneSelfServeOperationallyEnabled($lane); } protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool { if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) { return false; } $subuser_id = $this->authenticatedSubuserId(); try { if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) { return true; } } catch (\Throwable) { // Fall back to the persisted session lookup below. } $department_id = $this->departmentIdForLane($lane); return $department_id > 0 && $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id); } protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool { if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) { return true; } if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) { return false; } $department_id = $this->departmentIdForLane($lane); return $department_id > 0 && $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $this->authenticatedSubuserId()); } protected function canCustomerUseActiveOperationalSelfServeLane( selfserve_lane $lane, int $customer_number, bool $allow_department_active_wash = false ): bool { return $this->isLaneSelfServeOperationallyEnabled($lane) && ( $allow_department_active_wash ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) : $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number) ); } protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool { if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) { return false; } $subuser_id = $this->authenticatedSubuserId(); try { if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) { return true; } } catch (\Throwable) { // Fall back to the persisted lane session lookup below. } $active_statuses = array_map( static fn(selfserve_wash_session_status $status): string => $status->value, [ selfserve_wash_session_status::MACHINE_RELAY_ENABLED, selfserve_wash_session_status::READY_FOR_MACHINE_START, selfserve_wash_session_status::MACHINE_STARTED, selfserve_wash_session_status::PENDING_QUESTIONS, selfserve_wash_session_status::MACHINE_NOT_ALLOWED, ] ); $filters = [ 'lane_id' => (int)$lane->id, 'customer_number' => $customer_number, 'completed_at' => null, 'deleted_at' => null, ]; if ($subuser_id !== null) { $filters['subuser_id'] = $subuser_id; } $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']); foreach ($sessions as $session) { if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { return true; } } return false; } protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool { try { if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) { return false; } } catch (\Throwable) { return false; } $department_id = $this->departmentIdForLane($lane); if ($department_id <= 0) { return false; } try { $department = (new departments_o())->select($department_id); return $department->exists() && $department->getSelfServeEnabled(); } catch (\Throwable) { return false; } } protected function departmentIdForLane(selfserve_lane $lane): int { try { return (int)$lane->department_lane?->department?->value(); } catch (\Throwable) { return 0; } } protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool { if ($department_id <= 0 || $customer_number <= 0) { return false; } $active_statuses = array_map( static fn(selfserve_wash_session_status $status): string => $status->value, [ selfserve_wash_session_status::MACHINE_RELAY_ENABLED, selfserve_wash_session_status::READY_FOR_MACHINE_START, selfserve_wash_session_status::MACHINE_STARTED, selfserve_wash_session_status::PENDING_QUESTIONS, selfserve_wash_session_status::MACHINE_NOT_ALLOWED, ] ); $filters = [ 'department_id' => $department_id, 'customer_number' => $customer_number, 'completed_at' => null, 'deleted_at' => null, ]; if ($subuser_id !== null) { $filters['subuser_id'] = $subuser_id; } $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']); foreach ($sessions as $session) { if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { return true; } } foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) { if ($subuser_id !== null) { continue; } try { $lane = (new selfserve())->lane((int)$department_lane->id); if ((int)$lane->getCustomerNumber() !== $customer_number) { continue; } if ( $lane->getLaneStatus()->equals(selfserve_lane_status::OCCUPIED) || $lane->getLaneState()->equals(selfserve_lane_state::IN_WASH) ) { return true; } } catch (\Throwable) { continue; } } return false; } /** * @param array $task_ids * @return array> */ private function publishedConfigTaskServicesForLane(selfserve_lane $lane, array $task_ids): array { $department_id = $this->departmentIdForLane($lane); if ($department_id <= 0 || $task_ids === []) { return []; } $published = (new selfserve_config_versioning())->getPublishedConfig($department_id); $config = is_array($published) ? (array)($published['config'] ?? []) : []; $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : []; if ($tasks === []) { return []; } $requested_task_ids = array_fill_keys(array_map(static fn($task_id): int => (int)$task_id, $task_ids), true); $services_by_task_id = []; foreach ($tasks as $task) { if (!is_array($task)) { continue; } $task_id = (int)($task['id'] ?? $task['task_id'] ?? 0); if ($task_id <= 0 || !isset($requested_task_ids[$task_id])) { continue; } $task_department_id = (int)($task['department'] ?? $task['department_id'] ?? 0); if ($task_department_id !== 0 && $task_department_id !== $department_id) { continue; } $task_lane_id = (int)($task['lane'] ?? $task['lane_id'] ?? 0); if ($task_lane_id !== 0 && $task_lane_id !== (int)$lane->id) { continue; } $services = $task['services'] ?? []; if (is_string($services)) { $decoded = json_decode($services, true); $services = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : []; } $services_by_task_id[$task_id] = is_array($services) ? $services : []; } return $services_by_task_id; } private function requestedShellyTransportOverride(): ?string { $transport = null; foreach (['transport', 'test_transport', 'transport_mode'] as $parameter) { if (self::isParametersSet([$parameter])) { $transport = self::getParameter($parameter); break; } } $normalized = strtolower(trim((string)$transport)); if ($normalized === '') { return null; } if ($normalized === 'gateway') { return 'local'; } if (!in_array($normalized, ['local', 'cloud'], true)) { throw new \InvalidArgumentException('Invalid transport. Expected local, gateway, or cloud.'); } return $normalized; } private function requestedRelayToggleAfter(int $default_seconds): int { $toggle_after = $default_seconds; foreach (['toggle_after', 'toggleAfter', 'timer'] as $parameter) { if (self::isParametersSet([$parameter])) { $toggle_after = (int)self::getParameter($parameter); break; } } self::requireMinValue($toggle_after, 1); self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS); return $toggle_after; } private function machineStatusAuditUserName(?object $user): ?string { if (!$user) { return null; } foreach (['display_name', 'email'] as $property) { if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) { continue; } $value = trim((string)$user->{$property}->value()); if ($value !== '' && strtolower($value) !== 'unnamed') { return $value; } } if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) { $customer_number = (int)$user->customer_number->value(); if ($customer_number > 0) { return 'Kunde ' . $customer_number; } } return isset($user->id) ? 'Bruger #' . (int)$user->id : null; } private function requestedBoolean(string $parameter, bool $default = false): bool { if (!self::isParametersSet([$parameter])) { return $default; } $value = self::getParameter($parameter); if (is_bool($value)) { return $value; } if (is_int($value)) { return $value === 1; } $normalized = strtolower(trim((string)$value)); return in_array($normalized, ['1', 'true', 'yes', 'on'], true); } }