conditionEvaluator ??= new selfserve_condition_evaluator(); } public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); } /** * @param array $options * @return array */ public function previewStudioSimulation(int $departmentId, int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { $options['debug'] = true; $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); $response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); $response['simulator_version'] = 2; $response['dry_run'] = true; $response['mode'] = 'full_dry_run'; $response['config_source'] = (string)($options['config_source'] ?? 'draft'); $response['debug'] = $this->buildStudioDebugPayload($departmentId, $snapshot, $options); return $response; } public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array { $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $mutationResult = $this->withSessionMutationLock( $laneId, $snapshot['reg'], $snapshot['customer_number'], function () use ($laneId, $snapshot, $options): array { $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); $createSession = (bool)($options['create_session'] ?? true); if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) { return [ 'session' => $session, 'response' => $session->exists() ? $this->getSessionSummary((int)$session->id) : $this->formatBlockedSessionSummary($snapshot), ]; } if (!$session->exists() && !$createSession) { return [ 'session' => $session, 'response' => $this->formatSnapshotResponse($snapshot, null), ]; } if (!$session->exists()) { $session = (new selfserve_wash_sessions_o())->add( $laneId, (int)$snapshot['lane']['department'], $snapshot['machine_type']['id'] ?? null, $snapshot['customer_number'], $snapshot['reg'], $snapshot['vehicle']['id'] ?? null, $snapshot['vehicle']['type'] ?? null, $this->deriveBaseStatus($snapshot), (bool)$snapshot['allowed'], $this->buildSessionMetadata($snapshot), ); } else { $session->machine_type_id->set($snapshot['machine_type']['id'] ?? null); $session->customer_number->set($snapshot['customer_number']); $session->vehicle_id->set($snapshot['vehicle']['id'] ?? null); $session->vehicle_type_id->set($snapshot['vehicle_type_id']); $session->reg->set($snapshot['reg']); $session->allowed->set((bool)$snapshot['allowed']); $session->metadata_json->set($this->buildSessionMetadata($snapshot)); $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); } $this->syncSessionAnswers((int)$session->id, $snapshot['questions']); $this->syncSessionTasks((int)$session->id, $snapshot['tasks']); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [ 'allowed' => (bool)$snapshot['allowed'], 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], 'allowed_services' => $snapshot['allowed_services'], 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), ]); return [ 'session' => $session, 'response' => null, ]; } ); $session = $mutationResult['session']; if ($mutationResult['response'] !== null) { return $mutationResult['response']; } if ($syncRelayState) { if ($session->exists()) { $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); } } return $this->getSessionSummary((int)$session->id); } public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array { $normalizedReg = $reg === null ? null : selfserve::standardize_registration($reg); $session = $normalizedReg !== null ? $this->findLatestOpenSession($laneId, $normalizedReg) : $this->findLatestOpenSessionByLane($laneId); if (!$session->exists()) { if ($normalizedReg === null) { throw new \RuntimeException('No active self-serve wash session found for the lane.'); } $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false); if (empty($summary['session']['id'])) { throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.')); } $session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']); } $lane = (new selfserve())->lane($laneId); $effectiveReg = $normalizedReg ?? (string)$session->reg->value(); $customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) { $lane->setLaneStatus(selfserve_lane_status::OCCUPIED); } if (!$lane->getLaneState()->equals(selfserve_lane_state::IN_WASH)) { $lane->setLaneState(selfserve_lane_state::IN_WASH); } if ($effectiveReg !== '') { $lane->setLicensePlate($effectiveReg); } if ($customerNumber !== null && $customerNumber > 0) { $lane->setCustomerNumber($customerNumber); } if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } $washStartedAt = (int)$lane->getWashStartTime(); $session->markMachineStartTriggered( $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null ); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [ 'lane_id' => $laneId, 'reg' => $effectiveReg, 'customer_number' => $customerNumber, ]); $actionContext = [ 'lane_id' => $laneId, 'reg' => $effectiveReg, 'customer_number' => $customerNumber, 'session_id' => (int)$session->id, 'source_payload' => $payload, ]; try { if ($effectiveReg !== '') { $actionSnapshot = $this->buildEligibilitySnapshot( $laneId, $effectiveReg, $customerNumber, $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), ['config_source' => 'published'] ); if (is_array($actionSnapshot['evaluation_trace']['condition_results'] ?? null)) { $actionContext['condition_results'] = (array)$actionSnapshot['evaluation_trace']['condition_results']; } if (is_array($actionSnapshot['evaluation_trace']['visibility_condition_results'] ?? null)) { $actionContext['visibility_condition_results'] = (array)$actionSnapshot['evaluation_trace']['visibility_condition_results']; } $actionContext['allowed_services'] = (array)($actionSnapshot['allowed_services'] ?? []); $actionContext['vehicle_type_id'] = $actionSnapshot['vehicle_type_id'] ?? null; $actionContext['product'] = $actionSnapshot['vehicle_type_id'] ?? null; $actionContext['machine_type_id'] = $actionSnapshot['machine_type']['id'] ?? null; } } catch (\Throwable) { // Action execution should stay best-effort even when preview context cannot be rebuilt. } (new selfserve_studio_action_runner())->executeForLaneEvent( $lane, selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, selfserve_studio_actions::MODE_MACHINE, $actionContext ); $this->enableCleanerRelayForStartedWash($lane); return $this->getSessionSummary((int)$session->id); } public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool { $normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); $session = $normalizedReg !== null ? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber) : $this->findLatestOpenSessionByLane($laneId, $customerNumber); return $session->exists() && (bool)$session->machine_start_triggered->value(); } protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void { try { if ( empty($lane->department_lane) || empty($lane->department_lane->relay_machine_cleaner_id) || trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) === '' ) { return; } $lane->setMachineCleanerRelayStatusHard(true); } catch (\Throwable) { // Best effort only; webhook start flow must continue. } } protected function disableMachineRelayForCompletedWash(int $laneId): void { try { $lane = (new selfserve())->lane($laneId); if (empty($lane->department_lane)) { return; } $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE); $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER); } catch (\Throwable) { // Best effort only; session completion flow must continue. } } protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void { if (!$this->isRelayConfiguredForLane($lane, $relay)) { return; } try { $lane->setRelayStatusHard($relay, false); } catch (\Throwable) { // Best effort only; session completion flow must continue. } } protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool { if (empty($lane->department_lane)) { return false; } $relayId = match ($relay) { selfserve_lane_relay::MACHINE => (string)$lane->department_lane->relay_machine_id->value(), selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$lane->department_lane->relay_machine_program_picker_id->value(), selfserve_lane_relay::MACHINE_CLEANER => (string)$lane->department_lane->relay_machine_cleaner_id->value(), }; return trim($relayId) !== ''; } public function getSessionSummary(int $sessionId): array { $session = (new selfserve_wash_sessions_o())->select($sessionId); if (!$session->exists()) { throw new \RuntimeException('Self-serve wash session not found.'); } $lane = (new department_lanes_o())->select((int)$session->lane_id->value()); $machineType = null; if ($session->machine_type_id->value() !== null) { $machineTypeObject = (new selfserve_machine_types_o())->select((int)$session->machine_type_id->value()); if ($machineTypeObject->exists()) { $machineType = $machineTypeObject->asArray(); } } $answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId); $answers = $this->buildSessionQuestions($session, $answerRows); $metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : []; $allowedServices = $this->normalizeServiceNames( is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : [] ); $machineWashEnabled = $this->isMachineWashEnabled(); if (!$machineWashEnabled) { $allowedServices = $this->withoutMachineService($allowedServices); } $machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata) ? (bool)$metadata['machine_available'] : ($lane->exists() && !empty($lane->relay_machine_id->value()))); $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata) ? (bool)$metadata['all_visible_questions_answered'] : true; if (!array_key_exists('all_visible_questions_answered', $metadata)) { foreach ($answers as $answer) { if (($answer['answer'] ?? null) === null) { $allVisibleQuestionsAnswered = false; break; } } } $tasks = array_map(function (array $row): array { return [ 'task_id' => $row['task_id'] === null ? null : (int)$row['task_id'], 'task' => (string)$row['task_text'], 'description' => $row['description'] === null ? null : (string)$row['description'], 'services' => $this->normalizeJsonArray($row['services'] ?? null), 'buttons' => $this->normalizeJsonArray($row['buttons'] ?? null), 'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type'] ]; }, (new selfserve_wash_session_tasks_o())->listBySession($sessionId)); if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) { $tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices); } $tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks); $events = array_map(function (array $row): array { return [ 'id' => (int)$row['id'], 'type' => (string)$row['event_type'], 'payload' => $this->normalizeJsonValue($row['payload_json'] ?? null), 'created_at' => (string)$row['created_at'], ]; }, (new selfserve_wash_session_events_o())->listBySession($sessionId)); return [ 'session' => $session->asArray(), 'lane' => $lane->exists() ? $lane->asArray() : null, 'machine_type' => $machineType, 'questions' => $answers, 'tasks' => $tasks, 'events' => $events, 'allowed_services' => $allowedServices, 'machine_available' => $machineAvailable, 'machine_wash_enabled' => $machineWashEnabled, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'allowed' => (bool)$session->allowed->value(), 'config_version_id' => $metadata['config_version_id'] ?? null, 'evaluation_trace' => $metadata['evaluation_trace'] ?? null, ]; } public function getLatestSessionSummary(int $laneId, string $reg): array { $session = (new selfserve_wash_sessions_o())->selectLatestByLaneAndReg($laneId, selfserve::standardize_registration($reg)); if (!$session->exists()) { throw new \RuntimeException('No self-serve wash session found for the lane and vehicle.'); } return $this->getSessionSummary((int)$session->id); } public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array { $session = $reg !== null ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) : $this->findLatestOpenSessionByLane($laneId, $customerNumber); if (!$session->exists()) { return null; } if (!$session->markCompletedIfOpen($orderId)) { return $this->getSessionSummary((int)$session->id); } if ($disableRelays) { $this->disableMachineRelayForCompletedWash($laneId); } $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [ 'lane_id' => $laneId, 'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg), 'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()), 'order_id' => $orderId, ]); return $this->getSessionSummary((int)$session->id); } public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array { $lane = (new selfserve())->lane($laneId); $session = $this->resolveForceStopSession($laneId, $sessionId); $runtimeSnapshot = $this->buildForceStopRuntimeSnapshot($lane); $hasRuntime = $this->laneRuntimeLooksActive($runtimeSnapshot); if (!$session->exists() && !$hasRuntime) { throw new \RuntimeException('No active self-serve wash session or lane runtime found.'); } $orderId = null; if ($bill) { try { if ($lane->invoice() !== true) { throw new \RuntimeException('Elapsed-minute invoice was not created.'); } $orderId = method_exists($lane, 'getLastInvoiceOrderId') ? $lane->getLastInvoiceOrderId() : null; } catch (\Throwable $e) { throw new \RuntimeException('Failed to bill elapsed minutes before force stop: ' . $e->getMessage(), 409, $e); } } $summary = null; if ($session->exists()) { $eventPayload = [ 'lane_id' => $laneId, 'reason' => $reason, 'user_id' => $userId, 'bill' => $bill, 'order_id' => $orderId, 'runtime_before_reset' => $runtimeSnapshot, 'forced_at' => date('Y-m-d H:i:s'), ]; if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) { $summary = $this->getSessionSummary((int)$session->id); } else { $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload); $summary = $this->getSessionSummary((int)$session->id); } } $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); return [ 'lane_id' => $laneId, 'forced' => true, 'bill' => $bill, 'order_id' => $orderId, 'session' => $summary, 'runtime_before_reset' => $runtimeSnapshot, ]; } /** * @param array $options */ protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { $normalizedReg = selfserve::standardize_registration($reg); $lane = (new department_lanes_o())->select($laneId); if (!$lane->exists()) { throw new \RuntimeException('Department lane not found.'); } $departmentId = (int)$lane->department->value(); $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(); if (!$lane->isSelfServeEnabled()) { $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); return [ 'lane' => $lane->asArray(), 'machine_type' => null, 'vehicle' => $vehicleData, 'reg' => $normalizedReg, 'customer_number' => $resolvedCustomerNumber, 'vehicle_type_id' => $vehicleTypeId, 'answers' => [], 'persisted_answers' => [], 'persisted_answer_customer_number' => null, 'answer_overrides' => [], 'answer_sources' => [], 'questions' => [], 'conditions' => [], 'tasks' => [], 'allowed_services' => [], 'machine_available' => false, 'all_visible_questions_answered' => false, 'allowed' => false, 'blocked_reason' => 'Self-serve is disabled for this lane.', 'config_version_id' => null, 'config_source' => (string)($options['config_source'] ?? 'published'), 'evaluation_trace' => [ 'blocked' => true, 'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'], 'disabled_lane' => true, 'message' => 'Self-serve is disabled for this lane.', 'visibility_condition_results' => [], 'condition_results' => [], 'visibility_expression_traces' => [], 'condition_expression_traces' => [], 'task_gates' => [], 'visible_question_ids' => [], ], 'debug_candidates' => [ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [], 'actions' => [], 'visible_answers' => [], ], ]; } $configSource = (string)($options['config_source'] ?? 'published'); $publishedConfigVersionId = $options['config_version_id'] ?? null; $publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null; $versioning = new selfserve_config_versioning(); if ($publishedConfigPayload === null) { $publishedConfig = $versioning->getPublishedV2Config($departmentId); $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; $configSource = $publishedConfigPayload === null ? 'legacy' : 'published'; } $isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload); $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); $persistedAnswerCustomerNumber = $this->resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber); $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload); $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); $rules = $this->loadConditionRules($conditions, $publishedConfigPayload); $persistedAnswers = $this->loadPersistedAnswers($departmentId, $laneId, $normalizedReg, $persistedAnswerCustomerNumber); $answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []); $answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides); $answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides); if ($isV2Config) { $visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers); $visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []); $visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []); } else { $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); $visibilityExpressionTrace = []; } $visibleQuestions = []; $visibleQuestionIds = []; foreach ($questions as $question) { $gateId = $this->nullableInt($question['condition_id'] ?? null); if ($gateId !== null && (($visibilityConditionResults[$gateId] ?? false) !== true)) { continue; } $questionId = (int)$question['id']; $visibleQuestionIds[] = $questionId; $visibleQuestions[] = [ 'id' => $questionId, 'question' => (string)$question['question'], 'description' => (string)($question['description'] ?? ''), 'condition_id' => $gateId, 'order_priority' => (int)($question['order_priority'] ?? 0), 'answer' => array_key_exists($questionId, $answers) ? $answers[$questionId] : null, 'answer_source' => $answerSources[$questionId] ?? 'missing', ]; } usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); if ($isV2Config) { $serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers); $serviceConditionResults = (array)($serviceEvaluation['results'] ?? []); $serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []); } else { $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); $serviceExpressionTrace = []; } $tasks = (new selfserve_task_attachment_payloads())->attachToTasks( $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload) ); $activeTasks = []; $taskGateTrace = []; $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions); foreach ($tasks as $task) { $gateId = $this->nullableInt($task['condition_id'] ?? null); $resolvedGate = $this->resolveTaskGate($task, $conditionIds); $typedGateType = $resolvedGate['gate_type']; $typedGateRefId = $resolvedGate['gate_ref_id']; $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped( $typedGateType->value, $typedGateRefId, $serviceConditionResults, $visibleAnswers ); $taskGateTrace[] = [ 'task_id' => (int)$task['id'], 'legacy_gate_id' => $gateId, 'gate_type' => $typedGateType->value, 'gate_ref_id' => $typedGateRefId, 'satisfied' => $gateSatisfied, ]; if (!$gateSatisfied) { continue; } $activeTasks[] = [ 'id' => (int)$task['id'], 'task' => (string)$task['task'], 'description' => (string)($task['description'] ?? ''), 'condition_id' => $gateId, 'gate_type' => $typedGateType->value, 'gate_ref_id' => $typedGateRefId, 'order_priority' => (int)($task['order_priority'] ?? 0), 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], 'attachments' => $task['attachments'] ?? [], ]; } usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); $allowedServices = []; foreach ($activeTasks as $task) { foreach ($task['services'] as $service) { if (!in_array($service, $allowedServices, true)) { $allowedServices[] = $service; } } } $machineWashEnabled = $this->isMachineWashEnabled(); if (!$machineWashEnabled) { $allowedServices = $this->withoutMachineService($allowedServices); } $machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value()); $allVisibleQuestionsAnswered = true; foreach ($visibleQuestions as $question) { if ($question['answer'] === null) { $allVisibleQuestionsAnswered = false; break; } } $machineAllowed = $allVisibleQuestionsAnswered && $machineAvailable && in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true); $visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices); $machineType = null; if ($machineTypeId !== null) { $machineTypeObject = (new selfserve_machine_types_o())->select($machineTypeId); if ($machineTypeObject->exists()) { $machineType = $machineTypeObject->asArray(); } } return [ 'lane' => $lane->asArray(), 'machine_type' => $machineType, 'vehicle' => $vehicleData, 'reg' => $normalizedReg, 'customer_number' => $resolvedCustomerNumber, 'vehicle_type_id' => $vehicleTypeId, 'answers' => $answers, 'persisted_answers' => $persistedAnswers, 'persisted_answer_customer_number' => $persistedAnswerCustomerNumber, 'answer_overrides' => $answerOverrides, 'answer_sources' => $answerSources, 'questions' => $visibleQuestions, 'conditions' => $serviceConditionResults, 'tasks' => $visibleTasks, 'allowed_services' => $allowedServices, 'machine_available' => $machineAvailable, 'machine_wash_enabled' => $machineWashEnabled, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'allowed' => $machineAllowed, 'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null, 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, 'config_source' => $configSource, 'evaluation_trace' => [ 'visibility_condition_results' => $visibilityConditionResults, 'condition_results' => $serviceConditionResults, 'visibility_expression_traces' => $visibilityExpressionTrace, 'condition_expression_traces' => $serviceExpressionTrace, 'task_gates' => $taskGateTrace, 'visible_question_ids' => $visibleQuestionIds, ], 'debug_candidates' => [ 'questions' => $questions, 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, 'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [], 'visible_answers' => $visibleAnswers, ], ]; } protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void { $laneId = (int)$snapshot['lane']['id']; $lane = (new selfserve())->lane($laneId); $this->enableCleanerRelayForStartedWash($lane); if ((bool)$session->machine_relay_enabled->value() === true) { return; } $session->markRelayEnabled(); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [ 'lane_id' => $laneId, 'reg' => $snapshot['reg'], 'allowed_services' => $snapshot['allowed_services'], 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), ]); } protected function syncMachineRelayFromVisibleServices(array $snapshot, selfserve_wash_sessions_o $session, bool $allowEnable): void { $laneId = (int)$snapshot['lane']['id']; $lane = (new selfserve())->lane($laneId); $sync = $lane->syncMachineRelayFromVisibleServices( is_array($snapshot['allowed_services'] ?? null) ? $snapshot['allowed_services'] : [], $allowEnable ); if (($sync['relay_action'] ?? '') === 'enabled') { $this->enableMachineRelayIfAllowed($snapshot, $session); } $relayTargetOn = (bool)($sync['relay_target_on'] ?? false); if (!$relayTargetOn && (bool)$session->machine_relay_enabled->value() === true) { $session->markRelayDisabled(); $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); } } protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status { if (!$snapshot['all_visible_questions_answered']) { return selfserve_wash_session_status::PENDING_QUESTIONS; } if ($snapshot['allowed']) { return selfserve_wash_session_status::READY_FOR_MACHINE_START; } return selfserve_wash_session_status::MACHINE_NOT_ALLOWED; } protected function deriveCurrentStatus(array $snapshot, selfserve_wash_sessions_o $session): selfserve_wash_session_status { if ($session->completed_at->value() !== null) { return selfserve_wash_session_status::COMPLETED; } if ((bool)$session->machine_start_triggered->value() === true) { return selfserve_wash_session_status::MACHINE_STARTED; } if ((bool)$session->machine_relay_enabled->value() === true) { return selfserve_wash_session_status::MACHINE_RELAY_ENABLED; } return $this->deriveBaseStatus($snapshot); } protected function formatSnapshotResponse(array $snapshot, ?array $session = null): array { return [ 'lane' => $snapshot['lane'], 'machine_type' => $snapshot['machine_type'], 'vehicle' => $snapshot['vehicle'], 'reg' => $snapshot['reg'], 'customer_number' => $snapshot['customer_number'], 'vehicle_type_id' => $snapshot['vehicle_type_id'], 'questions' => $snapshot['questions'], 'tasks' => $snapshot['tasks'], 'allowed_services' => $snapshot['allowed_services'], 'machine_available' => $snapshot['machine_available'], 'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true, 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'allowed' => $snapshot['allowed'], 'blocked_reason' => $snapshot['blocked_reason'] ?? null, 'session' => $session, 'config_version_id' => $snapshot['config_version_id'] ?? null, 'config_source' => $snapshot['config_source'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, ]; } protected function formatBlockedSessionSummary(array $snapshot): array { return [ 'session' => null, 'lane' => $snapshot['lane'], 'machine_type' => $snapshot['machine_type'], 'questions' => [], 'tasks' => [], 'events' => [], 'allowed' => false, 'allowed_services' => [], 'machine_available' => false, 'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.', 'config_version_id' => $snapshot['config_version_id'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, ]; } /** * @param array $snapshot * @param array $options * @return array */ public function buildStudioDebugPayload(int $departmentId, array $snapshot, array $options = []): array { $lookups = is_array($options['lookups'] ?? null) ? (array)$options['lookups'] : []; $candidates = is_array($snapshot['debug_candidates'] ?? null) ? (array)$snapshot['debug_candidates'] : []; $questions = $this->buildDebugQuestions($snapshot, (array)($candidates['questions'] ?? []), $lookups); $rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups); $conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups); $tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? [])); $actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups); $hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions); $dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks); $decisions = $this->buildDebugDecisions( $questions, $conditions, $rules, $tasks, $actions, (array)($hardware['signal_timeline'] ?? []), $dynamicImageButtons ); $recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules); $summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware); $stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups); $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? [])); return [ 'summary' => $summary, 'parameters' => [ 'department_id' => $departmentId, 'department' => $this->debugLabel($lookups, 'departments', $departmentId, 'Department ' . $departmentId), 'lane_id' => (int)($snapshot['lane']['id'] ?? 0), 'lane' => $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, (string)($snapshot['lane']['name'] ?? 'Lane')), 'machine_type_id' => $snapshot['machine_type']['id'] ?? null, 'machine_type' => $this->debugLabel($lookups, 'machine_types', $snapshot['machine_type']['id'] ?? null, 'No machine type'), 'vehicle_type_id' => $snapshot['vehicle_type_id'], 'vehicle_type' => $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'Auto'), 'registration' => $snapshot['reg'], 'customer_number' => $snapshot['customer_number'], 'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'), 'config_version_id' => $snapshot['config_version_id'] ?? null, 'hardware_mode' => $options['hardware_mode'] ?? 'studio', 'mode' => 'full_dry_run', 'dry_run' => true, ], 'stages' => $stages, 'questions' => $questions, 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, 'actions' => $actions, 'dynamic_image_buttons' => $dynamicImageButtons, 'decisions' => $decisions, 'hardware' => $hardware, 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), 'graph_annotations' => $annotations, 'recommendations' => $recommendations, ]; } /** * @param mixed $raw * @return array */ protected function normalizeAnswerOverrides(mixed $raw): array { $overrides = []; if (!is_array($raw)) { return $overrides; } foreach ($raw as $key => $entry) { if (is_array($entry)) { $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); $value = $entry['value'] ?? $entry['answer'] ?? null; } else { $questionId = (int)$key; $value = $entry; } if ($questionId <= 0) { continue; } if ($value === null || $value === '' || strtolower((string)$value) === 'unset' || strtolower((string)$value) === 'null') { $overrides[$questionId] = null; continue; } $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $overrides[$questionId] = $parsed; } return $overrides; } /** * @param array $answers * @param array $overrides * @return array */ protected function applyAnswerOverrides(array $answers, array $overrides): array { foreach ($overrides as $questionId => $value) { $answers[(int)$questionId] = $value; } return $answers; } /** * @param array $answers * @param array $overrides * @return array */ protected function buildAnswerSources(array $answers, array $overrides): array { $sources = []; foreach ($answers as $questionId => $_value) { $sources[(int)$questionId] = 'saved'; } foreach ($overrides as $questionId => $_value) { $sources[(int)$questionId] = 'override'; } return $sources; } protected function resolvePersistedAnswerCustomerNumber(?int $resolvedCustomerNumber): ?int { if ($resolvedCustomerNumber === null || $resolvedCustomerNumber <= 0) { return null; } return $resolvedCustomerNumber; } /** * @return array */ protected function loadPersistedAnswers(int $departmentId, int $laneId, string $reg, ?int $customerNumber): array { if ($customerNumber === null || $customerNumber <= 0) { return []; } return (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle( $departmentId, $laneId, $reg, $customerNumber ); } /** * @param array $snapshot * @param array> $questions * @param array $lookups * @return array> */ protected function buildDebugQuestions(array $snapshot, array $questions, array $lookups): array { $visibleIds = array_flip(array_map('intval', (array)($snapshot['evaluation_trace']['visible_question_ids'] ?? []))); $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; $sources = is_array($snapshot['answer_sources'] ?? null) ? (array)$snapshot['answer_sources'] : []; $conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['visibility_condition_results'] : []; $expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['visibility_expression_traces'] : []; $items = []; foreach ($questions as $question) { $questionId = (int)($question['id'] ?? 0); if ($questionId <= 0) { continue; } $gateId = $this->nullableInt($question['condition_id'] ?? null); $visible = isset($visibleIds[$questionId]); $answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null; $state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered'); $label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)); $conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId); $gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true); $causes = []; if ($gateId !== null) { $causes[] = $this->debugCause( 'condition', $gateId, $conditionLabel, true, ($conditionResults[$gateId] ?? null), $this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null) ); } if ($visible && $answer === null) { $causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.'); } $reason = $visible ? ($answer === null ? 'Question ' . $label . ' is visible but has no answer.' : 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.') : 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.'; $items[] = [ 'kind' => 'question', 'id' => $questionId, 'node_id' => 'question:' . $questionId, 'node_ids' => ['question:' . $questionId], 'label' => $label, 'visible' => $visible, 'state' => $state, 'answer' => $answer, 'answer_source' => $sources[$questionId] ?? 'missing', 'condition_id' => $gateId, 'condition' => $conditionLabel, 'gate_satisfied' => $gateSatisfied, 'reason' => $reason, 'causes' => $causes, 'order_priority' => (int)($question['order_priority'] ?? 0), ]; } usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); return $items; } /** * @param array $snapshot * @param array> $rules * @param array $lookups * @return array> */ protected function buildDebugRules(array $snapshot, array $rules, array $lookups): array { $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; $items = []; foreach ($rules as $rule) { $ruleId = (int)($rule['id'] ?? 0); if ($ruleId <= 0) { continue; } $objectType = strtolower((string)($rule['object_type'] ?? '')); $objectId = (int)($rule['object_id'] ?? 0); $actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null); $satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual); $lookupType = $objectType === 'condition' ? 'conditions' : 'questions'; $label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)); $objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId); $expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? '')); $invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'); $reason = $invalidReference ? 'Rule ' . $label . ' skipped because its referenced object is invalid.' : ($satisfied ? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.' : 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.'); $items[] = [ 'kind' => 'rule', 'id' => $ruleId, 'node_id' => 'rule:' . $ruleId, 'node_ids' => ['rule:' . $ruleId], 'condition_id' => (int)($rule['condition_id'] ?? 0), 'label' => $label, 'type' => (string)($rule['type'] ?? ''), 'object_type' => $objectType, 'object_id' => $objectId, 'object_label' => $objectLabel, 'actual_value' => $actual, 'satisfied' => $satisfied, 'invalid_reference' => $invalidReference, 'reason' => $reason, 'causes' => [ $this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null), ], ]; } return $items; } /** * @param array $snapshot * @param array> $conditions * @param array> $rules * @param array $lookups * @return array> */ protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array { $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; $expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : []; $cycleIds = $this->detectConditionCycles($conditions, $rules); $rulesByCondition = []; foreach ($rules as $rule) { $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; } $items = []; foreach ($conditions as $condition) { $conditionId = (int)($condition['id'] ?? 0); if ($conditionId <= 0) { continue; } $result = ($conditionResults[$conditionId] ?? false) === true; $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []; $expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null; $nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups); $label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)); $causes = []; $failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups); if ($failedExpressionCause !== null) { $causes[] = $failedExpressionCause; } elseif (!$result) { foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) { if (($rule['satisfied'] ?? false) !== true) { foreach ((array)($rule['causes'] ?? []) as $cause) { if (is_array($cause)) { $causes[] = $cause; } } break; } } } $items[] = [ 'kind' => 'condition', 'id' => $conditionId, 'node_id' => 'condition:' . $conditionId, 'node_ids' => ['condition:' . $conditionId], 'label' => $label, 'result' => $result, 'state' => $result ? 'passed' : 'failed', 'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null), 'rules' => array_values($rulesByCondition[$conditionId] ?? []), 'expression' => $expression, 'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups), 'expression_trace' => $expressionTrace, 'next_fix' => $nextFix, 'has_cycle' => in_array($conditionId, $cycleIds, true), 'reason' => in_array($conditionId, $cycleIds, true) ? 'Condition dependency cycle detected.' : ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')), 'causes' => $causes, ]; } return $items; } /** * @param array $snapshot * @param array> $tasks * @param array $lookups * @param array $gatewayWorkspace * @return array> */ protected function buildDebugTasks(array $snapshot, array $tasks, array $lookups, array $gatewayWorkspace): array { $activeIds = array_flip(array_map(static fn(array $task): int => (int)($task['id'] ?? 0), (array)($snapshot['tasks'] ?? []))); $activeAttachments = []; foreach ((array)($snapshot['tasks'] ?? []) as $task) { if (!is_array($task)) { continue; } $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); if ($taskId > 0 && is_array($task['attachments'] ?? null)) { $activeAttachments[$taskId] = array_values($task['attachments']); } } $gateTrace = []; foreach ((array)($snapshot['evaluation_trace']['task_gates'] ?? []) as $trace) { if (is_array($trace)) { $gateTrace[(int)($trace['task_id'] ?? 0)] = $trace; } } $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; $visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null) ? (array)$snapshot['debug_candidates']['visible_answers'] : (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []); $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); $items = []; foreach ($tasks as $task) { $taskId = (int)($task['id'] ?? 0); if ($taskId <= 0) { continue; } $trace = $gateTrace[$taskId] ?? []; $services = $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)); $bindings = []; foreach ($services as $service) { foreach ($bindingsByService[$service] ?? [] as $binding) { $bindings[] = $binding; } } $active = isset($activeIds[$taskId]); $gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value); $gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null); $gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId); $label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)); $gateSatisfied = ($trace['satisfied'] ?? false) === true; $gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers); $taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []); $items[] = [ 'kind' => 'task', 'id' => $taskId, 'node_id' => 'task:' . $taskId, 'node_ids' => ['task:' . $taskId], 'label' => $label, 'description' => (string)($task['description'] ?? ''), 'active' => $active, 'state' => $active ? 'active' : 'blocked', 'gate_type' => $gateType, 'gate_ref_id' => $gateRefId, 'gate_ref_label' => $gateRefLabel, 'gate_satisfied' => $gateSatisfied, 'services' => $services, 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], 'attachments' => $taskAttachments, 'relay_bindings' => $bindings, 'order_priority' => (int)($task['order_priority'] ?? 0), 'reason' => $gateDecision['reason'], 'causes' => $gateDecision['causes'], ]; } usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); return $items; } /** * @param array $snapshot * @param array> $actions * @param array $lookups * @return array> */ protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array { $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; $laneId = (int)($lane['id'] ?? 0); $departmentId = (int)($lane['department'] ?? 0); $vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null); $machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null); $eventModes = [ selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot), selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot), selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE, ]; $items = []; foreach ($actions as $row) { if (!is_array($row)) { continue; } $action = selfserve_studio_actions::normalize((array)$row); $actionId = (int)($action['id'] ?? 0); if ($actionId <= 0) { continue; } $expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH; $modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true); $scopeMatches = true; $scopeReason = null; $scopeCause = null; if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { $scopeMatches = false; $scopeReason = 'Action department scope does not match the simulated lane.'; $scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason); } elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { $scopeMatches = false; $scopeReason = 'Action lane scope does not match the simulated lane.'; $scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason); } elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) { $scopeMatches = false; $scopeReason = 'Action vehicle type scope does not match the simulated vehicle.'; $scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason); } elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) { $scopeMatches = false; $scopeReason = 'Action machine type scope does not match the simulated lane.'; $scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason); } $conditionId = $this->nullableInt($action['condition_id'] ?? null); $conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true); $enabled = (bool)($action['enabled'] ?? true); $active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied; $label = (string)$action['name']; $conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId); $causes = []; $reason = 'Action would run for this simulator event.'; if (!$enabled) { $reason = 'Action is disabled.'; $causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason); } elseif (!$modeMatches) { $reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.'; $causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason); } elseif (!$scopeMatches) { $reason = $scopeReason ?? 'Action scope does not match the simulated lane.'; if ($scopeCause !== null) { $causes[] = $scopeCause; } } elseif (!$conditionSatisfied) { $reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.'; $causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason); } $items[] = [ 'kind' => 'action', 'id' => $actionId, 'node_id' => 'action:' . $actionId, 'node_ids' => ['action:' . $actionId], 'label' => $label, 'active' => $active, 'state' => $active ? 'active' : 'skipped', 'reason' => $reason, 'causes' => $causes, 'event' => (string)$action['event'], 'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']), 'wash_mode' => (string)$action['wash_mode'], 'simulated_wash_mode' => $expectedMode, 'operation' => (string)$action['operation'], 'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']), 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']), 'relay_state' => $action['relay_state'], 'condition_id' => $conditionId, 'condition' => $conditionLabel, 'condition_satisfied' => $conditionSatisfied, 'scope' => [ 'department' => (int)$action['department'], 'lane' => (int)$action['lane'], 'product' => (int)$action['product'], 'machine_type_id' => $action['machine_type_id'], ], 'options' => (array)$action['options'], 'order_priority' => (int)$action['order_priority'], 'raw' => $action, ]; } usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event']) ?: ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); return $items; } /** * @param array $snapshot * @param array> $tasks * @param array $gatewayWorkspace * @param array $lookups * @param array> $actions * @return array */ protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array { $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); $allowedServices = (array)($snapshot['allowed_services'] ?? []); $missingBindings = []; foreach ($allowedServices as $service) { $service = strtoupper((string)$service); if ($service !== '' && empty($bindingsByService[$service])) { $missingBindings[] = $service; } } $laneId = (int)($snapshot['lane']['id'] ?? 0); $laneRelaySlots = []; foreach ((array)($gatewayWorkspace['lanes'] ?? []) as $lane) { if (!is_array($lane) || (int)($lane['id'] ?? 0) !== $laneId) { continue; } foreach ((array)($lane['relay_slots'] ?? []) as $slot) { if (is_array($slot)) { $laneRelaySlots[] = $slot; } } } $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions); $dryRunOperations = []; if (($snapshot['allowed'] ?? false) === true) { $dryRunOperations[] = [ 'operation' => 'machine_relay_enable', 'status' => 'predicted', 'message' => 'Dry run predicts the machine relay would be enabled.', ]; } else { $dryRunOperations[] = [ 'operation' => 'machine_relay_enable', 'status' => 'blocked', 'message' => 'Dry run predicts no machine relay action because eligibility is blocked.', ]; } return [ 'machine_relay_configured' => (bool)($snapshot['machine_available'] ?? false), 'lane_relay_slots' => $laneRelaySlots, 'allowed_services' => $allowedServices, 'service_bindings' => $bindingsByService, 'missing_service_bindings' => array_values($missingBindings), 'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])), 'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])), 'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false), 'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [], 'signal_timeline' => $signalTimeline, 'dry_run_operations' => $dryRunOperations, 'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups), ]; } /** * @param array $snapshot * @param array $gatewayWorkspace * @param array>> $bindingsByService * @param array> $laneRelaySlots * @param array> $actions * @return array> */ protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array { $timeline = []; $sequence = 1; $allowed = (bool)($snapshot['allowed'] ?? false); $machineAvailable = (bool)($snapshot['machine_available'] ?? false); $timeline[] = $this->debugSignalTimelineRow( $sequence++, 'eligibility_sync', 'session_event', 'SESSION', null, null, [ 'event' => 'SESSION_SYNCED', 'allowed' => $allowed, 'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])), ], 'none', 'sent', null ); $this->appendDebugActionSignalRows( $timeline, $sequence, $actions, selfserve_studio_actions::EVENT_WASH_START_COMMAND, $allowed, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $snapshot ); $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot); $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE'); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'eligibility_sync', 'relay_switch', 'MACHINE', $machineRelayId, $machineBinding, ['id' => $machineRelayId, 'channel' => 0, 'on' => true], $allowed && $machineAvailable, $allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.' ); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'machine_start_signal', 'shelly_event', 'MACHINE', $machineRelayId, $machineBinding, [ 'event' => 'input.toggle_on', 'alternate_event' => 'switch.on', 'input' => ['component' => 'input:0', 'state' => true], 'switch' => ['component' => 'switch:0', 'output' => true], 'bill_machine_wash' => true, ], $allowed && $machineAvailable, $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.' ); $this->appendDebugActionSignalRows( $timeline, $sequence, $actions, selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, $allowed && $machineAvailable, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $snapshot ); $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot); $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER'); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'machine_start', 'relay_switch', 'CLEANER', $cleanerRelayId, $cleanerBinding, ['id' => $cleanerRelayId, 'channel' => 0, 'on' => true], $allowed && $cleanerRelayId !== null, $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.' ); $this->appendDebugActionSignalRows( $timeline, $sequence, $actions, selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, $allowed, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $snapshot ); $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot); $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT'); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'stop', 'relay_pulse', 'EXIT', $exitRelayId, $exitBinding, ['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1], $allowed && $exitRelayId !== null, $allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.' ); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'stop', 'relay_switch', 'CLEANER', $cleanerRelayId, $cleanerBinding, ['id' => $cleanerRelayId, 'channel' => 0, 'on' => false], $allowed && $cleanerRelayId !== null, $allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.' ); $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, 'stop', 'relay_switch', 'MACHINE', $machineRelayId, $machineBinding, ['id' => $machineRelayId, 'channel' => 0, 'on' => false], $machineRelayId !== null, $machineRelayId === null ? 'Lane machine relay is not configured.' : null ); $timeline[] = $this->debugSignalTimelineRow( $sequence, 'session_completion', 'session_event', 'SESSION', null, null, [ 'event' => 'SESSION_COMPLETED', 'reset_lane_state' => true, ], 'none', $allowed ? 'sent' : 'skipped', $allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.' ); return $timeline; } protected function debugWashModeForStart(array $snapshot): string { return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL; } protected function debugWashModeForStop(array $snapshot): string { return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true) ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL; } /** * @param array> $timeline * @param array> $actions * @param array $gatewayWorkspace * @param array>> $bindingsByService * @param array> $laneRelaySlots * @param array $snapshot */ protected function appendDebugActionSignalRows( array &$timeline, int &$sequence, array $actions, string $event, bool $eventAllowed, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $snapshot ): void { $eventActions = array_values(array_filter( $actions, static fn(array $action): bool => (string)($action['event'] ?? '') === $event )); usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); foreach ($eventActions as $action) { $raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action); $operation = (string)($raw['operation'] ?? ''); $relayRole = selfserve_studio_actions::relayRoleForOperation($operation); $runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event); $isRelayOperation = selfserve_studio_actions::isRelayOperation($operation); $isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true); $relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot); $binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole); $active = (bool)($action['active'] ?? false); $reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.'); $payload = [ 'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0), 'action' => (string)($raw['name'] ?? $action['label'] ?? ''), 'event' => $event, 'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH), 'operation' => $operation, 'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null), 'enabled' => (bool)($raw['enabled'] ?? true), 'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0), 'options' => (array)($raw['options'] ?? []), ]; if ($isRelayOperation) { $payload['id'] = $relayId; $payload['channel'] = 0; $payload['on'] = (bool)($raw['relay_state'] ?? true); $signalType = 'studio_action_relay_switch'; } elseif ($isPropertyGate) { $payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE'; $signalType = 'studio_action_gate_open'; } else { $payload['id'] = $relayId; $payload['on'] = true; $payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1; $signalType = 'studio_action_relay_pulse'; } if (!$active) { $timeline[] = $this->debugSignalTimelineRow( $sequence++, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'skipped', $reason ); continue; } if (!$eventAllowed) { $timeline[] = $this->debugSignalTimelineRow( $sequence++, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', 'Action event would not fire because the dry-run scenario is blocked before this stage.' ); continue; } if ($isPropertyGate) { $timeline[] = $this->debugSignalTimelineRow( $sequence++, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'real', 'sent', null ); continue; } $timeline[] = $this->debugRelaySignalTimelineRow( $sequence++, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, true, 'Action relay is not configured for the simulated lane.' ); } } /** * @param array $snapshot * @param array> $laneRelaySlots */ protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string { $role = strtoupper(trim($role)); foreach ($laneRelaySlots as $slot) { if (!is_array($slot)) { continue; } $slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''))); $relayId = trim((string)($slot['relay_id'] ?? '')); if ($slotRole === $role && $relayId !== '') { return $relayId; } } $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; $field = match ($role) { 'ENTRY' => 'relay_in_id', 'EXIT' => 'relay_out_id', 'MACHINE' => 'relay_machine_id', 'PROGRAM_PICKER' => 'relay_machine_program_picker_id', 'CLEANER' => 'relay_machine_cleaner_id', default => '', }; $relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : ''; return $relayId !== '' ? $relayId : null; } /** * @param array $gatewayWorkspace * @param array>> $bindingsByService * @return array|null */ protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array { if ($relayId === null || trim($relayId) === '') { return null; } $role = strtoupper(trim($role)); foreach ((array)($bindingsByService[$role] ?? []) as $binding) { if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) { return $binding; } } foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) { if ((string)($binding['relay_id'] ?? '') === $relayId) { return $binding; } } return null; } /** * @param array $binding|null * @param array $payload * @return array */ protected function debugRelaySignalTimelineRow( int $sequence, string $runtimeStage, string $signalType, string $relayRole, ?string $relayId, ?array $binding, array $payload, bool $eligible, ?string $reason ): array { if ($relayId === null || trim($relayId) === '') { return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason); } if (!$eligible) { return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason); } if ($binding === null) { return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.'); } $source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'; return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null); } /** * @param array|null $binding * @param array $payload * @return array */ protected function debugSignalTimelineRow( int $sequence, string $runtimeStage, string $signalType, string $relayRole, ?string $relayId, ?array $binding, array $payload, string $source, string $predictedStatus, ?string $reason ): array { $id = 'signal:' . $sequence; $label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType); $targetBinding = $binding['node_id'] ?? null; $nodeIds = [$id]; if (isset($payload['action_id'])) { $nodeIds[] = 'action:' . (int)$payload['action_id']; } if (is_string($targetBinding) && $targetBinding !== '') { $nodeIds[] = $targetBinding; } if ($relayId !== null && trim($relayId) !== '') { $nodeIds[] = 'relay:' . $relayId; } $causes = $reason === null ? [] : [ $this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason), ]; return [ 'kind' => 'signal', 'id' => $id, 'node_id' => $id, 'node_ids' => array_values(array_unique($nodeIds)), 'label' => $label, 'state' => $predictedStatus, 'sequence' => $sequence, 'runtime_stage' => $runtimeStage, 'signal_type' => $signalType, 'relay_role' => $relayRole, 'relay_id' => $relayId, 'target_gateway' => $binding['gateway_id'] ?? null, 'target_gateway_label' => $binding['gateway_label'] ?? null, 'target_binding' => $binding['node_id'] ?? null, 'target_binding_label' => $binding['relay_label'] ?? null, 'transport' => match ($signalType) { 'session_event' => 'selfserve_wash_session_events', 'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event', default => '/v2/devices/api/set/switch', }, 'payload' => $payload, 'source' => $source, 'virtual' => $source === 'virtual', 'predicted_status' => $predictedStatus, 'skip_block_reason' => $reason, 'reason' => $reason, 'causes' => $causes, ]; } /** * @param array $snapshot * @param array> $questions * @param array> $tasks * @param array $hardware * @param array> $conditions * @param array> $rules * @return array> */ protected function buildDebugRecommendations(array $snapshot, array $questions, array $tasks, array $hardware, array $conditions, array $rules): array { $items = []; $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); if ($missingQuestions !== []) { $items[] = [ 'severity' => 'error', 'title' => 'Answer required questions', 'message' => count($missingQuestions) . ' visible question(s) are missing answers.', 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $missingQuestions), ]; } if (($snapshot['machine_available'] ?? false) !== true) { $items[] = [ 'severity' => 'error', 'title' => 'Configure lane machine relay', 'message' => 'The selected lane has no machine relay configured, so the machine cannot start.', 'node_ids' => ['lane:' . (int)($snapshot['lane']['id'] ?? 0)], ]; } if (!in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)) { $items[] = [ 'severity' => 'error', 'title' => 'Expose MACHINE service', 'message' => 'No active task exposes the MACHINE service for this scenario.', 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), ]; } foreach ((array)($hardware['missing_service_bindings'] ?? []) as $service) { $items[] = [ 'severity' => 'warning', 'title' => 'Bind gateway relay for ' . $service, 'message' => 'The active service has no edge gateway relay binding in the studio hardware workspace.', 'node_ids' => [], ]; } foreach ($conditions as $condition) { if (($condition['has_cycle'] ?? false) === true) { $items[] = [ 'severity' => 'error', 'title' => 'Fix condition cycle', 'message' => 'Condition "' . (string)$condition['label'] . '" depends on itself through another condition.', 'node_ids' => [(string)$condition['node_id']], ]; } } foreach ($rules as $rule) { if (($rule['invalid_reference'] ?? false) === true) { $items[] = [ 'severity' => 'error', 'title' => 'Fix invalid rule reference', 'message' => 'Rule "' . (string)$rule['label'] . '" references an invalid object.', 'node_ids' => [(string)$rule['node_id']], ]; } } if ($items === []) { $items[] = [ 'severity' => 'success', 'title' => 'Flow is ready', 'message' => 'The simulated parameters pass every dry-run check.', 'node_ids' => ['checkpoint:eligible'], ]; } return $items; } /** * @param array $snapshot * @param array> $recommendations * @param array $hardware * @return array */ protected function buildDebugSummary(array $snapshot, array $recommendations, array $hardware): array { $primary = null; foreach ($recommendations as $recommendation) { if (($recommendation['severity'] ?? '') === 'error') { $primary = $recommendation; break; } } $warnings = array_values(array_filter($recommendations, static fn(array $recommendation): bool => ($recommendation['severity'] ?? '') === 'warning')); $allowed = ($snapshot['allowed'] ?? false) === true; return [ 'status' => $allowed ? ($warnings === [] ? 'allowed' : 'warning') : 'blocked', 'allowed' => $allowed, 'title' => $allowed ? ($warnings === [] ? 'Allowed' : 'Allowed with warnings') : 'Blocked', 'primary_blocker' => $primary, 'next_action' => $primary['message'] ?? ($warnings[0]['message'] ?? 'No action required.'), 'warning_count' => count($warnings), 'dry_run' => true, 'hardware_ready' => (bool)($hardware['machine_relay_configured'] ?? false), ]; } /** * @param array $snapshot * @param array> $questions * @param array> $conditions * @param array> $rules * @param array> $tasks * @param array $hardware * @param array $summary * @param array $lookups * @return array> */ protected function buildDebugStages(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $summary, array $lookups): array { $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); $activeTasks = array_values(array_filter($tasks, static fn(array $task): bool => ($task['active'] ?? false) === true)); $failedConditions = array_values(array_filter($conditions, static fn(array $condition): bool => ($condition['result'] ?? false) !== true)); return [ [ 'id' => 'input', 'title' => 'Input normalization', 'status' => 'ok', 'summary' => 'Registration normalized to ' . (string)$snapshot['reg'] . '.', 'node_ids' => ['checkpoint:start'], 'edge_ids' => ['runtime:start-eligible'], ], [ 'id' => 'scope', 'title' => 'Lane and scope resolution', 'status' => $snapshot['vehicle_type_id'] === null ? 'warning' : 'ok', 'summary' => 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' uses ' . $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'automatic vehicle type') . '.', 'node_ids' => array_values(array_filter([ 'lane:' . (int)($snapshot['lane']['id'] ?? 0), $snapshot['vehicle_type_id'] === null ? null : 'vehicle_type:' . (int)$snapshot['vehicle_type_id'], isset($snapshot['machine_type']['id']) ? 'machine_type:' . (int)$snapshot['machine_type']['id'] : null, ])), 'edge_ids' => [], ], [ 'id' => 'questions', 'title' => 'Question visibility and answers', 'status' => $missingQuestions === [] ? 'ok' : 'error', 'summary' => count($questions) . ' question(s) evaluated; ' . count($missingQuestions) . ' visible question(s) missing answers.', 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $questions), 'edge_ids' => [], ], [ 'id' => 'conditions', 'title' => 'Condition and rule evaluation', 'status' => count(array_filter($conditions, static fn(array $condition): bool => ($condition['has_cycle'] ?? false) === true)) > 0 ? 'error' : 'ok', 'summary' => count($conditions) . ' condition(s) and ' . count($rules) . ' rule(s) evaluated; ' . count($failedConditions) . ' condition(s) false.', 'node_ids' => array_merge( array_map(static fn(array $condition): string => (string)$condition['node_id'], $conditions), array_map(static fn(array $rule): string => (string)$rule['node_id'], $rules), ), 'edge_ids' => [], ], [ 'id' => 'tasks', 'title' => 'Task gates and services', 'status' => in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) ? 'ok' : 'error', 'summary' => count($activeTasks) . ' task(s) active; services: ' . implode(', ', (array)($snapshot['allowed_services'] ?? [])), 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), 'edge_ids' => [], ], [ 'id' => 'hardware', 'title' => 'Gateway and relay readiness', 'status' => ($snapshot['machine_available'] ?? false) === true ? (((array)($hardware['missing_service_bindings'] ?? [])) === [] ? 'ok' : 'warning') : 'error', 'summary' => (string)($hardware['summary'] ?? ''), 'node_ids' => [], 'edge_ids' => [], ], [ 'id' => 'final', 'title' => 'Final eligibility decision', 'status' => ($summary['status'] ?? '') === 'blocked' ? 'error' : (($summary['status'] ?? '') === 'warning' ? 'warning' : 'ok'), 'summary' => (string)($summary['next_action'] ?? ''), 'node_ids' => ['checkpoint:eligible', 'checkpoint:finish'], 'edge_ids' => ['runtime:eligible-finish'], ], ]; } /** * @param array $snapshot * @param array> $questions * @param array> $conditions * @param array> $rules * @param array> $tasks * @param array> $actions * @param array $hardware * @param array $graph * @return array */ protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array { $nodes = [ 'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'], 'checkpoint:eligible' => ['state' => ($snapshot['allowed'] ?? false) ? 'active' : 'blocked', 'label' => 'Eligibility resolved'], 'checkpoint:finish' => ['state' => ($snapshot['allowed'] ?? false) ? 'visited' : 'not_applicable', 'label' => 'Predicted completion checkpoint'], 'lane:' . (int)($snapshot['lane']['id'] ?? 0) => ['state' => 'active', 'label' => 'Selected lane'], ]; if ($snapshot['vehicle_type_id'] !== null) { $nodes['vehicle_type:' . (int)$snapshot['vehicle_type_id']] = ['state' => 'active', 'label' => 'Selected vehicle type']; } if (isset($snapshot['machine_type']['id'])) { $nodes['machine_type:' . (int)$snapshot['machine_type']['id']] = ['state' => 'active', 'label' => 'Resolved machine type']; } foreach ($questions as $question) { $state = match ($question['state'] ?? '') { 'answered' => 'active', 'missing' => 'warning', default => 'not_applicable', }; $nodes[(string)$question['node_id']] = ['state' => $state, 'label' => (string)$question['reason']]; } foreach ($conditions as $condition) { $nodes[(string)$condition['node_id']] = [ 'state' => ($condition['has_cycle'] ?? false) ? 'error' : (($condition['result'] ?? false) ? 'active' : 'blocked'), 'label' => (string)$condition['reason'], ]; } foreach ($rules as $rule) { $nodes[(string)$rule['node_id']] = [ 'state' => ($rule['invalid_reference'] ?? false) ? 'error' : (($rule['satisfied'] ?? false) ? 'active' : 'blocked'), 'label' => (string)$rule['reason'], ]; } foreach ($tasks as $task) { $nodes[(string)$task['node_id']] = [ 'state' => ($task['active'] ?? false) ? 'active' : 'blocked', 'label' => (string)$task['reason'], ]; foreach ((array)($task['relay_bindings'] ?? []) as $binding) { if (isset($binding['node_id'])) { $nodes[(string)$binding['node_id']] = ['state' => ($task['active'] ?? false) ? 'active' : 'visited', 'label' => 'Relay binding for active service']; } if (isset($binding['gateway_node_id'])) { $nodes[(string)$binding['gateway_node_id']] = ['state' => 'visited', 'label' => 'Gateway available for service']; } if (isset($binding['relay_node_id'])) { $nodes[(string)$binding['relay_node_id']] = ['state' => 'visited', 'label' => 'Hardware relay available for service']; } } } foreach ($actions as $action) { $nodes[(string)$action['node_id']] = [ 'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable', 'label' => (string)($action['reason'] ?? 'Action evaluated.'), ]; } $edges = []; foreach ((array)($graph['edges'] ?? []) as $edge) { if (!is_array($edge)) { continue; } $edgeId = (string)($edge['id'] ?? ''); $source = (string)($edge['source'] ?? ''); $target = (string)($edge['target'] ?? ''); if ($edgeId === '' || (!isset($nodes[$source]) && !isset($nodes[$target]))) { continue; } $sourceState = (string)($nodes[$source]['state'] ?? 'visited'); $targetState = (string)($nodes[$target]['state'] ?? 'visited'); $edges[$edgeId] = [ 'state' => $this->mergeAnnotationStates($sourceState, $targetState), 'label' => (string)($edge['label'] ?? 'Simulated relationship'), ]; } if (($snapshot['machine_available'] ?? false) !== true) { $nodes['lane:' . (int)($snapshot['lane']['id'] ?? 0)] = ['state' => 'error', 'label' => 'Lane machine relay is not configured.']; } return [ 'nodes' => $nodes, 'edges' => $edges, ]; } protected function debugRuleSatisfied(string $type, mixed $actual): bool { return match (strtoupper(trim($type))) { 'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => $actual === true, 'IS_FALSE' => $actual === false, 'IS_SET' => $actual !== null, 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, default => false, }; } protected function debugRuleExpectedValue(string $type): mixed { return match (strtoupper(trim($type))) { 'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true, 'IS_FALSE' => false, 'IS_SET' => 'set', 'IS_TRUE_OR_NOT_SET' => 'true or missing', 'IS_FALSE_OR_NOT_SET' => 'false or missing', default => strtolower(str_replace('_', ' ', trim($type))), }; } protected function debugExpectedLabel(mixed $expected): string { return $this->debugValueLabel($expected); } protected function debugValueLabel(mixed $value): string { if ($value === true) { return 'true'; } if ($value === false) { return 'false'; } if ($value === null) { return 'missing'; } if (is_array($value)) { $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return $encoded === false ? 'array' : $encoded; } if ($value instanceof \Stringable) { return (string)$value; } return trim((string)$value) === '' ? 'empty' : (string)$value; } /** * @return array */ protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array { return [ 'kind' => $kind, 'id' => $id, 'label' => $label, 'expected' => $expected, 'actual' => $actual, 'expected_label' => $this->debugExpectedLabel($expected), 'actual_label' => $this->debugValueLabel($actual), 'reason' => $reason, ]; } protected function debugExpressionTraceReason(mixed $trace): ?string { if (!is_array($trace)) { return null; } $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace; $failed = $this->firstFailedPredicateTrace($expression); if ($failed !== null) { return (string)($failed['reason'] ?? 'Predicate did not pass.'); } return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null); } /** * @param array $trace * @param array $lookups * @return array|null */ protected function debugFailedExpressionCause(array $trace, array $lookups): ?array { $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace; $failed = $this->firstFailedPredicateTrace($expression); if ($failed === null) { if (($expression['result'] ?? true) === false) { return $this->debugCause( 'expression', $trace['condition_id'] ?? null, 'Condition expression', true, false, (string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.') ); } return null; } $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); $subjectId = (int)($failed['subject_id'] ?? 0); $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); $expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE')); $actual = $failed['actual_value'] ?? null; $reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.'; return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason); } /** * @param array $conditionResults * @param array $visibleAnswers * @return array{reason:string,causes:array>} */ protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array { $gateType = strtoupper(trim($gateType)); if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) { if (!$active && $gateSatisfied) { return [ 'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.', 'causes' => [ $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'), ], ]; } return [ 'reason' => $active ? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.' : 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.', 'causes' => $active ? [] : [ $this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'), ], ]; } $actual = $gateType === selfserve_task_gate_type::CONDITION->value ? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null)) : ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null)); $sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question'; if (!$active && $gateSatisfied) { return [ 'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.', 'causes' => [ $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'), $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'), ], ]; } $reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.'; return [ 'reason' => $reason, 'causes' => $active ? [] : [ $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason), ], ]; } /** * @param array> $tasks * @return array> */ protected function buildDebugDynamicImageButtons(array $tasks): array { $items = []; foreach ($tasks as $task) { $taskId = (int)($task['id'] ?? 0); if ($taskId <= 0) { continue; } $taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId)); $active = (bool)($task['active'] ?? false); foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) { $buttonLabel = $this->debugDynamicImageButtonLabel($button); $id = $taskId . ':' . (int)$index; $nodeId = 'dynamic_image_button:' . $id; $reason = $active ? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.' : 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.'; $items[] = [ 'kind' => 'dynamic_image_button', 'id' => $id, 'node_id' => $nodeId, 'node_ids' => ['task:' . $taskId, $nodeId], 'label' => $buttonLabel, 'task_id' => $taskId, 'task_label' => $taskLabel, 'button_index' => (int)$index, 'button' => $button, 'state' => $active ? 'active' : 'hidden', 'reason' => $reason, 'causes' => $active ? [] : [ $this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)), ], ]; } } return $items; } protected function taskUsesProgramPicker(array $task): bool { if (in_array( 'PROGRAM_PICKER', $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true )) { return true; } return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true); } protected function isProgramNumberButton(mixed $button): bool { return is_int($button) && $button >= 0 && $button <= 11; } protected function dynamicImageButtonSequenceForTask(array $task): array { $buttons = $this->normalizeButtonList($task['buttons'] ?? null); if (!$this->taskUsesProgramPicker($task)) { return $buttons; } $sequence = ['program_picker']; foreach ($buttons as $button) { if ($button === 'program_picker' || $this->isProgramNumberButton($button)) { continue; } $sequence[] = $button; } return $this->normalizeButtonList($sequence); } /** * @param array> $questions * @param array> $conditions * @param array> $rules * @param array> $tasks * @param array> $actions * @param array> $signals * @param array> $dynamicImageButtons * @return array> */ protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array { $decisions = []; foreach ([ 'question' => $questions, 'condition' => $conditions, 'rule' => $rules, 'task' => $tasks, 'action' => $actions, 'signal' => $signals, 'dynamic_image_button' => $dynamicImageButtons, ] as $kind => $items) { foreach ($items as $item) { if (is_array($item)) { $decisions[] = $this->debugDecisionFromItem($kind, $item); } } } return $decisions; } /** * @param array $item * @return array */ protected function debugDecisionFromItem(string $kind, array $item): array { $nodeIds = []; foreach ((array)($item['node_ids'] ?? []) as $nodeId) { if (is_string($nodeId) && $nodeId !== '') { $nodeIds[] = $nodeId; } } if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') { $nodeIds[] = $item['node_id']; } $state = (string)($item['state'] ?? ''); if ($state === '') { $state = match ($kind) { 'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'), 'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'), 'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'), 'signal' => (string)($item['predicted_status'] ?? 'unknown'), default => 'unknown', }; } $causes = []; foreach ((array)($item['causes'] ?? []) as $cause) { if (is_array($cause)) { $causes[] = $cause; } } return [ 'kind' => (string)($item['kind'] ?? $kind), 'id' => $item['id'] ?? ($item['node_id'] ?? null), 'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind), 'state' => $state, 'reason' => (string)($item['reason'] ?? ''), 'node_ids' => array_values(array_unique($nodeIds)), 'causes' => $causes, ]; } protected function debugDynamicImageButtonLabel(mixed $button): string { if (is_array($button)) { foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) { if (isset($button[$key]) && trim((string)$button[$key]) !== '') { return (string)$button[$key]; } } $encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return $encoded === false ? 'Button' : $encoded; } $label = trim((string)$button); if (strtolower($label) === 'program_picker') { return 'Program picker'; } return $label === '' ? 'Button' : $label; } /** * @param array> $conditions * @param array> $rules * @return array */ protected function detectConditionCycles(array $conditions, array $rules): array { $edges = []; foreach ($conditions as $condition) { $conditionId = (int)($condition['id'] ?? 0); $parentId = $this->nullableInt($condition['condition_id'] ?? null); if ($conditionId > 0 && $parentId !== null) { $edges[$conditionId][] = $parentId; } } foreach ($rules as $rule) { if (strtolower((string)($rule['object_type'] ?? '')) !== 'condition') { continue; } $conditionId = (int)($rule['condition_id'] ?? 0); $objectId = (int)($rule['object_id'] ?? 0); if ($conditionId > 0 && $objectId > 0) { $edges[$conditionId][] = $objectId; } } $visiting = []; $visited = []; $cycles = []; $walk = function (int $conditionId) use (&$walk, &$visiting, &$visited, &$cycles, $edges): void { if (isset($visited[$conditionId])) { return; } if (isset($visiting[$conditionId])) { $cycles[$conditionId] = $conditionId; return; } $visiting[$conditionId] = true; foreach ($edges[$conditionId] ?? [] as $nextId) { $walk((int)$nextId); if (isset($cycles[(int)$nextId])) { $cycles[$conditionId] = $conditionId; } } unset($visiting[$conditionId]); $visited[$conditionId] = true; }; foreach (array_keys($edges) as $conditionId) { $walk((int)$conditionId); } return array_values($cycles); } /** * @param array $expression * @param array $lookups */ protected function debugExpressionSummary(array $expression, array $lookups): string { $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); if ($type === 'predicate') { $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); return $label . ' ' . strtolower(str_replace('_', ' ', $operator)); } $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; if ($children === []) { return 'No predicates'; } $parts = []; foreach (array_slice($children, 0, 3) as $child) { if (is_array($child)) { $parts[] = $this->debugExpressionSummary((array)$child, $lookups); } } if (count($children) > 3) { $parts[] = '+' . (count($children) - 3) . ' more'; } return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts); } /** * @param array $trace * @param array $lookups */ protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string { $failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace)); if ($failed === null) { return null; } $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); $subjectId = (int)($failed['subject_id'] ?? 0); $operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE')); $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.'; } /** * @param array $trace * @return array|null */ protected function firstFailedPredicateTrace(array $trace): ?array { if (($trace['type'] ?? '') === 'predicate') { return (($trace['result'] ?? false) === true) ? null : $trace; } foreach ((array)($trace['children'] ?? []) as $child) { if (!is_array($child)) { continue; } $failed = $this->firstFailedPredicateTrace((array)$child); if ($failed !== null) { return $failed; } } foreach (['when', 'then', 'default'] as $field) { if (!is_array($trace[$field] ?? null)) { continue; } $failed = $this->firstFailedPredicateTrace((array)$trace[$field]); if ($failed !== null) { return $failed; } } foreach (['branches', 'cases'] as $field) { foreach ((array)($trace[$field] ?? []) as $child) { if (!is_array($child)) { continue; } $failed = $this->firstFailedPredicateTrace((array)$child); if ($failed !== null) { return $failed; } } } return null; } /** * @param array $workspace * @return array> */ protected function debugRelayServicesFromWorkspace(array $workspace): array { $servicesByRelay = []; foreach ((array)($workspace['lanes'] ?? []) as $lane) { if (!is_array($lane)) { continue; } foreach ((array)($lane['relay_slots'] ?? []) as $slot) { if (!is_array($slot)) { continue; } $relayId = trim((string)($slot['relay_id'] ?? '')); if ($relayId === '') { continue; } foreach ($this->debugNormalizeServiceValues($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '') as $service) { $servicesByRelay[$relayId][$service] = true; } } } return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); } /** * @param array $binding * @param array> $relayServices * @return array */ protected function debugBindingServices(array $binding, string $relayId, array $relayServices): array { $services = []; $addServices = function (mixed $value) use (&$services): void { foreach ($this->debugNormalizeServiceValues($value) as $service) { $services[$service] = true; } }; foreach (['role', 'service', 'slot'] as $field) { $addServices($binding[$field] ?? null); } $addServices($binding['services'] ?? []); foreach ($this->debugBindingConsumerContexts($binding) as $context) { if (!is_array($context)) { continue; } foreach (['slot', 'role', 'service'] as $field) { $addServices($context[$field] ?? null); } } foreach ((array)($relayServices[$relayId] ?? []) as $service) { $addServices($service); } return array_keys($services); } /** * @param array $binding * @return array */ protected function debugBindingConsumerContexts(array $binding): array { $contexts = []; foreach (['consumer_contexts', 'consumers'] as $field) { foreach ((array)($binding[$field] ?? []) as $context) { $contexts[] = $context; } } $metadata = is_array($binding['metadata'] ?? null) ? (array)$binding['metadata'] : []; foreach (['consumer_contexts', 'consumers'] as $field) { foreach ((array)($metadata[$field] ?? []) as $context) { $contexts[] = $context; } } return $contexts; } /** * @return array */ protected function debugNormalizeServiceValues(mixed $value): array { if (is_string($value)) { $trimmed = trim($value); if ($trimmed === '') { return []; } $decoded = json_decode($trimmed, true); $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $trimmed); } if (!is_array($value)) { $value = [$value]; } $services = []; foreach ($value as $entry) { if (is_array($entry)) { continue; } $service = strtoupper(trim((string)$entry)); if ($service !== '') { $services[$service] = true; } } return array_keys($services); } /** * @param array $workspace * @return array>> */ protected function debugGatewayBindingsByService(array $workspace): array { $bindings = []; foreach ($this->debugGatewayBindingReferences($workspace) as $binding) { foreach ((array)($binding['services'] ?? []) as $service) { $row = $binding; $row['service'] = $service; $bindings[$service][] = $row; } } return $bindings; } /** * @param array $workspace * @return array> */ protected function debugGatewayBindingReferences(array $workspace): array { $references = []; $relayServices = $this->debugRelayServicesFromWorkspace($workspace); foreach ((array)($workspace['gateways'] ?? []) as $gateway) { if (!is_array($gateway)) { continue; } $gatewayId = $this->debugGatewayIdentifier($gateway); if ($gatewayId === '') { continue; } foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); if ($relayId === '') { continue; } $services = $this->debugBindingServices($binding, $relayId, $relayServices); if ($services === []) { continue; } $references[] = [ 'gateway_id' => $gatewayId, 'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'), 'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)), 'relay_id' => $relayId, 'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_node_id' => 'relay:' . $relayId, 'binding_index' => (int)$index, 'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)), 'services' => $services, 'channel' => $binding['channel'] ?? null, 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), ]; } } return $references; } /** * @param array $gateway */ protected function debugGatewayIdentifier(array $gateway): string { return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); } /** * @param array $snapshot * @param array $missingBindings * @param array $lookups */ protected function debugHardwareSummary(array $snapshot, array $missingBindings, array $lookups): string { if (($snapshot['machine_available'] ?? false) !== true) { return 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' has no machine relay configured.'; } if ($missingBindings !== []) { return 'Lane relay is configured, but gateway bindings are missing for: ' . implode(', ', $missingBindings) . '.'; } return 'Lane relay and gateway service bindings are ready for the simulated services.'; } /** * @param array $lookups */ protected function debugLabel(array $lookups, string $type, mixed $id, string $fallback): string { $key = (string)($id ?? ''); if ($key === '' || $key === '0') { return $fallback; } if (isset($lookups['labels'][$type][$key])) { return (string)$lookups['labels'][$type][$key]; } foreach ((array)($lookups[$type] ?? []) as $row) { if (is_array($row) && (string)($row['id'] ?? '') === $key) { return (string)($row['label'] ?? $fallback); } } return $fallback; } /** * @param array $lookups */ protected function debugGateReferenceLabel(array $lookups, string $gateType, int $gateRefId): string { $gateType = strtoupper(trim($gateType)); if ($gateType === selfserve_task_gate_type::CONDITION->value) { return $this->debugLabel($lookups, 'conditions', $gateRefId, 'Condition ' . $gateRefId); } if ($gateType === selfserve_task_gate_type::QUESTION->value) { return $this->debugLabel($lookups, 'questions', $gateRefId, 'Question ' . $gateRefId); } return 'Always'; } protected function mergeAnnotationStates(string $left, string $right): string { $rank = [ 'error' => 6, 'blocked' => 5, 'warning' => 4, 'active' => 3, 'visited' => 2, 'not_applicable' => 1, ]; return (($rank[$left] ?? 0) >= ($rank[$right] ?? 0)) ? $left : $right; } /** * @param array|null $publishedConfig */ protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?array $publishedConfig = null): array { if (is_array($publishedConfig) && isset($publishedConfig['questions']) && is_array($publishedConfig['questions'])) { $questions = array_values(array_filter($publishedConfig['questions'], static function (array $question) use ($departmentId): bool { return ((int)($question['department'] ?? 0) === 0) || ((int)($question['department'] ?? 0) === $departmentId); })); $sharedQuestions = array_values(array_filter($questions, static function (array $question): bool { return (int)($question['department'] ?? 0) === 0 && (int)($question['lane'] ?? 0) === 0 && (int)($question['product'] ?? 0) === 0; })); if ($sharedQuestions !== []) { return $sharedQuestions; } if ($vehicleTypeId === null) { return []; } return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool { return (int)($question['department'] ?? 0) === $departmentId && ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId) && (int)($question['product'] ?? 0) === $vehicleTypeId; })); } $questionsObject = new department_selfserve_questions_o(); $sharedQuestions = $questionsObject->getSharedQuestions(); if ($sharedQuestions !== []) { return $sharedQuestions; } if ($vehicleTypeId === null) { return []; } return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); } /** * @param array|null $publishedConfig */ protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array { if (is_array($publishedConfig) && isset($publishedConfig['conditions']) && is_array($publishedConfig['conditions'])) { $conditions = array_values(array_filter($publishedConfig['conditions'], static function (array $condition) use ($departmentId): bool { return ((int)($condition['department'] ?? 0) === 0) || ((int)($condition['department'] ?? 0) === $departmentId); })); if ($machineTypeId !== null) { $machineTypeConditions = array_values(array_filter($conditions, static function (array $condition) use ($machineTypeId): bool { return (int)($condition['machine_type_id'] ?? 0) === $machineTypeId; })); if ($machineTypeConditions !== []) { return $machineTypeConditions; } } if ($vehicleTypeId === null) { return []; } return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool { return (int)($condition['machine_type_id'] ?? 0) === 0 && (int)($condition['department'] ?? 0) === $departmentId && ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId) && (int)($condition['product'] ?? 0) === $vehicleTypeId; })); } $conditionsObject = new department_selfserve_conditions_o(); if ($machineTypeId !== null) { $machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId); if ($machineTypeConditions !== []) { return $machineTypeConditions; } } if ($vehicleTypeId === null) { return []; } return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); } /** * @param array|null $publishedConfig */ protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array { if (is_array($publishedConfig) && isset($publishedConfig['tasks']) && is_array($publishedConfig['tasks'])) { $tasks = array_values(array_filter($publishedConfig['tasks'], static function (array $task) use ($departmentId): bool { return ((int)($task['department'] ?? 0) === 0) || ((int)($task['department'] ?? 0) === $departmentId); })); if ($machineTypeId !== null) { $machineTypeTasks = array_values(array_filter($tasks, static function (array $task) use ($machineTypeId): bool { return (int)($task['machine_type_id'] ?? 0) === $machineTypeId; })); if ($machineTypeTasks !== []) { return $machineTypeTasks; } } if ($vehicleTypeId === null) { return []; } return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool { return (int)($task['machine_type_id'] ?? 0) === 0 && (int)($task['department'] ?? 0) === $departmentId && ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId) && (int)($task['product'] ?? 0) === $vehicleTypeId; })); } $tasksObject = new department_selfserve_tasks_o(); if ($machineTypeId !== null) { $machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId); if ($machineTypeTasks !== []) { return $machineTypeTasks; } } if ($vehicleTypeId === null) { return []; } return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); } /** * @param array $task * @param array $conditionIds * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} */ protected function resolveTaskGate(array $task, array $conditionIds): array { $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); if ( $gateType === selfserve_task_gate_type::CONDITION || $gateType === selfserve_task_gate_type::QUESTION ) { return [ 'gate_type' => $gateType, 'gate_ref_id' => $gateRefId ?? $legacyGateId, ]; } $shouldInferLegacyGate = $gateType === null || ( $gateType === selfserve_task_gate_type::ALWAYS && $gateRefId === null && $legacyGateId !== null ); if ($shouldInferLegacyGate) { $fallbackGateId = $gateRefId ?? $legacyGateId; if ($fallbackGateId === null) { return [ 'gate_type' => selfserve_task_gate_type::ALWAYS, 'gate_ref_id' => null, ]; } return [ 'gate_type' => in_array($fallbackGateId, $conditionIds, true) ? selfserve_task_gate_type::CONDITION : selfserve_task_gate_type::QUESTION, 'gate_ref_id' => $fallbackGateId, ]; } return [ 'gate_type' => selfserve_task_gate_type::ALWAYS, 'gate_ref_id' => null, ]; } /** * @param array|null $publishedConfig */ protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array { if ($conditions === []) { return []; } if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) { return []; } $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) { return array_values(array_filter($publishedConfig['rules'], static function (array $rule) use ($conditionIds): bool { return in_array((int)($rule['condition_id'] ?? 0), $conditionIds, true); })); } return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([ 'condition_id' => $conditionIds, 'deleted_at' => null, ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); } protected function syncSessionAnswers(int $sessionId, array $questions): void { $answersObject = new selfserve_wash_session_answers_o(); $answeredQuestionIds = []; foreach ($questions as $question) { if ($question['answer'] === null) { continue; } $answeredQuestionIds[] = (int)$question['id']; $answersObject->upsert( $sessionId, (int)$question['id'], (string)$question['question'], (bool)$question['answer'], ); } $answersObject->deleteMissingForSession($sessionId, $answeredQuestionIds); } protected function syncSessionTasks(int $sessionId, array $tasks): void { $tasksObject = new selfserve_wash_session_tasks_o(); $tasksObject->deleteBySession($sessionId); foreach ($tasks as $task) { $tasksObject->addSnapshot( $sessionId, (int)$task['id'], (string)$task['task'], (string)$task['description'], $task['services'], $task['buttons'], $task['dynamic_images_vehicle_type'] ?? null, ); } } protected function logSessionEvent(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): void { (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload); } /** * @param callable():array $callback * @return array */ protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array { $lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber); $lock = $this->acquireSessionMutationLock($lockKey); try { return $callback(); } finally { $this->releaseSessionMutationLock($lock); } } /** * @return array{driver:string,key:string,token:?string} */ protected function acquireSessionMutationLock(string $lockKey): array { if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) { $token = bin2hex(random_bytes(16)); if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) { throw new \RuntimeException('Self-serve wash session is busy. Try again.'); } return [ 'driver' => 'redis', 'key' => $lockKey, 'token' => $token, ]; } global $db; $result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired"); $row = $db->fetch_assoc($result); if ((int)($row['acquired'] ?? 0) !== 1) { throw new \RuntimeException('Self-serve wash session is busy. Try again.'); } return [ 'driver' => 'mysql', 'key' => $lockKey, 'token' => null, ]; } /** * @param array{driver:string,key:string,token:?string} $lock */ protected function releaseSessionMutationLock(array $lock): void { try { if ($lock['driver'] === 'redis' && defined('redis')) { if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) { return; } if (method_exists(redis, 'delete')) { redis->delete($lock['key']); } return; } if ($lock['driver'] === 'mysql') { global $db; $db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')"); } } catch (\Throwable) { // Locks have TTLs or connection scope; release failures must not mask API results. } } protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string { return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1( selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber) ); } protected function buildSessionMetadata(array $snapshot): array { return [ 'allowed_services' => $snapshot['allowed_services'], 'machine_available' => (bool)$snapshot['machine_available'], 'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true), 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], 'config_version_id' => $snapshot['config_version_id'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, 'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']), 'visible_questions' => array_map(static fn(array $question): array => [ 'id' => (int)$question['id'], 'question' => (string)$question['question'], 'order_priority' => (int)($question['order_priority'] ?? 0), ], $snapshot['questions']), 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), ]; } /** * @param array> $answerRows * @return array> */ protected function buildSessionQuestions(selfserve_wash_sessions_o $session, array $answerRows): array { $answersByQuestionId = []; foreach ($answerRows as $row) { $answersByQuestionId[(int)$row['question_id']] = $row; } $metadata = $session->metadata_json->value(); $metadata = is_array($metadata) ? $metadata : []; $visibleQuestions = $this->resolveVisibleQuestionsFromMetadata( $metadata, (int)$session->department_id->value(), (int)$session->lane_id->value(), $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), ); if ($visibleQuestions === []) { return array_map(static function (array $row): array { return [ 'question_id' => (int)$row['question_id'], 'question' => (string)$row['question_text'], 'answer' => (bool)$row['answer_value'], 'answered_at' => (string)$row['answered_at'], ]; }, $answerRows); } $questions = []; foreach ($visibleQuestions as $visibleQuestion) { $questionId = (int)$visibleQuestion['id']; if ($questionId <= 0) { continue; } $answerRow = $answersByQuestionId[$questionId] ?? null; $questions[] = [ 'question_id' => $questionId, 'question' => $answerRow === null ? (string)$visibleQuestion['question'] : (string)$answerRow['question_text'], 'answer' => $answerRow === null ? null : (bool)$answerRow['answer_value'], 'answered_at' => $answerRow === null ? null : (string)$answerRow['answered_at'], ]; unset($answersByQuestionId[$questionId]); } foreach ($answerRows as $row) { $questionId = (int)$row['question_id']; if (!array_key_exists($questionId, $answersByQuestionId)) { continue; } $questions[] = [ 'question_id' => $questionId, 'question' => (string)$row['question_text'], 'answer' => (bool)$row['answer_value'], 'answered_at' => (string)$row['answered_at'], ]; unset($answersByQuestionId[$questionId]); } return $questions; } /** * @param array $metadata * @return array */ protected function resolveVisibleQuestionsFromMetadata(array $metadata, int $departmentId, int $laneId, ?int $vehicleTypeId): array { $visibleQuestions = []; if (isset($metadata['visible_questions']) && is_array($metadata['visible_questions'])) { foreach ($metadata['visible_questions'] as $visibleQuestion) { if (!is_array($visibleQuestion)) { continue; } $questionId = (int)($visibleQuestion['id'] ?? 0); if ($questionId <= 0) { continue; } $visibleQuestions[] = [ 'id' => $questionId, 'question' => (string)($visibleQuestion['question'] ?? ''), 'order_priority' => (int)($visibleQuestion['order_priority'] ?? 0), ]; } return $visibleQuestions; } if (!isset($metadata['visible_question_ids']) || !is_array($metadata['visible_question_ids'])) { return []; } $questionById = []; foreach ($this->loadQuestions($departmentId, $laneId, $vehicleTypeId) as $question) { $questionById[(int)$question['id']] = [ 'question' => (string)($question['question'] ?? ''), 'order_priority' => (int)($question['order_priority'] ?? 0), ]; } foreach ($metadata['visible_question_ids'] as $visibleQuestionId) { $questionId = (int)$visibleQuestionId; if ($questionId <= 0) { continue; } $visibleQuestions[] = [ 'id' => $questionId, 'question' => (string)($questionById[$questionId]['question'] ?? ''), 'order_priority' => (int)($questionById[$questionId]['order_priority'] ?? 0), ]; } return $visibleQuestions; } protected function findVehicleByRegistration(string $reg): ?customer_vehicles_o { $vehicle = (new customer_vehicles_o())->selectByPlate($reg); return $vehicle->exists() ? $vehicle : null; } protected function resolveVehicleTypeId(?customer_vehicles_o $vehicle, ?int $vehicleTypeIdOverride = null): ?int { if ($vehicleTypeIdOverride !== null && $vehicleTypeIdOverride > 0) { return $vehicleTypeIdOverride; } if ($vehicle === null) { return null; } $vehicleTypeId = (int)$vehicle->type->value(); return $vehicleTypeId > 0 ? $vehicleTypeId : null; } protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o { $session = new selfserve_wash_sessions_o(); $session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber); return $session; } protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o { $rows = (new selfserve_wash_sessions_o())->getFieldsWhere( [ 'lane_id' => $laneId, 'completed_at' => null, 'deleted_at' => null, ...($customerNumber !== null ? ['customer_number' => $customerNumber] : []), ], ['id', 'status'] ); $rows = array_values(array_filter( $rows, static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null) )); 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']); } protected function resolveForceStopSession(int $laneId, ?int $sessionId = null): selfserve_wash_sessions_o { if ($sessionId === null) { return (new selfserve_wash_sessions_o())->selectLatestOpenByLane($laneId); } $session = (new selfserve_wash_sessions_o())->select($sessionId); if (!$session->exists()) { throw new \RuntimeException('Self-serve wash session not found.'); } if ((int)$session->lane_id->value() !== $laneId) { throw new \RuntimeException('Self-serve wash session does not belong to the requested lane.'); } if ($session->completed_at->value() !== null) { throw new \RuntimeException('Self-serve wash session is already closed.'); } return $session; } protected function buildForceStopRuntimeSnapshot(selfserve_lane $lane): array { return [ '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(), ]; } protected function laneRuntimeLooksActive(array $snapshot): bool { return $snapshot['status'] === selfserve_lane_status::OCCUPIED->name || $snapshot['state'] === selfserve_lane_state::IN_WASH->name || (int)($snapshot['wash_start_time'] ?? 0) > 0 || trim((string)($snapshot['license_plate'] ?? '')) !== '' || (int)($snapshot['customer_number'] ?? 0) > 0; } protected function nullableInt(mixed $value): ?int { if ($value === null || $value === '' || $value === 0 || $value === '0') { return null; } return (int)$value; } /** * @param array $answers * @param array $visibleQuestionIds * @return array */ protected function filterAnswersToVisibleQuestions(array $answers, array $visibleQuestionIds): array { $filtered = []; foreach ($visibleQuestionIds as $questionId) { $questionId = (int)$questionId; if ($questionId <= 0 || !array_key_exists($questionId, $answers)) { continue; } $filtered[$questionId] = $answers[$questionId]; } return $filtered; } protected function normalizeJsonValue(mixed $value): mixed { if ($value === null || $value === '') { return null; } if (is_array($value)) { return $value; } if (is_string($value)) { $decoded = json_decode($value, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } return $value; } protected function normalizeJsonArray(mixed $value): array { $decoded = $this->normalizeJsonValue($value); return is_array($decoded) ? $decoded : []; } protected function normalizeButtonList(mixed $value): array { try { return department_selfserve_tasks_o::normalizeButtonsInput($value); } catch (\Throwable) { return []; } } /** * @param array> $tasks * @param array $allowedServices * @return array> */ protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array { if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) { return array_values($tasks); } return array_values(array_filter( $tasks, fn(array $task): bool => !$this->taskUsesMachineControls($task) )); } protected function isMachineWashEnabled(): bool { try { return (new selfserve())->config->machine_wash_enabled->isTrue(); } catch (\Throwable) { return true; } } /** * @param array $services * @return array */ protected function withoutMachineService(array $services): array { return array_values(array_filter( $this->normalizeServiceNames($services), static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name )); } protected function taskUsesMachineControls(array $task): bool { if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) { return true; } if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) { return true; } $dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null; return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== ''; } protected function normalizeServiceNames(array $services): array { $normalized = []; foreach ($services as $service) { $name = strtoupper(trim((string)$service)); if ($name === '') { continue; } if (!in_array($name, $normalized, true)) { $normalized[] = $name; } } return $normalized; } protected function normalizeIntArray(array $values): array { $normalized = []; foreach ($values as $value) { $intValue = (int)$value; if (!in_array($intValue, $normalized, true)) { $normalized[] = $intValue; } } return $normalized; } public function isMachineAllowedToStartWash(int $id): bool { $sessionSummary = $this->getSessionSummary($id); return ($sessionSummary['session']['allowed'] ?? false) === true; } }