3533 lines
154 KiB
PHP
3533 lines
154 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);
|
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
|
|
|
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
|
return $session->exists()
|
|
? $this->getSessionSummary((int)$session->id)
|
|
: $this->formatBlockedSessionSummary($snapshot);
|
|
}
|
|
|
|
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']),
|
|
]);
|
|
|
|
if ($syncRelayState) {
|
|
$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->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
|
|
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
|
} catch (\Throwable) {
|
|
// Best effort only; session completion flow must continue.
|
|
}
|
|
}
|
|
|
|
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
|
{
|
|
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$status = $lane->getRelayStatus($relay);
|
|
if ((bool)($status['on'] ?? false) !== true) {
|
|
return;
|
|
}
|
|
} catch (\Throwable) {
|
|
// If relay status can't be read, still attempt turn-off as best effort.
|
|
}
|
|
|
|
try {
|
|
$lane->setRelayStatusHard($relay, false);
|
|
} catch (\Throwable) {
|
|
// Best effort only; session completion flow must continue.
|
|
}
|
|
}
|
|
|
|
protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool
|
|
{
|
|
if (empty($lane->department_lane)) {
|
|
return false;
|
|
}
|
|
|
|
$relayId = match ($relay) {
|
|
selfserve_lane_relay::MACHINE => (string)$lane->department_lane->relay_machine_id->value(),
|
|
selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$lane->department_lane->relay_machine_program_picker_id->value(),
|
|
selfserve_lane_relay::MACHINE_CLEANER => (string)$lane->department_lane->relay_machine_cleaner_id->value(),
|
|
};
|
|
|
|
return trim($relayId) !== '';
|
|
}
|
|
|
|
public function getSessionSummary(int $sessionId): array
|
|
{
|
|
$session = (new selfserve_wash_sessions_o())->select($sessionId);
|
|
if (!$session->exists()) {
|
|
throw new \RuntimeException('Self-serve wash session not found.');
|
|
}
|
|
|
|
$lane = (new department_lanes_o())->select((int)$session->lane_id->value());
|
|
$machineType = null;
|
|
if ($session->machine_type_id->value() !== null) {
|
|
$machineTypeObject = (new selfserve_machine_types_o())->select((int)$session->machine_type_id->value());
|
|
if ($machineTypeObject->exists()) {
|
|
$machineType = $machineTypeObject->asArray();
|
|
}
|
|
}
|
|
|
|
$answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId);
|
|
$answers = $this->buildSessionQuestions($session, $answerRows);
|
|
$metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : [];
|
|
$allowedServices = $this->normalizeServiceNames(
|
|
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
|
);
|
|
$machineAvailable = 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,
|
|
'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): ?array
|
|
{
|
|
$session = $reg !== null
|
|
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
|
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
|
|
|
if (!$session->exists()) {
|
|
return null;
|
|
}
|
|
|
|
$session->markCompleted($orderId);
|
|
$this->disableMachineRelayForCompletedWash($laneId);
|
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
|
'lane_id' => $laneId,
|
|
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
|
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
|
|
'order_id' => $orderId,
|
|
]);
|
|
|
|
return $this->getSessionSummary((int)$session->id);
|
|
}
|
|
|
|
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
|
|
{
|
|
$lane = (new selfserve())->lane($laneId);
|
|
$session = $this->resolveForceStopSession($laneId, $sessionId);
|
|
$runtimeSnapshot = $this->buildForceStopRuntimeSnapshot($lane);
|
|
$hasRuntime = $this->laneRuntimeLooksActive($runtimeSnapshot);
|
|
if (!$session->exists() && !$hasRuntime) {
|
|
throw new \RuntimeException('No active self-serve wash session or lane runtime found.');
|
|
}
|
|
|
|
$orderId = null;
|
|
if ($bill) {
|
|
try {
|
|
if ($lane->invoice() !== true) {
|
|
throw new \RuntimeException('Elapsed-minute invoice was not created.');
|
|
}
|
|
$orderId = method_exists($lane, 'getLastInvoiceOrderId') ? $lane->getLastInvoiceOrderId() : null;
|
|
} catch (\Throwable $e) {
|
|
throw new \RuntimeException('Failed to bill elapsed minutes before force stop: ' . $e->getMessage(), 409, $e);
|
|
}
|
|
}
|
|
|
|
$summary = null;
|
|
if ($session->exists()) {
|
|
$eventPayload = [
|
|
'lane_id' => $laneId,
|
|
'reason' => $reason,
|
|
'user_id' => $userId,
|
|
'bill' => $bill,
|
|
'order_id' => $orderId,
|
|
'runtime_before_reset' => $runtimeSnapshot,
|
|
'forced_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
$session->markForceStopped($orderId, $eventPayload);
|
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
|
$summary = $this->getSessionSummary((int)$session->id);
|
|
}
|
|
|
|
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
|
|
|
return [
|
|
'lane_id' => $laneId,
|
|
'forced' => true,
|
|
'bill' => $bill,
|
|
'order_id' => $orderId,
|
|
'session' => $summary,
|
|
'runtime_before_reset' => $runtimeSnapshot,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|
|
}
|
|
|
|
$machineAvailable = !empty($lane->relay_machine_id->value());
|
|
$allVisibleQuestionsAnswered = true;
|
|
foreach ($visibleQuestions as $question) {
|
|
if ($question['answer'] === null) {
|
|
$allVisibleQuestionsAnswered = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$machineAllowed = $allVisibleQuestionsAnswered
|
|
&& $machineAvailable
|
|
&& in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true);
|
|
$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,
|
|
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
|
'allowed' => $machineAllowed,
|
|
'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
|
|
{
|
|
if ((bool)$session->machine_relay_enabled->value() === true) {
|
|
return;
|
|
}
|
|
|
|
$laneId = (int)$snapshot['lane']['id'];
|
|
$lane = (new selfserve())->lane($laneId);
|
|
$this->enableCleanerRelayForStartedWash($lane);
|
|
|
|
$session->markRelayEnabled();
|
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
|
'lane_id' => $laneId,
|
|
'reg' => $snapshot['reg'],
|
|
'allowed_services' => $snapshot['allowed_services'],
|
|
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
|
]);
|
|
}
|
|
|
|
protected function syncMachineRelayFromVisibleServices(array $snapshot, selfserve_wash_sessions_o $session, bool $allowEnable): void
|
|
{
|
|
$laneId = (int)$snapshot['lane']['id'];
|
|
$lane = (new selfserve())->lane($laneId);
|
|
$sync = $lane->syncMachineRelayFromVisibleServices(
|
|
is_array($snapshot['allowed_services'] ?? null) ? $snapshot['allowed_services'] : [],
|
|
$allowEnable
|
|
);
|
|
|
|
if (($sync['relay_action'] ?? '') === 'enabled') {
|
|
$this->enableMachineRelayIfAllowed($snapshot, $session);
|
|
}
|
|
|
|
$relayTargetOn = (bool)($sync['relay_target_on'] ?? false);
|
|
if (!$relayTargetOn && (bool)$session->machine_relay_enabled->value() === true) {
|
|
$session->markRelayDisabled();
|
|
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
|
}
|
|
}
|
|
|
|
protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status
|
|
{
|
|
if (!$snapshot['all_visible_questions_answered']) {
|
|
return selfserve_wash_session_status::PENDING_QUESTIONS;
|
|
}
|
|
if ($snapshot['allowed']) {
|
|
return selfserve_wash_session_status::READY_FOR_MACHINE_START;
|
|
}
|
|
return selfserve_wash_session_status::MACHINE_NOT_ALLOWED;
|
|
}
|
|
|
|
protected function deriveCurrentStatus(array $snapshot, selfserve_wash_sessions_o $session): selfserve_wash_session_status
|
|
{
|
|
if ($session->completed_at->value() !== null) {
|
|
return selfserve_wash_session_status::COMPLETED;
|
|
}
|
|
if ((bool)$session->machine_start_triggered->value() === true) {
|
|
return selfserve_wash_session_status::MACHINE_STARTED;
|
|
}
|
|
if ((bool)$session->machine_relay_enabled->value() === true) {
|
|
return selfserve_wash_session_status::MACHINE_RELAY_ENABLED;
|
|
}
|
|
return $this->deriveBaseStatus($snapshot);
|
|
}
|
|
|
|
protected function formatSnapshotResponse(array $snapshot, ?array $session = null): array
|
|
{
|
|
return [
|
|
'lane' => $snapshot['lane'],
|
|
'machine_type' => $snapshot['machine_type'],
|
|
'vehicle' => $snapshot['vehicle'],
|
|
'reg' => $snapshot['reg'],
|
|
'customer_number' => $snapshot['customer_number'],
|
|
'vehicle_type_id' => $snapshot['vehicle_type_id'],
|
|
'questions' => $snapshot['questions'],
|
|
'tasks' => $snapshot['tasks'],
|
|
'allowed_services' => $snapshot['allowed_services'],
|
|
'machine_available' => $snapshot['machine_available'],
|
|
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
|
'allowed' => $snapshot['allowed'],
|
|
'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
|
|
{
|
|
return in_array(
|
|
'PROGRAM_PICKER',
|
|
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
|
true
|
|
);
|
|
}
|
|
|
|
protected function dynamicImageButtonSequenceForTask(array $task): array
|
|
{
|
|
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
|
|
if (!$this->taskUsesProgramPicker($task)) {
|
|
return $buttons;
|
|
}
|
|
|
|
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons));
|
|
}
|
|
|
|
/**
|
|
* @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) === $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) === $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) === $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);
|
|
}
|
|
|
|
protected function buildSessionMetadata(array $snapshot): array
|
|
{
|
|
return [
|
|
'allowed_services' => $snapshot['allowed_services'],
|
|
'machine_available' => (bool)$snapshot['machine_available'],
|
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
|
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
|
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
|
'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']),
|
|
'visible_questions' => array_map(static fn(array $question): array => [
|
|
'id' => (int)$question['id'],
|
|
'question' => (string)$question['question'],
|
|
'order_priority' => (int)($question['order_priority'] ?? 0),
|
|
], $snapshot['questions']),
|
|
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<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 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;
|
|
}
|
|
}
|