Files
api/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php
T

3714 lines
161 KiB
PHP

<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/selfserve.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_event_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
require_once WD . '/objects/customer_vehicles_o.php';
if (!class_exists(\objects\department_lanes_o::class, false)) {
require_once WD . '/objects/department_lanes_o.php';
}
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/department_selfserve_vehicle_conditions_o.php';
require_once WD . '/objects/selfserve_machine_types_o.php';
require_once WD . '/objects/selfserve_wash_session_answers_o.php';
require_once WD . '/objects/selfserve_wash_session_events_o.php';
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
use classes\selfserve;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_studio_action_runner;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_state;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\helpers\selfserve_wash_event_type;
use modules\selfserve\helpers\selfserve_wash_session_status;
use modules\selfserve\interfaces\selfserve_condition_evaluator_i;
use modules\selfserve\interfaces\selfserve_wash_flow_i;
use objects\customer_vehicles_o;
use objects\department_lanes_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
use objects\department_selfserve_vehicle_conditions_o;
use objects\selfserve_machine_types_o;
use objects\selfserve_wash_session_answers_o;
use objects\selfserve_wash_session_events_o;
use objects\selfserve_wash_session_tasks_o;
use objects\selfserve_wash_sessions_o;
class selfserve_wash_flow implements selfserve_wash_flow_i
{
public function __construct(
protected ?selfserve_condition_evaluator_i $conditionEvaluator = null,
) {
selfserve_schema_bootstrap::ensureTables();
$this->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<string,mixed> $options
* @return array<string,mixed>
*/
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;
}
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
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);
}
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
{
try {
if ($session->wash_started_at->value() !== null) {
return;
}
$lane = (new selfserve())->lane($laneId);
$washStartedAt = (int)$lane->getWashStartTime();
if ($washStartedAt <= 0) {
return;
}
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
} catch (\Throwable) {
// Session timestamp enrichment must not block STOP completion.
}
}
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<string,mixed> $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<string,mixed> $snapshot
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
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<int,bool|null>
*/
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<int,bool|null> $answers
* @param array<int,bool|null> $overrides
* @return array<int,bool|null>
*/
protected function applyAnswerOverrides(array $answers, array $overrides): array
{
foreach ($overrides as $questionId => $value) {
$answers[(int)$questionId] = $value;
}
return $answers;
}
/**
* @param array<int,bool|null> $answers
* @param array<int,bool|null> $overrides
* @return array<int,string>
*/
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<int,bool>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $questions
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $rules
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $lookups
* @param array<string,mixed> $gatewayWorkspace
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,mixed> $lookups
* @param array<int,array<string,mixed>> $actions
* @return array<string,mixed>
*/
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<string,mixed> $snapshot
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
* @param array<int,array<string,mixed>> $laneRelaySlots
* @param array<int,array<string,mixed>> $actions
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $timeline
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
* @param array<int,array<string,mixed>> $laneRelaySlots
* @param array<string,mixed> $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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $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<string,mixed> $gatewayWorkspace
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
* @return array<string,mixed>|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<string,mixed> $binding|null
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
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<string,mixed>|null $binding
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $hardware
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $recommendations
* @param array<string,mixed> $hardware
* @return array<string,mixed>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $hardware
* @param array<string,mixed> $summary
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $hardware
* @param array<string,mixed> $graph
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $trace
* @param array<string,mixed> $lookups
* @return array<string,mixed>|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<int,bool|null> $conditionResults
* @param array<int,bool|null> $visibleAnswers
* @return array{reason:string,causes:array<int,array<string,mixed>>}
*/
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<int,array<string,mixed>> $tasks
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $actions
* @param array<int,array<string,mixed>> $signals
* @param array<int,array<string,mixed>> $dynamicImageButtons
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $item
* @return array<string,mixed>
*/
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<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @return array<int,int>
*/
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<string,mixed> $expression
* @param array<string,mixed> $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<string,mixed> $trace
* @param array<string,mixed> $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<string,mixed> $trace
* @return array<string,mixed>|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<string,mixed> $workspace
* @return array<string,array<int,string>>
*/
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<string,mixed> $binding
* @param array<string,array<int,string>> $relayServices
* @return array<int,string>
*/
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<string,mixed> $binding
* @return array<int,mixed>
*/
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<int,string>
*/
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<string,mixed> $workspace
* @return array<string,array<int,array<string,mixed>>>
*/
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<string,mixed> $workspace
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $gateway
*/
protected function debugGatewayIdentifier(array $gateway): string
{
return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''));
}
/**
* @param array<string,mixed> $snapshot
* @param array<int,string> $missingBindings
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed> $task
* @param array<int,int> $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<string,mixed>|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<string,mixed> $callback
* @return array<string,mixed>
*/
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<int,array<string,mixed>> $answerRows
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $metadata
* @return array<int,array{id:int,question:string,order_priority:int}>
*/
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<int,bool|null> $answers
* @param array<int,int> $visibleQuestionIds
* @return array<int,bool|null>
*/
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<int,array<string,mixed>> $tasks
* @param array<int,string> $allowedServices
* @return array<int,array<string,mixed>>
*/
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<int,string> $services
* @return array<int,string>
*/
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;
}
}