conditionEvaluator ??= new selfserve_condition_evaluator(); } public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array { $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); } public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array { $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); 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']), ]); $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); $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(); $this->enableCleanerRelayForStartedWash($lane); $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, ]); return $this->getSessionSummary((int)$session->id); } 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->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE); $this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER); } catch (\Throwable) { // Best effort only; session completion flow must continue. } } protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void { if (!$this->isRelayConfiguredForLane($lane, $relay)) { return; } try { $status = $lane->getRelayStatus($relay); if ((bool)($status['on'] ?? false) !== true) { return; } } catch (\Throwable) { // If relay status can't be read, still attempt turn-off as best effort. } 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); $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)); $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, 'config_version_id' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['config_version_id'] ?? null) : null, 'evaluation_trace' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['evaluation_trace'] ?? null) : 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): ?array { $session = $reg !== null ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) : $this->findLatestOpenSessionByLane($laneId, $customerNumber); if (!$session->exists()) { return null; } $session->markCompleted($orderId); $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'), ]; $session->markForceStopped($orderId, $eventPayload); $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, ]; } protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): 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(); $publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId); $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload); $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); $rules = $this->loadConditionRules($conditions, $publishedConfigPayload); $answers = (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle($departmentId, $laneId, $normalizedReg); $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); $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) ? (bool)$answers[$questionId] : null, ]; } usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); $tasks = $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); $typedGateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); $typedGateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); if ($typedGateType === null) { if ($gateId === null) { $typedGateType = selfserve_task_gate_type::ALWAYS; $typedGateRefId = null; } elseif (in_array($gateId, $conditionIds, true)) { $typedGateType = selfserve_task_gate_type::CONDITION; $typedGateRefId = $gateId; } else { $typedGateType = selfserve_task_gate_type::QUESTION; $typedGateRefId = $gateId; } } $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->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)), 'dynamic_images_vehicle_type' => (int)($task['dynamic_images_vehicle_type'] ?? 0), ]; } 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; } } } $machineAvailable = !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); $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, 'questions' => $visibleQuestions, 'conditions' => $serviceConditionResults, 'tasks' => $activeTasks, 'allowed_services' => $allowedServices, 'machine_available' => $machineAvailable, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'allowed' => $machineAllowed, 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, 'evaluation_trace' => [ 'condition_results' => $serviceConditionResults, 'task_gates' => $taskGateTrace, 'visible_question_ids' => $visibleQuestionIds, ], ]; } protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void { if ((bool)$session->machine_relay_enabled->value() === true) { return; } $laneId = (int)$snapshot['lane']['id']; $lane = (new selfserve())->lane($laneId); $this->enableCleanerRelayForStartedWash($lane); $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'], 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'allowed' => $snapshot['allowed'], 'session' => $session, 'config_version_id' => $snapshot['config_version_id'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, ]; } /** * @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) === $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) === $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) === $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|null $publishedConfig */ protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array { if ($conditions === []) { 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); } protected function buildSessionMetadata(array $snapshot): array { return [ 'allowed_services' => $snapshot['allowed_services'], 'machine_available' => (bool)$snapshot['machine_available'], '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'] ); 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 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; } }