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

5231 lines
213 KiB
PHP

<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/db.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_virtual_hardware.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.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_lanes_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_manager.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_manager.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_view_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_view_service.php';
}
use classes\db;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
use objects\department_selfserve_tasks_o;
use objects\selfserve_config_versions_o;
class selfserve_studio_graph
{
private const DEFAULT_PATH_MAX_STATES = 2048;
private const MAX_PATH_MAX_STATES = 2048;
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
private const MAX_PATH_SAMPLE_LIMIT = 200;
/** @var array<string,array<int,string>> */
private array $columnCache = [];
public function __construct()
{
selfserve_schema_bootstrap::ensureTables();
}
/**
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array
{
$versioning = new selfserve_config_versioning();
$draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId);
$gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false)
? $this->buildGatewayWorkspace($departmentId)
: [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'restricted' => true,
];
$lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace);
$layout = $this->loadLayout($departmentId, $userId);
$configWithAttachments = $this->withTaskAttachments($config);
$graph = $this->buildGraphFromConfig($configWithAttachments, [
'department_id' => $departmentId,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
], $layout);
$validation = $versioning->validateConfig($config);
$virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace);
if ($virtualWarnings !== []) {
$validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings)));
}
$validation['items'] = $this->buildValidationItems($validation);
return [
'nodes' => $graph['nodes'],
'edges' => $graph['edges'],
'lookups' => $lookups,
'validation' => $validation,
'layout' => $layout,
'versions' => $versioning->listVersions($departmentId),
'active_config' => $versioning->getPublishedConfig($departmentId),
'draft' => [
'id' => $draft['id'] ?? null,
'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT,
'version_number' => $draft['version_number'] ?? null,
'created_at' => $draft['created_at'] ?? null,
'updated_at' => $draft['updated_at'] ?? null,
],
'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace),
'gateway_workspace' => $gatewayWorkspace,
'permissions' => $permissions,
'meta' => [
'department_id' => $departmentId,
'layout_affects_runtime' => false,
'generated_at' => date('c'),
'path_editor' => is_array($config['v2_meta']['path_editor'] ?? null) ? (array)$config['v2_meta']['path_editor'] : ['paths' => []],
],
];
}
/**
* Pure graph builder used by unit tests and the API serializer.
*
* @param array<string,mixed> $config
* @param array<string,mixed> $context
* @param array<string,mixed> $layout
* @return array{nodes:array<int,array<string,mixed>>,edges:array<int,array<string,mixed>>}
*/
public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array
{
$lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : [];
$gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : [];
$isV2Config = (int)($config['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2;
$nodes = [];
$edges = [];
$nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [
'stage' => 'start',
'subtitle' => 'Vehicle scanned',
], 0, 0);
$nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [
'stage' => 'eligible',
'subtitle' => 'Questions, rules, and gates evaluated',
], 320, 0);
$nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [
'stage' => 'finish',
'subtitle' => 'Session closed',
], 640, 0);
$edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime');
$edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime');
foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) {
$id = 'lane:' . (int)($lane['id'] ?? 0);
$nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [
'object_id' => (int)($lane['id'] ?? 0),
'raw' => $lane,
'subtitle' => 'Lane scope',
], 0, 180 + ($index * 120));
}
foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) {
$id = 'machine_type:' . (int)($machineType['id'] ?? 0);
$nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [
'object_id' => (int)($machineType['id'] ?? 0),
'raw' => $machineType,
'subtitle' => 'Reusable machine setup',
], 0, 560 + ($index * 120));
}
foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) {
$id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0);
$nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [
'object_id' => (int)($vehicleType['id'] ?? 0),
'raw' => $vehicleType,
'subtitle' => 'Vehicle scope',
], 0, 880 + ($index * 120));
}
foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) {
$id = (int)($condition['id'] ?? 0);
$nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [
'object_id' => $id,
'raw' => $condition,
'scope' => $this->scopeForRow($condition, $lookups),
'expression_summary' => $this->expressionSummary(is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []),
'subtitle' => $this->scopeLabel($condition, $lookups),
], 360, 160 + ($index * 130));
$parentId = $this->nullableInt($condition['condition_id'] ?? null);
if ($parentId !== null) {
$edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent');
}
if ($isV2Config && is_array($condition['expression'] ?? null)) {
$this->appendExpressionEdges($edges, 'condition:' . $id, $id, (array)$condition['expression']);
}
$this->appendScopeEdges($edges, 'condition:' . $id, $condition);
}
foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) {
$id = (int)($question['id'] ?? 0);
$nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [
'object_id' => $id,
'raw' => $question,
'scope' => $this->scopeForRow($question, $lookups),
'subtitle' => $this->scopeLabel($question, $lookups),
], 720, 160 + ($index * 130));
$conditionId = $this->nullableInt($question['condition_id'] ?? null);
if ($conditionId !== null) {
$edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if');
}
$this->appendScopeEdges($edges, 'question:' . $id, $question);
}
if (!$isV2Config) {
foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) {
$id = (int)($rule['id'] ?? 0);
$nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [
'object_id' => $id,
'raw' => $rule,
'subtitle' => $this->ruleSubtitle($rule, $lookups),
], 520, 520 + ($index * 120));
$conditionId = (int)($rule['condition_id'] ?? 0);
if ($conditionId > 0) {
$edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of');
}
$objectType = strtolower((string)($rule['object_type'] ?? ''));
$objectId = (int)($rule['object_id'] ?? 0);
if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) {
$edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule'));
}
}
}
$tasksByScope = [];
$taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']);
foreach ($taskRows as $index => $task) {
$id = (int)($task['id'] ?? 0);
$nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [
'object_id' => $id,
'raw' => $this->normalizeTaskPayload($task),
'scope' => $this->scopeForRow($task, $lookups),
'subtitle' => $this->scopeLabel($task, $lookups),
], 1080, 160 + ($index * 130));
$gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) {
$edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks');
} elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) {
$edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks');
}
$scopeKey = implode(':', [
(int)($task['department'] ?? 0),
(int)($task['lane'] ?? 0),
(int)($task['product'] ?? 0),
(int)($task['machine_type_id'] ?? 0),
]);
$tasksByScope[$scopeKey][] = $task;
$this->appendScopeEdges($edges, 'task:' . $id, $task);
}
foreach ($tasksByScope as $tasks) {
$orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']);
for ($i = 1; $i < count($orderedTasks); $i++) {
$sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0);
$targetId = (int)($orderedTasks[$i]['id'] ?? 0);
if ($sourceId > 0 && $targetId > 0) {
$edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then');
}
}
}
$actionsByEvent = [];
$actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']);
foreach ($actionRows as $index => $action) {
$normalizedAction = selfserve_studio_actions::normalize($action);
$id = (int)($normalizedAction['id'] ?? 0);
if ($id <= 0) {
continue;
}
$actionsByEvent[$normalizedAction['event']][] = $normalizedAction;
$nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [
'object_id' => $id,
'raw' => $normalizedAction,
'scope' => $this->scopeForRow($normalizedAction, $lookups),
'subtitle' => $this->actionSubtitle($normalizedAction, $lookups),
'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']),
'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']),
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']),
], 1360, 160 + ($index * 130));
$eventSource = match ((string)$normalizedAction['event']) {
selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start',
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish',
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible',
default => 'checkpoint:start',
};
$edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']);
$conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null);
if ($conditionId !== null) {
$edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows');
}
$this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction);
}
foreach ($actionsByEvent as $event => $actions) {
$orderedActions = $this->sortedRows($actions, ['order_priority', 'id']);
for ($i = 1; $i < count($orderedActions); $i++) {
$sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0);
$targetId = (int)($orderedActions[$i]['id'] ?? 0);
if ($sourceId > 0 && $targetId > 0) {
$edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then');
}
}
}
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
$this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
$this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace);
return [
'nodes' => $this->applyLayoutToNodes($nodes, $layout),
'edges' => array_values($edges),
];
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
{
$operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : [];
$versioning = new selfserve_config_versioning();
$draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId);
if ($versioning->isV2Config($config)) {
foreach ($operations as $operation) {
if (is_array($operation)) {
$this->applyConfigOperation($departmentId, $config, $operation, $permissions);
}
}
$validation = $versioning->validateConfig($config);
$draftObject = (new selfserve_config_versions_o())->select((int)($draft['id'] ?? 0));
if (!$draftObject->exists()) {
throw new \RuntimeException('Self-serve draft version was not found.');
}
$draftObject->config_json->set($config);
$draftObject->validation_result_json->set($validation);
} else {
foreach ($operations as $operation) {
if (is_array($operation)) {
$this->applyOperation($departmentId, $operation, $permissions);
}
}
}
if (isset($payload['layout']) && is_array($payload['layout'])) {
$this->saveLayout($departmentId, $userId, (array)$payload['layout']);
} elseif (isset($payload['nodes']) && is_array($payload['nodes'])) {
$this->saveLayout($departmentId, $userId, [
'nodes' => $this->extractNodePositions((array)$payload['nodes']),
'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [],
]);
}
if (!$versioning->isV2Config($config)) {
$versioning->syncDraftFromLegacyForDepartment($departmentId);
}
return $this->buildGraph($departmentId, $userId, $permissions);
}
/**
* @param array<string,mixed> $layout
* @return array<string,mixed>
*/
public function saveLayout(int $departmentId, ?int $userId, array $layout): array
{
$normalized = [
'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])),
'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [],
'saved_at' => date('c'),
'runtime_affecting' => false,
];
$layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE);
if ($layoutJson === false) {
throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg());
}
$pdo = db::getPDO();
$statement = $pdo->prepare(
"SELECT id
FROM department_selfserve_studio_layouts
WHERE department_id = :department_id
AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . "
AND deleted_at IS NULL
ORDER BY id DESC
LIMIT 1"
);
$params = [':department_id' => $departmentId];
if ($userId !== null) {
$params[':user_id'] = $userId;
}
$statement->execute($params);
$row = $statement->fetch(\PDO::FETCH_ASSOC);
if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
$update = $pdo->prepare(
"UPDATE department_selfserve_studio_layouts
SET layout_json = :layout_json, updated_at = NOW()
WHERE id = :id"
);
$update->execute([
':layout_json' => $layoutJson,
':id' => (int)$row['id'],
]);
} else {
$insert = $pdo->prepare(
"INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json)
VALUES (:department_id, :user_id, :layout_json)"
);
$insert->execute([
':department_id' => $departmentId,
':user_id' => $userId,
':layout_json' => $layoutJson,
]);
}
return $normalized;
}
/**
* @return array<string,mixed>
*/
public function loadLayout(int $departmentId, ?int $userId): array
{
$pdo = db::getPDO();
$statement = $pdo->prepare(
"SELECT layout_json
FROM department_selfserve_studio_layouts
WHERE department_id = :department_id
AND deleted_at IS NULL
AND (user_id = :user_id_filter OR user_id IS NULL)
ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC
LIMIT 1"
);
$statement->execute([
':department_id' => $departmentId,
':user_id_filter' => $userId,
':user_id_sort' => $userId,
]);
$row = $statement->fetch(\PDO::FETCH_ASSOC);
if (!is_array($row)) {
return [
'nodes' => [],
'viewport' => [],
'runtime_affecting' => false,
];
}
$layout = json_decode((string)($row['layout_json'] ?? '{}'), true);
if (!is_array($layout)) {
$layout = [];
}
$layout['runtime_affecting'] = false;
return $layout;
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function validatePayload(int $departmentId, array $payload = []): array
{
$versioning = new selfserve_config_versioning();
$draft = $versioning->ensureDraftFromLegacy($departmentId, null, false);
$config = is_array($payload['config'] ?? null)
? (array)$payload['config']
: (is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId));
if (isset($payload['operations']) && is_array($payload['operations']) && $versioning->isV2Config($config)) {
foreach ((array)$payload['operations'] as $operation) {
if (is_array($operation)) {
$this->applyConfigOperation($departmentId, $config, $operation);
}
}
}
$validation = $versioning->validateConfig($config);
$gatewayWorkspace = $this->buildGatewayWorkspace($departmentId);
$virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace);
if ($virtualWarnings !== []) {
$validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings)));
}
$validation['items'] = $this->buildValidationItems($validation);
return $validation;
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
{
$laneId = (int)($payload['lane_id'] ?? 0);
if ($laneId <= 0) {
throw new \RuntimeException('lane_id is required for studio simulation.');
}
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning();
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false;
$hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? '')));
if ($hardwareMode === '') {
$hardwareMode = $includeHardware ? 'studio' : 'none';
}
if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) {
$hardwareMode = 'studio';
}
if ($hardwareMode === 'none') {
$includeHardware = false;
}
if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) {
$gatewayWorkspace = [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'restricted' => !$includeHardware ? false : true,
'virtual' => [
'enabled' => $hardwareMode === 'studio',
'has_virtual_hardware' => false,
'gateway_count' => 0,
'binding_count' => 0,
],
];
} else {
$gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real');
}
$graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId);
$lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace);
$graph = $this->buildGraphFromConfig($graphConfig, [
'department_id' => $departmentId,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
]);
return (new selfserve_wash_flow())->previewStudioSimulation(
$departmentId,
$laneId,
(string)($payload['reg'] ?? ''),
array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null,
array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null,
[
'mode' => 'full_dry_run',
'config_source' => $configSource,
'config_payload' => $config,
'config_version_id' => $versionId,
'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [],
'include_hardware' => $includeHardware,
'hardware_mode' => $hardwareMode,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
'graph' => $graph,
],
);
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function projectPathOutcomes(
int $departmentId,
array $payload,
?int $userId = null,
array $permissions = [],
?callable $progressCallback = null
): array
{
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning();
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false;
$hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? '')));
if ($hardwareMode === '') {
$hardwareMode = $includeHardware ? 'studio' : 'none';
}
if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) {
$hardwareMode = 'studio';
}
if ($hardwareMode === 'none') {
$includeHardware = false;
}
if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) {
$gatewayWorkspace = [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'restricted' => !$includeHardware ? false : true,
'virtual' => [
'enabled' => $hardwareMode === 'studio',
'has_virtual_hardware' => false,
'gateway_count' => 0,
'binding_count' => 0,
],
];
} else {
$gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real');
}
$graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId);
$lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace);
$graph = $this->buildGraphFromConfig($graphConfig, [
'department_id' => $departmentId,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
]);
$defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace);
$laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null);
if ($laneId === null) {
throw new \RuntimeException('No lane is available for path outcome projection.');
}
$vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null);
$vehicleTypeIds = [];
if ($vehicleTypeId !== null) {
$vehicleTypeIds[] = $vehicleTypeId;
} else {
foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) {
$id = $this->nullableInt($row['id'] ?? null);
if ($id !== null) {
$vehicleTypeIds[$id] = $id;
}
}
$vehicleTypeIds = array_values($vehicleTypeIds);
}
if ($vehicleTypeIds === []) {
$vehicleTypeIds[] = null;
}
$maxStates = $this->pathLimit(
$payload['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
if ($reg === '') {
$reg = 'TEST123';
}
$customerNumber = array_key_exists('customer_number', $payload)
? $this->nullableInt($payload['customer_number'])
: $this->nullableInt($defaults['customer_number'] ?? null);
$flow = new selfserve_wash_flow();
$outcomes = [];
$warnings = [];
$truncated = false;
$stateCount = 0;
$terminalPathCount = 0;
$questionIds = [];
$pathSampleLimit = $this->pathLimit(
$payload['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT
);
$paths = [];
$scenarioCount = max(1, count($vehicleTypeIds));
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
$remainingStates = $maxStates - $stateCount;
if ($remainingStates <= 0) {
$truncated = true;
break;
}
$scenarioScope = [
'department_id' => $departmentId,
'department' => $this->labelFor('departments', $departmentId, $lookups),
'lane_id' => $laneId,
'lane' => $this->labelFor('lanes', $laneId, $lookups),
'vehicle_type_id' => $scenarioVehicleTypeId,
'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups),
'registration' => $reg,
'customer_number' => $customerNumber,
'config_source' => $configSource,
'config_version_id' => $versionId,
'hardware_mode' => $hardwareMode,
];
$simulate = function (array $answerOverrides) use (
$flow,
$departmentId,
$laneId,
$reg,
$customerNumber,
$scenarioVehicleTypeId,
$configSource,
$graphConfig,
$versionId,
$includeHardware,
$hardwareMode,
$lookups,
$gatewayWorkspace,
$graph
): array {
return $flow->previewStudioSimulation(
$departmentId,
$laneId,
$reg,
$customerNumber,
$scenarioVehicleTypeId,
[
'mode' => 'full_dry_run',
'config_source' => $configSource,
'config_payload' => $graphConfig,
'config_version_id' => $versionId,
'answer_overrides' => $answerOverrides,
'include_hardware' => $includeHardware,
'hardware_mode' => $hardwareMode,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
'graph' => $graph,
],
);
};
$projectionOptions = [
'scope' => $scenarioScope,
'max_states' => $remainingStates,
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
'progress_callback' => function (array $projection) use (
$progressCallback,
&$outcomes,
&$paths,
&$stateCount,
&$terminalPathCount,
&$questionIds,
$maxStates,
$pathSampleLimit,
$scenarioIndex,
$scenarioCount,
$departmentId,
$lookups,
$laneId,
$vehicleTypeId,
$reg,
$customerNumber,
$configSource,
$versionId,
$hardwareMode
): void {
if ($progressCallback === null) {
return;
}
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
if (count($partialPaths) > $pathSampleLimit) {
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
}
$partialQuestionIds = $questionIds;
foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
$partialQuestionIds[(int)$questionId] = true;
}
$projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : [];
$scenarioPercent = (float)($projectionProgress['percent'] ?? 0);
$overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100);
$partialPayload = $this->pathOutcomesPayload(
[
'department_id' => $departmentId,
'department' => $this->labelFor('departments', $departmentId, $lookups),
'lane_id' => $laneId,
'lane' => $this->labelFor('lanes', $laneId, $lookups),
'vehicle_type_id' => $vehicleTypeId,
'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
'vehicle_type_count' => $scenarioCount,
'registration' => $reg,
'customer_number' => $customerNumber,
'config_source' => $configSource,
'config_version_id' => $versionId,
'hardware_mode' => $hardwareMode,
'max_states' => $maxStates,
],
$partialOutcomes,
$partialPaths,
[],
false,
$maxStates,
$stateCount + (int)($projection['summary']['state_count'] ?? 0),
$terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
$partialQuestionIds,
[
'complete' => false,
'percent' => (int)floor($overallPercent),
'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0),
'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0),
'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
'scenario_index' => $scenarioIndex + 1,
'scenario_count' => $scenarioCount,
],
$confirmationRows
);
$progressCallback($partialPayload);
},
'confirmation_rows' => $confirmationRows,
];
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
if (is_array($outcome)) {
$outcomes[] = $outcome;
}
}
foreach ((array)($projection['paths'] ?? []) as $path) {
if (!is_array($path)) {
continue;
}
if (count($paths) < $pathSampleLimit) {
$paths[] = $path;
}
}
foreach ((array)($projection['warnings'] ?? []) as $warning) {
$warnings[] = (string)$warning;
}
$truncated = $truncated || (bool)($projection['truncated'] ?? false);
$stateCount += (int)($projection['summary']['state_count'] ?? 0);
$terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0);
foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
$questionIds[(int)$questionId] = true;
}
}
if ($truncated) {
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.';
}
return $this->pathOutcomesPayload(
[
'department_id' => $departmentId,
'department' => $this->labelFor('departments', $departmentId, $lookups),
'lane_id' => $laneId,
'lane' => $this->labelFor('lanes', $laneId, $lookups),
'vehicle_type_id' => $vehicleTypeId,
'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
'vehicle_type_count' => count($vehicleTypeIds),
'registration' => $reg,
'customer_number' => $customerNumber,
'config_source' => $configSource,
'config_version_id' => $versionId,
'hardware_mode' => $hardwareMode,
'max_states' => $maxStates,
],
$outcomes,
$paths,
$warnings,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
[
'complete' => true,
'percent' => 100,
'state_count' => $stateCount,
'pending_state_count' => 0,
'terminal_path_count' => $terminalPathCount,
'scenario_index' => $scenarioCount,
'scenario_count' => $scenarioCount,
],
$confirmationRows
);
}
/**
* @param callable(array<int,array{question_id:int,value:bool}>):array<string,mixed> $simulate
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
{
$maxStates = $this->pathLimit(
$options['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
$pathSampleLimit = $this->pathLimit(
$options['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT,
0
);
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
$confirmationRows = is_array($options['confirmation_rows'] ?? null) ? (array)$options['confirmation_rows'] : [];
$stack = [[
'answers' => [],
'chain' => [],
]];
$seenStates = [];
$groups = [];
$paths = [];
$stateCount = 0;
$terminalPathCount = 0;
$questionIds = [];
$truncated = false;
while ($stack !== []) {
if ($stateCount >= $maxStates) {
$truncated = true;
break;
}
$state = array_pop($stack);
$answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : [];
ksort($answers, SORT_NUMERIC);
$stateKey = $this->stableJson($answers);
if (isset($seenStates[$stateKey])) {
continue;
}
$seenStates[$stateKey] = true;
$stateCount++;
$simulation = $simulate($this->pathAnswerOverrides($answers));
$nextQuestion = $this->nextPathQuestion($simulation, $answers);
if ($nextQuestion !== null) {
$questionId = (int)($nextQuestion['id'] ?? 0);
if ($questionId > 0) {
$questionIds[$questionId] = true;
foreach ([false, true] as $answerValue) {
$nextAnswers = $answers;
$nextAnswers[$questionId] = $answerValue;
ksort($nextAnswers, SORT_NUMERIC);
$nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : [];
$nextChain[] = [
'question_id' => $questionId,
'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)),
'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)),
'answer' => $answerValue,
'answer_label' => $answerValue ? 'Yes' : 'No',
];
$stack[] = [
'answers' => $nextAnswers,
'chain' => $nextChain,
];
}
}
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
$progressCallback($this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack),
$confirmationRows
));
}
continue;
}
$terminalPathCount++;
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
if (count($paths) < $pathSampleLimit) {
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
}
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
$progressCallback($this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack),
$confirmationRows
));
}
}
return $this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack),
$confirmationRows
);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function confirmPathOutcome(int $departmentId, array $payload, ?int $userId): array
{
$pathSignature = trim((string)($payload['path_signature'] ?? ''));
$resultSignature = trim((string)($payload['result_signature'] ?? ''));
if ($pathSignature === '' || $resultSignature === '') {
throw new \RuntimeException('path_signature and result_signature are required.');
}
$scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : [];
$laneId = $this->nullableInt($payload['lane_id'] ?? $scope['lane_id'] ?? null);
$vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? $scope['vehicle_type_id'] ?? null);
$configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null);
$configSource = strtolower(trim((string)($payload['config_source'] ?? $scope['config_source'] ?? 'draft')));
if ($configSource === '') {
$configSource = 'draft';
}
$answers = is_array($payload['answers'] ?? null) ? array_values((array)$payload['answers']) : [];
$result = is_array($payload['result'] ?? null) ? (array)$payload['result'] : [];
$answersJson = json_encode($this->sortStableValue($answers), JSON_UNESCAPED_UNICODE);
$resultJson = json_encode($this->sortStableValue($result), JSON_UNESCAPED_UNICODE);
$scopeJson = json_encode($this->sortStableValue($scope), JSON_UNESCAPED_UNICODE);
if ($answersJson === false || $resultJson === false || $scopeJson === false) {
throw new \RuntimeException('Could not encode path confirmation payload.');
}
$pdo = db::getPDO();
$select = $pdo->prepare(
"SELECT id
FROM department_selfserve_path_confirmations
WHERE department_id = :department_id
AND path_signature = :path_signature
AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . "
AND deleted_at IS NULL
ORDER BY id DESC
LIMIT 1"
);
$selectParams = [
':department_id' => $departmentId,
':path_signature' => $pathSignature,
];
if ($configVersionId !== null) {
$selectParams[':config_version_id'] = $configVersionId;
}
$select->execute($selectParams);
$row = $select->fetch(\PDO::FETCH_ASSOC);
if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
$update = $pdo->prepare(
"UPDATE department_selfserve_path_confirmations
SET lane_id = :lane_id,
vehicle_type_id = :vehicle_type_id,
config_source = :config_source,
result_signature = :result_signature,
answers_json = :answers_json,
result_json = :result_json,
scope_json = :scope_json,
confirmed_by = :confirmed_by,
confirmed_at = NOW(),
stale_reason = NULL,
deleted_at = NULL
WHERE id = :id"
);
$update->execute([
':lane_id' => $laneId,
':vehicle_type_id' => $vehicleTypeId,
':config_source' => $configSource,
':result_signature' => $resultSignature,
':answers_json' => $answersJson,
':result_json' => $resultJson,
':scope_json' => $scopeJson,
':confirmed_by' => $userId,
':id' => (int)$row['id'],
]);
$id = (int)$row['id'];
} else {
$insert = $pdo->prepare(
"INSERT INTO department_selfserve_path_confirmations
(department_id, lane_id, vehicle_type_id, config_version_id, config_source, path_signature, result_signature, answers_json, result_json, scope_json, confirmed_by, confirmed_at)
VALUES
(:department_id, :lane_id, :vehicle_type_id, :config_version_id, :config_source, :path_signature, :result_signature, :answers_json, :result_json, :scope_json, :confirmed_by, NOW())"
);
$insert->execute([
':department_id' => $departmentId,
':lane_id' => $laneId,
':vehicle_type_id' => $vehicleTypeId,
':config_version_id' => $configVersionId,
':config_source' => $configSource,
':path_signature' => $pathSignature,
':result_signature' => $resultSignature,
':answers_json' => $answersJson,
':result_json' => $resultJson,
':scope_json' => $scopeJson,
':confirmed_by' => $userId,
]);
$id = (int)$pdo->lastInsertId();
}
return [
'id' => $id,
'department_id' => $departmentId,
'lane_id' => $laneId,
'vehicle_type_id' => $vehicleTypeId,
'config_version_id' => $configVersionId,
'config_source' => $configSource,
'path_signature' => $pathSignature,
'result_signature' => $resultSignature,
'answers' => $answers,
'result' => $result,
'scope' => $scope,
'confirmation_status' => 'confirmed',
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function resetPathConfirmation(int $departmentId, array $payload): array
{
$pathSignature = trim((string)($payload['path_signature'] ?? ''));
if ($pathSignature === '') {
throw new \RuntimeException('path_signature is required.');
}
$scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : [];
$configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null);
$pdo = db::getPDO();
$statement = $pdo->prepare(
"UPDATE department_selfserve_path_confirmations
SET deleted_at = NOW()
WHERE department_id = :department_id
AND path_signature = :path_signature
AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . "
AND deleted_at IS NULL"
);
$params = [
':department_id' => $departmentId,
':path_signature' => $pathSignature,
];
if ($configVersionId !== null) {
$params[':config_version_id'] = $configVersionId;
}
$statement->execute($params);
return [
'path_signature' => $pathSignature,
'reset' => true,
'affected' => $statement->rowCount(),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array
{
if (!class_exists(edge_gateway_view_service::class)) {
throw new \RuntimeException('Edge gateway module is not available.');
}
$gateway = (new edge_gateway_view_service())->getGateway($gatewayId);
if (!isset($gateway['id'])) {
throw new \RuntimeException('Edge gateway not found.');
}
if ((int)($gateway['department_id'] ?? 0) !== $departmentId) {
throw new \RuntimeException('Edge gateway does not belong to the selected department.');
}
$action = strtolower(trim($action));
$operations = new edge_gateway_operation_service();
return match ($action) {
'discovery', 'discover' => [
'action' => 'discovery',
'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId),
],
'update' => [
'action' => 'update',
'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId),
],
'uninstall' => [
'action' => 'uninstall',
'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId),
],
'cancel' => [
'action' => 'cancel',
'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId),
],
'rotate_credentials' => [
'action' => 'rotate_credentials',
'gateway' => $operations->rotateCredentials($gatewayId, $userId),
],
'bindings' => [
'action' => 'bindings',
'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId),
],
default => throw new \RuntimeException('Unsupported gateway action: ' . $action),
};
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function applyVirtualHardwareOperation(int $departmentId, array $payload, ?int $userId, array $permissions = []): array
{
if (($permissions['can_edit'] ?? false) !== true) {
throw new \RuntimeException('You do not have permission to edit self-serve studio hardware.');
}
$operation = strtolower(trim((string)($payload['operation'] ?? $payload['action'] ?? '')));
$data = is_array($payload['data'] ?? null) ? (array)$payload['data'] : $payload;
$realWorkspace = $this->buildGatewayWorkspace($departmentId, false);
(new selfserve_virtual_hardware())->applyOperation($departmentId, $operation, $data, $userId, $realWorkspace);
return $this->buildGraph($departmentId, $userId, $permissions);
}
/**
* @return array<string,mixed>
*/
private function buildGatewayWorkspace(int $departmentId, bool $includeVirtual = true): array
{
if (!class_exists(edge_gateway_department_workspace_service::class)) {
$workspace = [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'available' => false,
];
return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace;
}
try {
$workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId);
} catch (\Throwable $exception) {
$workspace = [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [
[
'severity' => 'warning',
'message' => $exception->getMessage(),
],
],
'actions' => [],
'available' => false,
];
}
return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace;
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $gatewayWorkspace
* @return array<string,mixed>
*/
private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array
{
$departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]);
$laneRows = $this->fetchRows('department_lanes', [
'id',
'department',
'name',
'relay_in_id',
'relay_out_id',
'relay_machine_id',
'relay_machine_program_picker_id',
'relay_machine_cleaner_id',
'machine_type_id',
'dynamic_image_id',
'selfserve_enabled',
], ['department' => $departmentId]);
$machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []);
$productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []);
$vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows);
$users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []);
$laneLookupRows = $this->labelRows($laneRows, 'name');
$machineTypeLookupRows = $this->addReferencedMachineTypeRows($this->labelRows($machineTypeRows, 'name'), $laneRows, $config);
$lookups = [
'departments' => $this->labelRows($departmentRows, 'name'),
'lanes' => $laneLookupRows,
'products' => $this->labelRows($productRows, 'name'),
'machine_types' => $machineTypeLookupRows,
'dynamic_images' => $this->dynamicImageRowsFromLanes($laneRows),
'vehicle_types' => $vehicleTypeRows,
'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'),
'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'),
'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'),
'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'),
'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'),
'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])),
'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
'users' => array_map(static function (array $row): array {
$label = trim((string)($row['display_name'] ?? ''));
if ($label === '') {
$label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? '');
}
$row['label'] = $label;
return $row;
}, $users),
];
$labels = [];
foreach ($lookups as $type => $rows) {
if (!is_array($rows)) {
continue;
}
$labels[$type] = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$id = (string)($row['id'] ?? '');
if ($id !== '') {
$labels[$type][$id] = (string)($row['label'] ?? $id);
}
}
}
$lookups['labels'] = $labels;
return $lookups;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $laneRows
* @param array<string,mixed> $config
* @return array<int,array<string,mixed>>
*/
private function addReferencedMachineTypeRows(array $rows, array $laneRows, array $config): array
{
$rowsById = [];
foreach ($rows as $row) {
$id = $this->nullableInt($row['id'] ?? null);
if ($id === null) {
continue;
}
$row['id'] = $id;
$row['label'] = trim((string)($row['label'] ?? $row['name'] ?? '')) ?: 'Machine type ' . $id;
$rowsById[$id] = $row;
}
foreach ($this->referencedMachineTypeIds($laneRows, $config) as $id) {
if (!isset($rowsById[$id])) {
$rowsById[$id] = [
'id' => $id,
'name' => 'Machine type ' . $id,
'label' => 'Machine type ' . $id,
'referenced' => true,
];
}
}
ksort($rowsById, SORT_NUMERIC);
return array_values($rowsById);
}
/**
* @param array<int,array<string,mixed>> $laneRows
* @param array<string,mixed> $config
* @return array<int,int>
*/
private function referencedMachineTypeIds(array $laneRows, array $config): array
{
$ids = [];
foreach ($laneRows as $row) {
$id = $this->nullableInt($row['machine_type_id'] ?? null);
if ($id !== null) {
$ids[$id] = $id;
}
}
foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) {
foreach ((array)($config[$section] ?? []) as $row) {
if (!is_array($row)) {
continue;
}
$id = $this->nullableInt($row['machine_type_id'] ?? null);
if ($id !== null) {
$ids[$id] = $id;
}
}
}
ksort($ids, SORT_NUMERIC);
return array_values($ids);
}
/**
* @param array<int,array<string,mixed>> $laneRows
* @return array<int,array<string,mixed>>
*/
private function dynamicImageRowsFromLanes(array $laneRows): array
{
$rowsById = [];
foreach ($this->supportedDynamicImageRows() as $row) {
$id = $this->nullableInt($row['id'] ?? null);
if ($id !== null) {
$rowsById[$id] = $row;
}
}
foreach ($laneRows as $lane) {
$id = $this->nullableInt($lane['dynamic_image_id'] ?? null);
if ($id === null || isset($rowsById[$id])) {
continue;
}
$rowsById[$id] = [
'id' => $id,
'name' => 'Dynamic image ' . $id,
'label' => 'Dynamic image ' . $id,
'referenced' => true,
];
}
ksort($rowsById, SORT_NUMERIC);
return array_values($rowsById);
}
/**
* @return array<int,array<string,mixed>>
*/
private function supportedDynamicImageRows(): array
{
return [
[
'id' => 1,
'name' => 'Machine 1',
'label' => 'Machine 1',
'class' => 'dynamicimages\\images\\machine_1',
],
];
}
/**
* @param array<int,array<string,mixed>> $nodes
* @param array<int,array<string,mixed>> $edges
* @param array<string,mixed> $workspace
*/
private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void
{
$relayServices = $this->relayServicesFromWorkspace($workspace);
foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = $this->gatewayIdentifier($gateway);
if ($gatewayId === '') {
continue;
}
$nodeId = $this->gatewayNodeId($gateway);
$nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [
'object_id' => $gatewayId,
'raw' => $gateway,
'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'),
], 1440, 160 + ($index * 150));
foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$bindingServices = $this->bindingServices($binding, $relayId, $relayServices);
if ($bindingServices !== []) {
$binding['services'] = $bindingServices;
if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) {
$binding['role'] = $bindingServices[0];
}
}
$bindingId = $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding);
$nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [
'object_id' => $relayId,
'raw' => $binding,
'subtitle' => (string)($binding['role'] ?? 'Relay binding'),
], 1720, 180 + (($index * 4 + $bindingIndex) * 100));
$edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds');
$edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls');
}
}
$relayIndex = 0;
foreach ((array)($workspace['relays'] ?? []) as $relay) {
if (!is_array($relay)) {
continue;
}
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
if ($relayId === '') {
continue;
}
$nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [
'object_id' => $relayId,
'raw' => $relay,
'subtitle' => 'Hardware relay',
], 2020, 180 + ($relayIndex * 100));
$relayIndex++;
}
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
if (!is_array($lane)) {
continue;
}
$laneId = (int)($lane['id'] ?? 0);
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (!is_array($slot)) {
continue;
}
$relayId = trim((string)($slot['relay_id'] ?? ''));
if ($laneId > 0 && $relayId !== '') {
$edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay'));
}
}
}
}
/**
* @param array<int,array<string,mixed>> $edges
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $workspace
*/
private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void
{
$bindingsByService = [];
foreach ($this->gatewayBindingReferences($workspace) as $binding) {
foreach ((array)$binding['services'] as $service) {
$bindingsByService[$service][] = $binding;
}
}
foreach ($tasks as $task) {
$taskId = (int)($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
foreach ($bindingsByService[$service] ?? [] as $binding) {
$edges[] = $this->edge(
'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'],
'task:' . $taskId,
(string)$binding['node_id'],
'task_service',
$service
);
}
}
}
}
/**
* @param array<int,array<string,mixed>> $edges
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $workspace
*/
private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void
{
foreach ($actions as $action) {
if (!is_array($action)) {
continue;
}
$normalizedAction = selfserve_studio_actions::normalize($action);
$actionId = (int)($normalizedAction['id'] ?? 0);
if ($actionId <= 0) {
continue;
}
$relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']));
if ($relayRole === '' || $relayRole === 'ACTION') {
continue;
}
$laneId = (int)($normalizedAction['lane'] ?? 0);
foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) {
$edges[] = $this->edge(
'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'],
'action:' . $actionId,
'relay:' . $target['relay_id'],
'action_relay',
$relayRole
);
}
}
}
/**
* @param array<string,mixed> $workspace
* @return array<int,array{relay_id:string,lane_id:int}>
*/
private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array
{
$targets = [];
$seen = [];
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
if (!is_array($lane)) {
continue;
}
$laneId = (int)($lane['id'] ?? 0);
if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) {
continue;
}
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (!is_array($slot)) {
continue;
}
$slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '');
$relayId = trim((string)($slot['relay_id'] ?? ''));
if ($relayId === '' || $slotRole !== $relayRole) {
continue;
}
$key = $laneId . ':' . $relayRole . ':' . $relayId;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$targets[] = [
'relay_id' => $relayId,
'lane_id' => $laneId,
];
}
}
return $targets;
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $operation
*/
private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void
{
$action = strtolower((string)($operation['action'] ?? ''));
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
$data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : [];
$id = (int)($operation['id'] ?? $data['id'] ?? 0);
if ($action === 'connect') {
$this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
return;
}
if ($action === 'disconnect') {
$this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
return;
}
if ($action === 'reorder') {
$this->applyConfigReorder($config, $entity, (array)($operation['items'] ?? []));
return;
}
if ($action === 'upsert_path' || ($entity === 'path' && $action === 'upsert')) {
$this->upsertConfigPath($departmentId, $config, $data);
return;
}
if ($entity === '') {
throw new \RuntimeException('Studio graph operation is missing entity.');
}
if ($entity === 'lane') {
$this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
return;
}
if ($entity === 'rule') {
throw new \RuntimeException('Standalone rule operations are not supported in self-serve rules v2.');
}
if ($action === 'create') {
$this->createConfigEntity($departmentId, $config, $entity, $data);
return;
}
if ($id <= 0) {
throw new \RuntimeException('Studio graph operation is missing id.');
}
if ($action === 'update') {
$this->updateConfigEntity($departmentId, $config, $entity, $id, $data);
return;
}
if ($action === 'delete') {
$this->deleteConfigEntity($config, $entity, $id);
return;
}
throw new \RuntimeException('Unsupported studio graph operation: ' . $action);
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $data
*/
private function upsertConfigPath(int $departmentId, array &$config, array $data): void
{
$scope = $this->normalizePathEditorScope($departmentId, is_array($data['scope'] ?? null) ? (array)$data['scope'] : $data);
$answers = $this->normalizePathEditorAnswers($data['answers'] ?? []);
if ($answers === []) {
throw new \RuntimeException('Path editor operation requires at least one answer.');
}
$this->assertPathEditorQuestionsExist($config, $answers);
$result = $this->normalizePathEditorResult(is_array($data['result'] ?? null) ? (array)$data['result'] : $data);
$previousPathKey = trim((string)($data['previous_path_key'] ?? ''));
$pathKey = trim((string)($data['path_key'] ?? ''));
if ($pathKey === '') {
$pathKey = $previousPathKey !== '' ? $previousPathKey : $this->pathEditorPathKey($scope, $answers);
}
if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
$config['v2_meta'] = [];
}
if (!isset($config['v2_meta']['path_editor']) || !is_array($config['v2_meta']['path_editor'])) {
$config['v2_meta']['path_editor'] = [];
}
if (!isset($config['v2_meta']['path_editor']['paths']) || !is_array($config['v2_meta']['path_editor']['paths'])) {
$config['v2_meta']['path_editor']['paths'] = [];
}
if ($previousPathKey !== '' && $previousPathKey !== $pathKey && isset($config['v2_meta']['path_editor']['paths'][$previousPathKey])) {
$config['v2_meta']['path_editor']['paths'][$pathKey] = $config['v2_meta']['path_editor']['paths'][$previousPathKey];
unset($config['v2_meta']['path_editor']['paths'][$previousPathKey]);
}
$paths = &$config['v2_meta']['path_editor']['paths'];
$existing = is_array($paths[$pathKey] ?? null) ? (array)$paths[$pathKey] : [];
$conditionId = $this->nullableInt($existing['condition_id'] ?? $data['condition_id'] ?? $data['existing_condition_id'] ?? null);
$existingTaskIds = $this->pathEditorExistingTaskIds($existing, $data);
$conditionId = $this->upsertPathEditorCondition($departmentId, $config, $pathKey, $scope, $answers, $result, $conditionId);
$taskIds = [];
if ((bool)$result['machine_allowed']) {
$baseOrderPriority = $this->pathEditorTaskBaseOrderPriority($config, $existingTaskIds);
foreach (array_values((array)($result['tasks'] ?? [])) as $index => $taskResult) {
if (!is_array($taskResult)) {
continue;
}
$taskIds[] = $this->upsertPathEditorTask(
$departmentId,
$config,
$pathKey,
$scope,
$taskResult,
$conditionId,
$existingTaskIds[$index] ?? null,
$baseOrderPriority + ($index * 10)
);
}
foreach (array_slice($existingTaskIds, count($taskIds)) as $staleTaskId) {
$this->deleteConfigEntity($config, 'task', $staleTaskId);
}
} else {
foreach ($existingTaskIds as $staleTaskId) {
$this->deleteConfigEntity($config, 'task', $staleTaskId);
}
}
$taskId = $taskIds[0] ?? null;
$pathSignature = $this->pathSignature($scope, $answers);
$resultSignature = $this->pathEditorResultSignature($result, $taskIds);
$paths[$pathKey] = [
'path_key' => $pathKey,
'condition_id' => $conditionId,
'task_id' => $taskId,
'task_ids' => $taskIds,
'scope' => $scope,
'answers' => $answers,
'result' => $result,
'path_signature' => $pathSignature,
'result_signature' => $resultSignature,
'updated_at' => date('c'),
];
unset($paths);
}
/**
* @param array<string,mixed> $scope
* @return array<string,mixed>
*/
private function normalizePathEditorScope(int $departmentId, array $scope): array
{
return [
'department_id' => $departmentId,
'lane_id' => $this->nullableInt($scope['lane_id'] ?? $scope['lane'] ?? null),
'vehicle_type_id' => $this->nullableInt($scope['vehicle_type_id'] ?? $scope['product'] ?? $scope['product_id'] ?? null),
'machine_type_id' => $this->nullableInt($scope['machine_type_id'] ?? null),
'config_source' => strtolower(trim((string)($scope['config_source'] ?? 'draft'))) ?: 'draft',
'hardware_mode' => strtolower(trim((string)($scope['hardware_mode'] ?? 'studio'))) ?: 'studio',
];
}
/**
* @return array<int,array{question_id:int,value:bool,answer:bool,answer_label:string}>
*/
private function normalizePathEditorAnswers(mixed $answers): array
{
$rows = [];
if (!is_array($answers)) {
return $rows;
}
foreach ($answers as $key => $entry) {
if (is_array($entry)) {
$questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key);
$rawValue = $entry['value'] ?? $entry['answer'] ?? null;
} else {
$questionId = (int)$key;
$rawValue = $entry;
}
if ($questionId <= 0) {
continue;
}
$value = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($value === null) {
continue;
}
$rows[] = [
'question_id' => $questionId,
'value' => (bool)$value,
'answer' => (bool)$value,
'answer_label' => (bool)$value ? 'Yes' : 'No',
];
}
return $rows;
}
/**
* @param array<string,mixed> $config
* @param array<int,array{question_id:int,value:bool,answer:bool,answer_label:string}> $answers
*/
private function assertPathEditorQuestionsExist(array $config, array $answers): void
{
$questionIds = [];
foreach ((array)($config['questions'] ?? []) as $question) {
if (is_array($question)) {
$questionIds[(int)($question['id'] ?? 0)] = true;
}
}
foreach ($answers as $answer) {
$questionId = (int)($answer['question_id'] ?? 0);
if ($questionId > 0 && !isset($questionIds[$questionId])) {
throw new \RuntimeException('Path editor answer references unknown question ' . $questionId . '.');
}
}
}
/**
* @param array<string,mixed> $result
* @return array<string,mixed>
*/
private function normalizePathEditorResult(array $result): array
{
$machineAllowed = filter_var($result['machine_allowed'] ?? $result['allowed'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$machineAllowed = $machineAllowed !== false;
$services = $machineAllowed ? $this->normalizeServiceList($result['services'] ?? ['MACHINE']) : $this->normalizeServiceList($result['services'] ?? []);
if ($machineAllowed && !in_array('MACHINE', $services, true)) {
$services[] = 'MACHINE';
sort($services);
}
try {
$buttons = department_selfserve_tasks_o::normalizeButtonsInput($result['buttons'] ?? []);
} catch (\Throwable $exception) {
throw new \RuntimeException('Invalid path editor buttons: ' . $exception->getMessage());
}
$dynamicImagesVehicleType = $this->nullableInt($result['dynamic_images_vehicle_type'] ?? null);
$hasTaskList = array_key_exists('tasks', $result) && is_array($result['tasks']);
$tasks = [];
if ($machineAllowed) {
if ($hasTaskList) {
foreach (array_values((array)$result['tasks']) as $index => $task) {
if (!is_array($task)) {
continue;
}
$tasks[] = $this->normalizePathEditorTaskResult((array)$task, $services, $index);
}
} else {
$tasks[] = $this->normalizePathEditorTaskResult([
'task' => $result['task'] ?? $result['task_text'] ?? 'Start machine',
'description' => $result['description'] ?? '',
'services' => $services,
'buttons' => $buttons,
'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
], $services, 0);
}
}
if ($hasTaskList) {
$buttons = $this->flattenPathEditorTaskButtons($tasks);
$services = $this->mergePathEditorTaskServices($services, $tasks, $machineAllowed);
if ($dynamicImagesVehicleType === null) {
foreach ($tasks as $task) {
$dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null);
if ($dynamicImagesVehicleType !== null) {
break;
}
}
}
}
return [
'machine_allowed' => $machineAllowed,
'task' => trim((string)($result['task'] ?? $result['task_text'] ?? 'Start machine')) ?: 'Start machine',
'description' => trim((string)($result['description'] ?? '')),
'services' => $services,
'buttons' => $buttons,
'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
'tasks' => $tasks,
'condition_name' => trim((string)($result['condition_name'] ?? '')),
];
}
/**
* @param array<string,mixed> $task
* @param array<int,string> $fallbackServices
* @return array<string,mixed>
*/
private function normalizePathEditorTaskResult(array $task, array $fallbackServices, int $index): array
{
try {
$buttons = department_selfserve_tasks_o::normalizeButtonsInput($task['buttons'] ?? []);
} catch (\Throwable $exception) {
throw new \RuntimeException('Invalid path editor task buttons: ' . $exception->getMessage());
}
$services = $this->normalizeServiceList($task['services'] ?? $fallbackServices);
if (!in_array('MACHINE', $services, true)) {
$services[] = 'MACHINE';
}
$dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null);
if (($dynamicImagesVehicleType !== null || in_array('program_picker', $buttons, true)) && !in_array('PROGRAM_PICKER', $services, true)) {
$services[] = 'PROGRAM_PICKER';
}
sort($services);
return [
'task' => trim((string)($task['task'] ?? $task['label'] ?? ('Task ' . ($index + 1)))) ?: ('Task ' . ($index + 1)),
'description' => trim((string)($task['description'] ?? '')),
'services' => $services,
'buttons' => $buttons,
'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
];
}
/**
* @param array<int,array<string,mixed>> $tasks
* @return array<int,mixed>
*/
private function flattenPathEditorTaskButtons(array $tasks): array
{
$buttons = [];
foreach ($tasks as $task) {
if (!is_array($task)) {
continue;
}
foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) {
$buttons[] = $button;
}
}
return department_selfserve_tasks_o::normalizeButtonsInput($buttons);
}
/**
* @param array<int,string> $services
* @param array<int,array<string,mixed>> $tasks
* @return array<int,string>
*/
private function mergePathEditorTaskServices(array $services, array $tasks, bool $machineAllowed): array
{
$merged = [];
foreach ($this->normalizeServiceList($services) as $service) {
$merged[$service] = true;
}
foreach ($tasks as $task) {
if (!is_array($task)) {
continue;
}
foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
$merged[$service] = true;
}
}
if ($machineAllowed) {
$merged['MACHINE'] = true;
}
$values = array_keys($merged);
sort($values);
return $values;
}
/**
* @param array<string,mixed> $scope
* @param array<int,array<string,mixed>> $answers
*/
private function pathEditorPathKey(array $scope, array $answers): string
{
return 'path_' . substr(hash('sha256', $this->stableJson([
'scope' => $scope,
'answers' => $answers,
])), 0, 16);
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $scope
* @param array<int,array<string,mixed>> $answers
* @param array<string,mixed> $result
*/
private function upsertPathEditorCondition(int $departmentId, array &$config, string $pathKey, array $scope, array $answers, array $result, ?int $conditionId): int
{
if ($conditionId === null || $this->configRowIndex((array)($config['conditions'] ?? []), $conditionId) === null) {
$conditionId = $this->allocateConfigId($config, 'condition');
$rows = &$this->configRows($config, 'condition');
$rows[] = [
'id' => $conditionId,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'name' => 'Generated path condition',
'description' => '',
'expression' => $this->emptyV2Expression(),
];
unset($rows);
}
$name = trim((string)($result['condition_name'] ?? ''));
if ($name === '') {
$name = 'Path: ' . $this->pathEditorAnswerSummary($answers);
}
$rows = &$this->configRows($config, 'condition');
$index = $this->configRowIndex($rows, $conditionId);
if ($index === null) {
throw new \RuntimeException('Generated path condition could not be created.');
}
$rows[$index] = $this->mergeConfigEntityData('condition', $rows[$index], [
'department' => $departmentId,
'lane' => $scope['lane_id'] ?? 0,
'product' => $scope['vehicle_type_id'] ?? 0,
'machine_type_id' => $scope['machine_type_id'] ?? null,
'name' => $name,
'description' => 'Generated by Path Editor for ' . $this->pathEditorAnswerSummary($answers),
'expression' => $this->pathEditorConditionExpression($answers),
]);
$rows[$index]['generated_by'] = 'path_editor';
$rows[$index]['path_key'] = $pathKey;
unset($rows);
return $conditionId;
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $scope
* @param array<string,mixed> $result
*/
private function upsertPathEditorTask(int $departmentId, array &$config, string $pathKey, array $scope, array $result, int $conditionId, ?int $taskId, int $orderPriority): int
{
if ($taskId === null || $this->configRowIndex((array)($config['tasks'] ?? []), $taskId) === null) {
$taskId = $this->allocateConfigId($config, 'task');
$rows = &$this->configRows($config, 'task');
$rows[] = [
'id' => $taskId,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'gate_type' => selfserve_task_gate_type::CONDITION->value,
'gate_ref_id' => $conditionId,
'task' => 'Start machine',
'description' => '',
'order_priority' => $orderPriority,
'services' => [],
'buttons' => [],
'dynamic_images_vehicle_type' => null,
];
unset($rows);
}
$rows = &$this->configRows($config, 'task');
$index = $this->configRowIndex($rows, $taskId);
if ($index === null) {
throw new \RuntimeException('Generated path task could not be created.');
}
$rows[$index] = $this->mergeConfigEntityData('task', $rows[$index], [
'department' => $departmentId,
'lane' => $scope['lane_id'] ?? 0,
'product' => $scope['vehicle_type_id'] ?? 0,
'machine_type_id' => $scope['machine_type_id'] ?? null,
'condition_id' => $conditionId,
'gate_type' => selfserve_task_gate_type::CONDITION->value,
'gate_ref_id' => $conditionId,
'task' => $result['task'],
'description' => $result['description'],
'order_priority' => $orderPriority,
'services' => $result['services'],
'buttons' => $result['buttons'],
'dynamic_images_vehicle_type' => $result['dynamic_images_vehicle_type'],
]);
$rows[$index]['condition_id'] = $conditionId;
$rows[$index]['generated_by'] = 'path_editor';
$rows[$index]['path_key'] = $pathKey;
unset($rows);
return $taskId;
}
/**
* @param array<string,mixed> $existing
* @param array<string,mixed> $data
* @return array<int,int>
*/
private function pathEditorExistingTaskIds(array $existing, array $data): array
{
$ids = [];
foreach ([$existing['task_ids'] ?? null, $data['task_ids'] ?? null] as $taskIds) {
if (!is_array($taskIds)) {
continue;
}
foreach ($taskIds as $taskId) {
$normalizedTaskId = $this->nullableInt($taskId);
if ($normalizedTaskId !== null) {
$ids[] = $normalizedTaskId;
}
}
}
foreach ([
$existing['task_id'] ?? null,
$data['task_id'] ?? null,
$data['existing_task_id'] ?? null,
] as $taskId) {
$normalizedTaskId = $this->nullableInt($taskId);
if ($normalizedTaskId !== null) {
$ids[] = $normalizedTaskId;
}
}
return array_values(array_unique(array_filter($ids, static fn(int $taskId): bool => $taskId > 0)));
}
/**
* @param array<string,mixed> $config
* @param array<int,int> $taskIds
*/
private function pathEditorTaskBaseOrderPriority(array $config, array $taskIds): int
{
$taskRows = (array)($config['tasks'] ?? []);
foreach ($taskIds as $taskId) {
$index = $this->configRowIndex($taskRows, $taskId);
if ($index !== null && is_array($taskRows[$index] ?? null)) {
return (int)($taskRows[$index]['order_priority'] ?? 0);
}
}
return $this->nextOrderPriority($taskRows);
}
/**
* @param array<int,array<string,mixed>> $answers
* @return array<string,mixed>
*/
private function pathEditorConditionExpression(array $answers): array
{
return [
'type' => 'group',
'operator' => 'ALL',
'children' => array_values(array_map(static fn(array $answer): array => [
'type' => 'predicate',
'subject_type' => 'question',
'subject_id' => (int)($answer['question_id'] ?? 0),
'operator' => ((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'IS_TRUE' : 'IS_FALSE',
], $answers)),
];
}
/**
* @param array<int,array<string,mixed>> $answers
*/
private function pathEditorAnswerSummary(array $answers): string
{
$parts = [];
foreach ($answers as $answer) {
$parts[] = 'Q' . (int)($answer['question_id'] ?? 0) . '=' . (((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'Yes' : 'No');
}
return implode(', ', $parts) ?: 'answers';
}
/**
* @param array<string,mixed> $scope
* @param array<int,array<string,mixed>> $answers
*/
private function pathSignature(array $scope, array $answers): string
{
return hash('sha256', $this->stableJson([
'scope' => $this->pathScopeKey($scope),
'answers' => array_values(array_map(static fn(array $answer): array => [
'question_id' => (int)($answer['question_id'] ?? 0),
'answer' => (bool)($answer['answer'] ?? $answer['value'] ?? false),
], $answers)),
]));
}
/**
* @param array<string,mixed> $result
*/
private function pathEditorResultSignature(array $result, array $taskIds): string
{
$tasks = [];
if ((bool)($result['machine_allowed'] ?? false)) {
foreach (array_values((array)($result['tasks'] ?? [])) as $index => $task) {
if (!is_array($task)) {
continue;
}
$tasks[] = [
'id' => (int)($taskIds[$index] ?? 0),
'label' => (string)($task['task'] ?? $task['label'] ?? ''),
'services' => $this->normalizeServiceList($task['services'] ?? []),
'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
'dynamic_images_vehicle_type' => $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null),
];
}
}
return hash('sha256', $this->stableJson([
'allowed' => (bool)($result['machine_allowed'] ?? false),
'services' => $this->normalizeServiceList($result['services'] ?? []),
'tasks' => $tasks,
'buttons' => $this->normalizeArrayPayload($result['buttons'] ?? []),
'signals' => [],
]));
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $data
*/
private function createConfigEntity(int $departmentId, array &$config, string $entity, array $data): void
{
$id = $this->allocateConfigId($config, $entity);
$row = match ($entity) {
'question' => [
'id' => $id,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'condition_id' => null,
'question' => 'New question',
'description' => '',
'order_priority' => $this->nextOrderPriority((array)($config['questions'] ?? [])),
],
'condition' => [
'id' => $id,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'name' => 'New condition',
'description' => '',
'expression' => $this->emptyV2Expression(),
],
'task' => [
'id' => $id,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'gate_type' => selfserve_task_gate_type::ALWAYS->value,
'gate_ref_id' => null,
'task' => 'New task',
'description' => '',
'order_priority' => $this->nextOrderPriority((array)($config['tasks'] ?? [])),
'services' => [],
'buttons' => [],
'dynamic_images_vehicle_type' => null,
],
'action' => [
'id' => $id,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'name' => 'Open lane entrance port',
'description' => '',
'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND,
'wash_mode' => selfserve_studio_actions::MODE_BOTH,
'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT,
'relay_state' => null,
'enabled' => true,
'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])),
'options' => [
'delay_ms' => 0,
'toggle_after_seconds' => 1,
'retry_count' => 0,
'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE,
'record_event' => true,
],
],
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
$row = $this->mergeConfigEntityData($entity, $row, $data);
$rows = &$this->configRows($config, $entity);
$rows[] = $row;
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $data
*/
private function updateConfigEntity(int $departmentId, array &$config, string $entity, int $id, array $data): void
{
unset($departmentId);
$rows = &$this->configRows($config, $entity);
$index = $this->configRowIndex($rows, $id);
if ($index === null) {
throw new \RuntimeException(ucfirst($entity) . ' is not available in the current draft.');
}
$rows[$index] = $this->mergeConfigEntityData($entity, $rows[$index], $data);
}
/**
* @param array<string,mixed> $config
*/
private function deleteConfigEntity(array &$config, string $entity, int $id): void
{
$rows = &$this->configRows($config, $entity);
$rows = array_values(array_filter($rows, static fn(array $row): bool => (int)($row['id'] ?? 0) !== $id));
if ($entity === 'question') {
$conditionRows = &$this->configRows($config, 'condition');
foreach ($conditionRows as &$condition) {
if (is_array($condition) && is_array($condition['expression'] ?? null)) {
$condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'question', $id);
}
}
unset($condition);
$taskRows = &$this->configRows($config, 'task');
foreach ($taskRows as &$task) {
if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::QUESTION->value && (int)($task['gate_ref_id'] ?? 0) === $id) {
$task['gate_type'] = selfserve_task_gate_type::ALWAYS->value;
$task['gate_ref_id'] = null;
$task['condition_id'] = null;
}
}
unset($task);
}
if ($entity === 'condition') {
$conditionRows = &$this->configRows($config, 'condition');
foreach ($conditionRows as &$condition) {
if (!is_array($condition)) {
continue;
}
if ((int)($condition['condition_id'] ?? 0) === $id) {
$condition['condition_id'] = null;
}
if (is_array($condition['expression'] ?? null)) {
$condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'condition', $id);
}
}
unset($condition);
$questionRows = &$this->configRows($config, 'question');
foreach ($questionRows as &$question) {
if (is_array($question) && (int)($question['condition_id'] ?? 0) === $id) {
$question['condition_id'] = null;
}
}
unset($question);
$taskRows = &$this->configRows($config, 'task');
foreach ($taskRows as &$task) {
if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::CONDITION->value && (int)($task['gate_ref_id'] ?? 0) === $id) {
$task['gate_type'] = selfserve_task_gate_type::ALWAYS->value;
$task['gate_ref_id'] = null;
$task['condition_id'] = null;
}
}
unset($task);
$actionRows = &$this->configRows($config, 'action');
foreach ($actionRows as &$action) {
if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) {
$action['condition_id'] = null;
}
}
unset($action);
}
}
/**
* @param array<string,mixed> $config
* @param array<int,array<string,mixed>> $items
*/
private function applyConfigReorder(array &$config, string $entity, array $items): void
{
if (!in_array($entity, ['question', 'task', 'action'], true)) {
throw new \RuntimeException('Only questions, tasks, and actions can be reordered.');
}
$priorities = [];
foreach ($items as $index => $item) {
if (is_array($item)) {
$priorities[(int)($item['id'] ?? 0)] = (int)($item['order_priority'] ?? $index);
}
}
$rows = &$this->configRows($config, $entity);
foreach ($rows as &$row) {
$id = (int)($row['id'] ?? 0);
if (isset($priorities[$id])) {
$row['order_priority'] = $priorities[$id];
}
}
unset($row);
}
/**
* @param array<string,mixed> $config
*/
private function applyConfigConnection(int $departmentId, array &$config, string $source, string $target, bool $disconnect): void
{
[$sourceType, $sourceIdRaw] = $this->parseNodeId($source);
[$targetType, $targetIdRaw] = $this->parseNodeId($target);
if ($sourceType === '' || $targetType === '' || $sourceIdRaw === '') {
throw new \RuntimeException('Invalid connection endpoints.');
}
if ($sourceType === 'task' && $targetType === 'binding') {
$service = $this->serviceForBindingNode($departmentId, $target);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateConfigTaskServiceConnection($config, (int)$sourceIdRaw, $service, $disconnect);
return;
}
if ($sourceType === 'binding' && $targetType === 'task') {
$service = $this->serviceForBindingNode($departmentId, $source);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateConfigTaskServiceConnection($config, (int)$targetIdRaw, $service, $disconnect);
return;
}
$sourceId = (int)$sourceIdRaw;
$targetId = (int)$targetIdRaw;
if ($sourceId <= 0 || $targetId <= 0) {
throw new \RuntimeException('Invalid connection endpoints.');
}
if (in_array($sourceType, ['question', 'condition'], true) && $targetType === 'condition') {
if ($sourceType === 'condition' && $sourceId === $targetId && !$disconnect) {
throw new \RuntimeException('Condition expressions cannot reference themselves.');
}
$this->updateConditionExpressionConnection($config, $targetId, $sourceType, $sourceId, $disconnect);
return;
}
if ($sourceType === 'condition' && $targetType === 'question') {
$rows = &$this->configRows($config, 'question');
$index = $this->configRowIndex($rows, $targetId);
if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
$rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
}
return;
}
if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') {
$rows = &$this->configRows($config, 'task');
$index = $this->configRowIndex($rows, $targetId);
if ($index === null) {
return;
}
$currentType = strtoupper((string)($rows[$index]['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
$currentRef = (int)($rows[$index]['gate_ref_id'] ?? 0);
if ($disconnect && ($currentType !== strtoupper($sourceType) || $currentRef !== $sourceId)) {
return;
}
$rows[$index]['gate_type'] = $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType);
$rows[$index]['gate_ref_id'] = $disconnect ? null : $sourceId;
$rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null;
return;
}
if ($sourceType === 'condition' && $targetType === 'action') {
$rows = &$this->configRows($config, 'action');
$index = $this->configRowIndex($rows, $targetId);
if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
$rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
}
return;
}
}
/**
* @param array<string,mixed> $config
*/
private function updateConditionExpressionConnection(array &$config, int $conditionId, string $subjectType, int $subjectId, bool $disconnect): void
{
$rows = &$this->configRows($config, 'condition');
$index = $this->configRowIndex($rows, $conditionId);
if ($index === null) {
throw new \RuntimeException('Condition is not available in the current draft.');
}
$expression = $this->normalizeExpressionNode($rows[$index]['expression'] ?? $this->emptyV2Expression());
if (($expression['type'] ?? 'group') === 'predicate') {
$expression = [
'type' => 'group',
'operator' => 'ALL',
'children' => [$expression],
];
} elseif (($expression['type'] ?? 'group') !== 'group') {
$expression = [
'type' => 'group',
'operator' => 'ALL',
'children' => [$expression],
];
}
if ($disconnect) {
$rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId);
return;
}
if (!$this->expressionHasPredicate($expression, $subjectType, $subjectId)) {
$expression['children'][] = [
'type' => 'predicate',
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'operator' => 'IS_TRUE',
];
}
$rows[$index]['expression'] = $expression;
}
/**
* @param array<string,mixed> $config
*/
private function updateConfigTaskServiceConnection(array &$config, int $taskId, string $service, bool $disconnect): void
{
if ($taskId <= 0 || $service === '') {
return;
}
$rows = &$this->configRows($config, 'task');
$index = $this->configRowIndex($rows, $taskId);
if ($index === null) {
throw new \RuntimeException('Task is not available in the current draft.');
}
$services = array_fill_keys($this->normalizeServiceList($rows[$index]['services'] ?? []), true);
if ($disconnect) {
unset($services[$service]);
} else {
$services[$service] = true;
}
$rows[$index]['services'] = array_keys($services);
}
/**
* @param array<string,mixed> $config
* @return array<int,array<string,mixed>>
*/
private function &configRows(array &$config, string $entity): array
{
$key = match ($entity) {
'question' => 'questions',
'condition' => 'conditions',
'task' => 'tasks',
'action' => 'actions',
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
if (!isset($config[$key]) || !is_array($config[$key])) {
$config[$key] = [];
}
return $config[$key];
}
/**
* @param array<int,array<string,mixed>> $rows
*/
private function configRowIndex(array $rows, int $id): ?int
{
foreach ($rows as $index => $row) {
if ((int)($row['id'] ?? 0) === $id) {
return (int)$index;
}
}
return null;
}
/**
* @param array<string,mixed> $config
*/
private function allocateConfigId(array &$config, string $entity): int
{
$name = match ($entity) {
'question', 'condition', 'task', 'action' => $entity,
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
$config['v2_meta'] = [];
}
if (!isset($config['v2_meta']['next_ids']) || !is_array($config['v2_meta']['next_ids'])) {
$config['v2_meta']['next_ids'] = [];
}
$rows = &$this->configRows($config, $entity);
$maxId = 0;
foreach ($rows as $row) {
$maxId = max($maxId, (int)($row['id'] ?? 0));
}
$nextId = max((int)($config['v2_meta']['next_ids'][$name] ?? 0), $maxId + 1);
$config['v2_meta']['next_ids'][$name] = $nextId + 1;
return $nextId;
}
/**
* @param array<int,array<string,mixed>> $rows
*/
private function nextOrderPriority(array $rows): int
{
$max = 0;
foreach ($rows as $row) {
$max = max($max, (int)($row['order_priority'] ?? 0));
}
return $max + 10;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
private function mergeConfigEntityData(string $entity, array $row, array $data): array
{
if (array_key_exists('label', $data)) {
if ($entity === 'question' && !array_key_exists('question', $data)) {
$data['question'] = $data['label'];
} elseif ($entity === 'condition' && !array_key_exists('name', $data)) {
$data['name'] = $data['label'];
} elseif ($entity === 'task' && !array_key_exists('task', $data)) {
$data['task'] = $data['label'];
} elseif ($entity === 'action' && !array_key_exists('name', $data)) {
$data['name'] = $data['label'];
}
}
$fields = match ($entity) {
'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'],
'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'],
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
foreach ($fields as $field) {
if (!array_key_exists($field, $data)) {
continue;
}
$row[$field] = $this->normalizeConfigField($field, $data[$field]);
}
if ($entity === 'condition' && !is_array($row['expression'] ?? null)) {
$row['expression'] = $this->emptyV2Expression();
}
if ($entity === 'task') {
$row['gate_type'] = $this->normalizeGateType((string)($row['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
if ($row['gate_type'] === selfserve_task_gate_type::ALWAYS->value) {
$row['gate_ref_id'] = null;
$row['condition_id'] = null;
}
}
if ($entity === 'action') {
$row = selfserve_studio_actions::normalize($row);
}
return $row;
}
private function normalizeConfigField(string $field, mixed $value): mixed
{
if ($field === 'expression') {
return $this->normalizeExpressionNode($value);
}
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) {
return $this->nullableInt($value);
}
if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) {
return (int)$value;
}
if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) {
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
if ($field === 'services') {
return $this->normalizeServiceList($value);
}
if ($field === 'buttons') {
return $this->normalizeArrayPayload($value);
}
if ($field === 'options') {
return is_array($value) ? (array)$value : [];
}
if ($field === 'gate_type') {
return $this->normalizeGateType((string)$value);
}
return is_array($value) ? $value : (string)$value;
}
/**
* @param array<string,mixed> $operation
*/
private function applyOperation(int $departmentId, array $operation, array $permissions = []): void
{
$action = strtolower((string)($operation['action'] ?? ''));
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
$data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : [];
$id = (int)($operation['id'] ?? $data['id'] ?? 0);
if ($action === 'connect') {
$this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
return;
}
if ($action === 'disconnect') {
$this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
return;
}
if ($action === 'reorder') {
$this->applyReorder($entity, (array)($operation['items'] ?? []));
return;
}
if ($entity === '') {
throw new \RuntimeException('Studio graph operation is missing entity.');
}
if ($entity === 'lane') {
$this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
return;
}
if ($action === 'create') {
$this->createEntity($departmentId, $entity, $data);
return;
}
if ($id <= 0) {
throw new \RuntimeException('Studio graph operation is missing id.');
}
if ($action === 'update') {
$this->updateEntity($departmentId, $entity, $id, $data);
return;
}
if ($action === 'delete') {
$this->softDeleteEntity($departmentId, $entity, $id);
return;
}
throw new \RuntimeException('Unsupported studio graph operation: ' . $action);
}
/**
* @param array<string,mixed> $data
*/
private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void
{
if (!$this->tableExists('department_lanes')) {
throw new \RuntimeException('Department lanes are not available.');
}
$this->assertLaneOperationAuthorized($action, $data, $permissions);
if ($action === 'create') {
$this->createLane($departmentId, $data);
return;
}
if ($id <= 0) {
throw new \RuntimeException('Studio lane operation is missing id.');
}
if (!$this->laneBelongsToDepartment($id, $departmentId)) {
throw new \RuntimeException('Lane is not available in the selected department.');
}
if ($action === 'update') {
$this->updateLane($departmentId, $id, $data);
return;
}
if ($action === 'delete') {
$this->softDeleteLane($departmentId, $id);
return;
}
throw new \RuntimeException('Unsupported studio lane operation: ' . $action);
}
/**
* @param array<string,mixed> $data
* @param array<string,bool> $permissions
*/
private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void
{
if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) {
throw new \RuntimeException('Missing permission: add_department_lane.');
}
if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) {
throw new \RuntimeException('Missing permission: edit_department_lane.');
}
if (!in_array($action, ['create', 'update'], true)) {
return;
}
$relayFields = [
'relay_in_id',
'relay_out_id',
'relay_machine_id',
'relay_machine_program_picker_id',
'relay_machine_cleaner_id',
];
foreach ($relayFields as $field) {
if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) {
throw new \RuntimeException('Missing permission: modules_shelly_config.');
}
}
}
/**
* @param array<string,mixed> $data
*/
private function createLane(int $departmentId, array $data): void
{
$fields = [
'department' => $departmentId,
'name' => $this->normalizeLaneName((string)($data['name'] ?? $data['label'] ?? 'New lane')),
];
foreach ($this->laneOptionalFields() as $field) {
if (array_key_exists($field, $data)) {
$fields[$field] = $this->normalizeLaneField($field, $data[$field]);
}
}
$availableColumns = $this->tableColumns('department_lanes');
$fields = array_filter(
$fields,
static fn(mixed $value, string $field): bool => in_array($field, $availableColumns, true),
ARRAY_FILTER_USE_BOTH
);
$columns = array_keys($fields);
$placeholders = array_map(static fn(string $field): string => ':' . $field, $columns);
$params = [];
foreach ($fields as $field => $value) {
$params[':' . $field] = $value;
}
db::getPDO()->prepare(
'INSERT INTO department_lanes (`' . implode('`, `', $columns) . '`) VALUES (' . implode(', ', $placeholders) . ')'
)->execute($params);
}
/**
* @param array<string,mixed> $data
*/
private function updateLane(int $departmentId, int $id, array $data): void
{
unset($departmentId);
$availableColumns = $this->tableColumns('department_lanes');
$updates = [];
$params = [':id' => $id];
$wasSelfServeEnabled = null;
if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) {
$statement = db::getPDO()->prepare(
'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL'
);
$statement->execute([':id' => $id]);
$wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1;
}
$fields = ['name', ...$this->laneOptionalFields()];
foreach ($fields as $field) {
if (!array_key_exists($field, $data) || !in_array($field, $availableColumns, true)) {
continue;
}
$updates[] = '`' . $field . '` = :' . $field;
$params[':' . $field] = $field === 'name'
? $this->normalizeLaneName((string)$data[$field])
: $this->normalizeLaneField($field, $data[$field]);
}
if ($updates === []) {
return;
}
db::getPDO()->prepare(
'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL'
)->execute($params);
if (
$wasSelfServeEnabled === true
&& array_key_exists(':selfserve_enabled', $params)
&& (int)$params[':selfserve_enabled'] === 0
) {
\objects\department_lanes_o::disableSelfServeRelaysBestEffort($id);
}
}
private function softDeleteLane(int $departmentId, int $id): void
{
unset($departmentId);
db::getPDO()->prepare(
'UPDATE department_lanes SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL'
)->execute([':id' => $id]);
}
/**
* @return array<int,string>
*/
private function laneOptionalFields(): array
{
return [
'relay_in_id',
'relay_out_id',
'relay_machine_id',
'relay_machine_program_picker_id',
'relay_machine_cleaner_id',
'dynamic_image_id',
'machine_type_id',
'selfserve_enabled',
];
}
private function normalizeLaneField(string $field, mixed $value): mixed
{
if ($field === 'selfserve_enabled') {
return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0;
}
if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) {
return $this->nullableInt($value);
}
$normalized = trim((string)($value ?? ''));
if ($normalized === '' || $normalized === '0' || strtolower($normalized) === 'null') {
return null;
}
return $normalized;
}
private function normalizeLaneName(string $name): string
{
$normalized = trim($name);
if ($normalized === '') {
throw new \RuntimeException('Lane name is required.');
}
return $normalized;
}
/**
* @param array<string,mixed> $data
*/
private function createEntity(int $departmentId, string $entity, array $data): void
{
$pdo = db::getPDO();
if ($entity === 'question') {
$pdo->prepare(
"INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority)
VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)"
)->execute([
':department' => $departmentId,
':lane' => (int)($data['lane'] ?? 0),
':product' => (int)($data['product'] ?? 0),
':condition_id' => $this->nullableInt($data['condition_id'] ?? null),
':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'),
':description' => (string)($data['description'] ?? ''),
':order_priority' => (int)($data['order_priority'] ?? 0),
]);
return;
}
if ($entity === 'condition') {
$pdo->prepare(
"INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description)
VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)"
)->execute([
':department' => $departmentId,
':lane' => (int)($data['lane'] ?? 0),
':product' => (int)($data['product'] ?? 0),
':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null),
':condition_id' => $this->nullableInt($data['condition_id'] ?? null),
':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'),
':description' => (string)($data['description'] ?? ''),
]);
return;
}
if ($entity === 'rule') {
$conditionId = (int)($data['condition_id'] ?? 0);
if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) {
throw new \RuntimeException('Rule condition_id is not available in the selected department.');
}
$pdo->prepare(
"INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description)
VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)"
)->execute([
':condition_id' => $conditionId,
':type' => (string)($data['type'] ?? 'IS_TRUE'),
':object_type' => (string)($data['object_type'] ?? 'question'),
':object_id' => (int)($data['object_id'] ?? 0),
':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'),
':description' => (string)($data['description'] ?? ''),
]);
return;
}
if ($entity === 'task') {
$gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
$gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null);
$pdo->prepare(
"INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type)
VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)"
)->execute([
':department' => $departmentId,
':lane' => (int)($data['lane'] ?? 0),
':product' => (int)($data['product'] ?? 0),
':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null),
':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null,
':gate_type' => $gateType,
':gate_ref_id' => $gateRefId,
':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'),
':description' => (string)($data['description'] ?? ''),
':order_priority' => (int)($data['order_priority'] ?? 0),
':services' => $this->jsonArray($data['services'] ?? []),
':buttons' => $this->jsonArray($data['buttons'] ?? []),
':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null),
]);
return;
}
throw new \RuntimeException('Unsupported studio entity: ' . $entity);
}
/**
* @param array<string,mixed> $data
*/
private function updateEntity(int $departmentId, string $entity, int $id, array $data): void
{
$map = [
'question' => [
'table' => 'department_selfserve_questions',
'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
'department' => 'department',
],
'condition' => [
'table' => 'department_selfserve_conditions',
'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'],
'department' => 'department',
],
'rule' => [
'table' => 'department_selfserve_condition_rules',
'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'],
'department' => null,
],
'task' => [
'table' => 'department_selfserve_tasks',
'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
'department' => 'department',
],
];
if (!isset($map[$entity])) {
throw new \RuntimeException('Unsupported studio entity: ' . $entity);
}
if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) {
throw new \RuntimeException('Rule condition_id is not available in the selected department.');
}
$updates = [];
$params = [
':id' => $id,
];
foreach ($map[$entity]['fields'] as $field) {
if (!array_key_exists($field, $data)) {
continue;
}
$updates[] = "`$field` = :$field";
$value = $data[$field];
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) {
$value = $this->nullableInt($value);
} elseif (in_array($field, ['services', 'buttons'], true)) {
$value = $this->jsonArray($value);
} elseif ($field === 'gate_type') {
$value = $this->normalizeGateType((string)$value);
}
$params[':' . $field] = $value;
}
if (isset($data['label'])) {
if ($entity === 'question' && !isset($data['question'])) {
$updates[] = '`question` = :label';
$params[':label'] = (string)$data['label'];
} elseif ($entity === 'condition' && !isset($data['name'])) {
$updates[] = '`name` = :label';
$params[':label'] = (string)$data['label'];
} elseif ($entity === 'task' && !isset($data['task'])) {
$updates[] = '`task` = :label';
$params[':label'] = (string)$data['label'];
}
}
if ($updates === []) {
return;
}
$where = 'id = :id';
if ($entity === 'rule') {
$where .= " AND condition_id IN (
SELECT id
FROM department_selfserve_conditions
WHERE department IN (0, :department)
AND deleted_at IS NULL
)";
$params[':department'] = $departmentId;
} elseif ($map[$entity]['department'] !== null) {
$where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)';
$params[':department'] = $departmentId;
}
db::getPDO()->prepare(
'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where
)->execute($params);
}
private function softDeleteEntity(int $departmentId, string $entity, int $id): void
{
$map = [
'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'],
'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'],
'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null],
'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'],
];
if (!isset($map[$entity])) {
throw new \RuntimeException('Unsupported studio entity: ' . $entity);
}
$params = [
':id' => $id,
];
$where = 'id = :id';
if ($entity === 'rule') {
$where .= " AND condition_id IN (
SELECT id
FROM department_selfserve_conditions
WHERE department IN (0, :department)
AND deleted_at IS NULL
)";
$params[':department'] = $departmentId;
} elseif ($map[$entity]['department'] !== null) {
$where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)';
$params[':department'] = $departmentId;
}
db::getPDO()->prepare(
'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where
)->execute($params);
}
/**
* @param array<int,array<string,mixed>> $items
*/
private function applyReorder(string $entity, array $items): void
{
$table = match ($entity) {
'question' => 'department_selfserve_questions',
'task' => 'department_selfserve_tasks',
default => null,
};
if ($table === null) {
throw new \RuntimeException('Only questions and tasks can be reordered.');
}
$statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id');
foreach ($items as $index => $item) {
if (!is_array($item)) {
continue;
}
$statement->execute([
':id' => (int)($item['id'] ?? 0),
':order_priority' => (int)($item['order_priority'] ?? $index),
]);
}
}
private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void
{
[$sourceType, $sourceId] = $this->parseNodeId($source);
[$targetType, $targetId] = $this->parseNodeId($target);
if ($sourceType === '' || $targetType === '' || $sourceId === '') {
throw new \RuntimeException('Invalid connection endpoints.');
}
if ($sourceType === 'task' && $targetType === 'binding') {
$service = $this->serviceForBindingNode($departmentId, $target);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateTaskServiceConnection($departmentId, (int)$sourceId, $service, $disconnect);
return;
}
if ($sourceType === 'binding' && $targetType === 'task') {
$service = $this->serviceForBindingNode($departmentId, $source);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect);
return;
}
if ($sourceType === 'condition' && $targetType === 'question') {
db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([
':condition_id' => $disconnect ? null : (int)$sourceId,
':id' => (int)$targetId,
]);
return;
}
if ($sourceType === 'condition' && $targetType === 'condition') {
db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([
':condition_id' => $disconnect ? null : (int)$sourceId,
':id' => (int)$targetId,
]);
return;
}
if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') {
db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([
':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType),
':gate_ref_id' => $disconnect ? null : (int)$sourceId,
':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null,
':id' => (int)$targetId,
]);
return;
}
if ($sourceType === 'condition' && $targetType === 'rule') {
db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([
':condition_id' => $disconnect ? 0 : (int)$sourceId,
':id' => (int)$targetId,
]);
return;
}
if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') {
db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([
':object_type' => $disconnect ? '' : $sourceType,
':object_id' => $disconnect ? 0 : (int)$sourceId,
':id' => (int)$targetId,
]);
return;
}
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $lookups
* @return array<string,mixed>
*/
private function scopeForRow(array $row, array $lookups): array
{
return [
'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups),
'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups),
'product' => $this->labelFor('products', $row['product'] ?? null, $lookups),
'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups),
];
}
/**
* @param array<string,mixed> $row
*/
private function scopeLabel(array $row, array $lookups): string
{
$parts = [];
foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) {
$value = $this->nullableInt($row[$field] ?? null);
if ($value !== null) {
$parts[] = $this->labelFor($lookupType, $value, $lookups);
}
}
return $parts === [] ? 'Shared scope' : implode(' / ', $parts);
}
/**
* @param array<string,mixed> $row
*/
private function ruleSubtitle(array $row, array $lookups): string
{
$objectType = strtolower((string)($row['object_type'] ?? 'object'));
$objectId = (int)($row['object_id'] ?? 0);
$lookupType = $objectType . 's';
$label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object';
return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label;
}
/**
* @param array<string,mixed> $expression
*/
private function expressionSummary(array $expression): 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'));
$subjectLabel = match ($subjectType) {
'question' => 'Question ' . $subjectId,
'condition' => 'Condition ' . $subjectId,
default => 'Unknown subject',
};
return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator));
}
if (in_array($type, ['branch', 'if', 'if_else'], true)) {
$branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : [];
if ($branches === []) {
return 'No if/else clauses';
}
$parts = [];
foreach (array_slice($branches, 0, 3) as $index => $branch) {
if (!is_array($branch)) {
continue;
}
$kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if')));
$isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true);
$label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if');
$when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : '';
$then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result';
$parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then);
}
if (count($branches) > 3) {
$parts[] = '+' . (count($branches) - 3) . ' more';
}
return implode('; ', $parts);
}
if ($type === 'case') {
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
$subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
$subjectLabel = match ($subjectType) {
'question' => 'Question ' . $subjectId,
'condition' => 'Condition ' . $subjectId,
default => 'Unknown subject',
};
$cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : [];
if ($cases === []) {
return 'Case ' . $subjectLabel . ': no clauses';
}
$parts = [];
foreach (array_slice($cases, 0, 3) as $case) {
if (!is_array($case)) {
continue;
}
$value = $this->caseValueLabel($case['value'] ?? null);
$then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result';
$parts[] = $value . ' then ' . $then;
}
if (count($cases) > 3) {
$parts[] = '+' . (count($cases) - 3) . ' more';
}
return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts);
}
$operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
if (!in_array($operator, ['ALL', 'ANY'], true)) {
$operator = '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->expressionSummary((array)$child);
}
}
if (count($children) > 3) {
$parts[] = '+' . (count($children) - 3) . ' more';
}
$prefix = $operator === 'ANY' ? 'Any of' : 'All of';
return $prefix . ': ' . implode('; ', $parts);
}
/**
* @param array<int,array<string,mixed>> $edges
* @param array<string,mixed> $expression
*/
private function appendExpressionEdges(array &$edges, string $targetNodeId, int $ownerConditionId, array $expression, string $path = '0'): void
{
$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);
if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) {
return;
}
$edge = $this->edge(
'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path), 0, 8),
$subjectType . ':' . $subjectId,
$targetNodeId,
'condition_expression',
strtoupper((string)($expression['operator'] ?? 'IS_TRUE'))
);
$edge['data']['subject_type'] = $subjectType;
$edge['data']['subject_id'] = $subjectId;
$edge['data']['condition_id'] = $ownerConditionId;
$edges[] = $edge;
return;
}
if (in_array($type, ['branch', 'if', 'if_else'], true)) {
foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
if (!is_array($branch)) {
continue;
}
if (is_array($branch['when'] ?? null)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when');
}
if (is_array($branch['then'] ?? null)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then');
}
}
if (is_array($expression['default'] ?? null)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default');
}
return;
}
if ($type === 'case') {
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
$subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) {
$edge = $this->edge(
'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8),
$subjectType . ':' . $subjectId,
$targetNodeId,
'condition_expression',
'CASE'
);
$edge['data']['subject_type'] = $subjectType;
$edge['data']['subject_id'] = $subjectId;
$edge['data']['condition_id'] = $ownerConditionId;
$edges[] = $edge;
}
foreach ((array)($expression['cases'] ?? []) as $index => $case) {
if (is_array($case) && is_array($case['then'] ?? null)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then');
}
}
if (is_array($expression['default'] ?? null)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default');
}
return;
}
$children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
foreach ($children as $index => $child) {
if (is_array($child)) {
$this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$child, $path . '.' . $index);
}
}
}
private function normalizeExpressionNode(mixed $expression): array
{
if (!is_array($expression)) {
return $this->emptyV2Expression();
}
$type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
if ($type === 'predicate') {
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
if (!in_array($subjectType, ['question', 'condition'], true)) {
$subjectType = 'question';
}
$operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE'));
if (!in_array($operator, ['IS_TRUE', 'IS_FALSE', 'IS_SET', 'IS_TRUE_OR_NOT_SET', 'IS_FALSE_OR_NOT_SET'], true)) {
$operator = 'IS_TRUE';
}
return [
'type' => 'predicate',
'subject_type' => $subjectType,
'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0),
'operator' => $operator,
];
}
if (in_array($type, ['branch', 'if', 'if_else'], true)) {
$branches = [];
foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
if (!is_array($branch)) {
continue;
}
$kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if')));
$isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true);
$normalized = [
'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'),
'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()),
];
if ($isElse) {
$normalized['else'] = true;
} else {
$normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression());
}
$branches[] = $normalized;
}
$normalizedExpression = [
'type' => 'branch',
'operator' => 'IF_ELSE',
'branches' => $branches,
];
if (is_array($expression['default'] ?? null)) {
$normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']);
}
return $normalizedExpression;
}
if ($type === 'case') {
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
if (!in_array($subjectType, ['question', 'condition'], true)) {
$subjectType = 'question';
}
$cases = [];
foreach ((array)($expression['cases'] ?? []) as $case) {
if (!is_array($case)) {
continue;
}
$cases[] = [
'value' => $case['value'] ?? null,
'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()),
];
}
$normalizedExpression = [
'type' => 'case',
'operator' => 'CASE',
'subject_type' => $subjectType,
'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0),
'cases' => $cases,
];
if (is_array($expression['default'] ?? null)) {
$normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']);
}
return $normalizedExpression;
}
$operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
if (!in_array($operator, ['ALL', 'ANY'], true)) {
$operator = 'ALL';
}
$children = [];
foreach ((array)($expression['children'] ?? []) as $child) {
if (is_array($child)) {
$children[] = $this->normalizeExpressionNode((array)$child);
}
}
return [
'type' => 'group',
'operator' => $operator,
'children' => $children,
];
}
/**
* @param array<string,mixed> $expression
*/
private function expressionHasPredicate(array $expression, string $subjectType, int $subjectId): bool
{
$type = strtolower((string)($expression['type'] ?? 'group'));
if ($type === 'predicate') {
return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
&& (int)($expression['subject_id'] ?? 0) === $subjectId;
}
if (in_array($type, ['branch', 'if', 'if_else'], true)) {
foreach ((array)($expression['branches'] ?? []) as $branch) {
if (!is_array($branch)) {
continue;
}
if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) {
return true;
}
if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) {
return true;
}
}
return is_array($expression['default'] ?? null)
&& $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId);
}
if ($type === 'case') {
if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
&& (int)($expression['subject_id'] ?? 0) === $subjectId) {
return true;
}
foreach ((array)($expression['cases'] ?? []) as $case) {
if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) {
return true;
}
}
return is_array($expression['default'] ?? null)
&& $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId);
}
foreach ((array)($expression['children'] ?? []) as $child) {
if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $expression
* @return array<string,mixed>
*/
private function removeExpressionPredicate(array $expression, string $subjectType, int $subjectId): array
{
$expression = $this->normalizeExpressionNode($expression);
if (($expression['type'] ?? 'group') === 'predicate') {
return $this->expressionHasPredicate($expression, $subjectType, $subjectId)
? $this->emptyV2Expression()
: $expression;
}
if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) {
foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
if (!is_array($branch)) {
continue;
}
if (is_array($branch['when'] ?? null)) {
$expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId);
}
if (is_array($branch['then'] ?? null)) {
$expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId);
}
}
if (is_array($expression['default'] ?? null)) {
$expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId);
}
return $expression;
}
if (($expression['type'] ?? 'group') === 'case') {
if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
&& (int)($expression['subject_id'] ?? 0) === $subjectId) {
return $this->emptyV2Expression();
}
foreach ((array)($expression['cases'] ?? []) as $index => $case) {
if (is_array($case) && is_array($case['then'] ?? null)) {
$expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId);
}
}
if (is_array($expression['default'] ?? null)) {
$expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId);
}
return $expression;
}
$children = [];
foreach ((array)($expression['children'] ?? []) as $child) {
if (!is_array($child)) {
continue;
}
$normalizedChild = $this->normalizeExpressionNode((array)$child);
if (($normalizedChild['type'] ?? '') === 'predicate' && $this->expressionHasPredicate($normalizedChild, $subjectType, $subjectId)) {
continue;
}
if (($normalizedChild['type'] ?? '') === 'group') {
$normalizedChild = $this->removeExpressionPredicate($normalizedChild, $subjectType, $subjectId);
}
$children[] = $normalizedChild;
}
$expression['children'] = $children;
return $expression;
}
/**
* @return array<string,mixed>
*/
private function emptyV2Expression(): array
{
return [
'type' => 'group',
'operator' => 'ALL',
'children' => [],
];
}
private function caseValueLabel(mixed $value): string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if ($value === null) {
return 'unanswered';
}
return (string)$value;
}
/**
* @param array<int,array<string,mixed>> $edges
* @param array<string,mixed> $row
*/
private function appendScopeEdges(array &$edges, string $targetId, array $row): void
{
$scopes = [
'lane' => 'lane',
'product' => 'vehicle_type',
'machine_type_id' => 'machine_type',
];
foreach ($scopes as $field => $type) {
$scopeId = $this->nullableInt($row[$field] ?? null);
if ($scopeId !== null) {
$edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope');
}
}
}
/**
* @param array<string,mixed> $layout
* @param array<int,array<string,mixed>> $nodes
* @return array<int,array<string,mixed>>
*/
private function applyLayoutToNodes(array $nodes, array $layout): array
{
$positions = $this->extractNodePositions((array)($layout['nodes'] ?? []));
foreach ($nodes as &$node) {
$id = (string)($node['id'] ?? '');
if (isset($positions[$id])) {
$node['position'] = $positions[$id];
}
}
unset($node);
return array_values($nodes);
}
/**
* @param array<int|string,mixed> $nodes
* @return array<string,array{x:float|int,y:float|int}>
*/
private function extractNodePositions(array $nodes): array
{
$positions = [];
foreach ($nodes as $key => $node) {
if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) {
$positions[(string)$node['id']] = [
'x' => (float)($node['position']['x'] ?? 0),
'y' => (float)($node['position']['y'] ?? 0),
];
continue;
}
if (is_string($key) && is_array($node)) {
$positions[$key] = [
'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0),
'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0),
];
}
}
return $positions;
}
/**
* @param array<string,mixed> $validation
* @return array<int,array<string,mixed>>
*/
private function buildValidationItems(array $validation): array
{
$items = [];
foreach ((array)($validation['errors'] ?? []) as $message) {
$items[] = [
'severity' => 'error',
'message' => (string)$message,
];
}
foreach ((array)($validation['warnings'] ?? []) as $message) {
$items[] = [
'severity' => 'warning',
'message' => (string)$message,
];
}
return $items;
}
/**
* @param array<string,mixed> $lookups
* @return array<string,mixed>
*/
private function buildSimulatorDefaults(int $departmentId, array $lookups, array $gatewayWorkspace = []): array
{
$lane = $this->lookupRows($lookups, 'lanes')[0] ?? null;
$vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null;
$hasVirtualHardware = (bool)($gatewayWorkspace['virtual']['has_virtual_hardware'] ?? false);
return [
'department' => $departmentId,
'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null,
'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null,
'reg' => 'TEST123',
'customer_number' => null,
'hardware_mode' => $hasVirtualHardware ? 'studio' : 'real',
];
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array
{
$data['kind'] = $kind;
$data['label'] = $label;
return [
'id' => $id,
'type' => $type,
'position' => [
'x' => $x,
'y' => $y,
],
'data' => $data,
];
}
/**
* @return array<string,mixed>
*/
private function edge(string $id, string $source, string $target, string $kind, string $label): array
{
return [
'id' => $id,
'source' => $source,
'target' => $target,
'type' => 'smoothstep',
'label' => $label,
'data' => [
'kind' => $kind,
],
];
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $lookups
*/
private function entityLabel(string $entity, int $id, array $row, array $lookups): string
{
$field = match ($entity) {
'question' => 'question',
'condition', 'rule' => 'name',
'task' => 'task',
'action' => 'name',
default => 'label',
};
$label = trim((string)($row[$field] ?? ''));
if ($label !== '') {
return $label;
}
return $this->labelFor($entity . 's', $id, $lookups);
}
/**
* @param array<string,mixed> $action
* @param array<string,mixed> $lookups
*/
private function actionSubtitle(array $action, array $lookups): string
{
$parts = [
selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')),
ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)),
selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null),
];
$scope = $this->scopeLabel($action, $lookups);
if ($scope !== 'Shared scope') {
$parts[] = $scope;
}
return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== ''));
}
/**
* @param array<string,mixed> $lookups
*/
private function labelFor(string $lookupType, mixed $id, array $lookups): string
{
$id = $this->nullableInt($id);
if ($id === null) {
return 'All';
}
$labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : [];
return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id);
}
/**
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
private function lookupRows(array $lookups, string $type): array
{
return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : [];
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function sortedRows(array $rows, array $fields): array
{
usort($rows, static function (array $left, array $right) use ($fields): int {
foreach ($fields as $field) {
$leftValue = $left[$field] ?? null;
$rightValue = $right[$field] ?? null;
if (is_numeric($leftValue) && is_numeric($rightValue)) {
$comparison = (int)$leftValue <=> (int)$rightValue;
} else {
$comparison = strcmp((string)$leftValue, (string)$rightValue);
}
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
});
return array_values($rows);
}
/**
* @param array<string,mixed> $task
* @return array<string,mixed>
*/
private function normalizeTaskPayload(array $task): array
{
$task['services'] = $this->normalizeServiceList($task['services'] ?? []);
$task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []);
$task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : [];
return $task;
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
private function withTaskAttachments(array $config): array
{
if (!is_array($config['tasks'] ?? null)) {
return $config;
}
$config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks']));
return $config;
}
/**
* @param array<string,mixed> $workspace
* @return array<string,array<int,string>>
*/
private function relayServicesFromWorkspace(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'] ?? ''));
$service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '');
if ($relayId === '' || $service === '') {
continue;
}
$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>
*/
private function bindingServices(array $binding, string $relayId, array $relayServices): array
{
$services = [];
foreach (['role', 'service', 'slot'] as $field) {
$service = $this->normalizeServiceName($binding[$field] ?? '');
if ($service !== '') {
$services[$service] = true;
}
}
foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) {
$services[$service] = true;
}
foreach ((array)($relayServices[$relayId] ?? []) as $service) {
$normalized = $this->normalizeServiceName($service);
if ($normalized !== '') {
$services[$normalized] = true;
}
}
return array_keys($services);
}
/**
* @param array<string,mixed> $gateway
*/
private function gatewayIdentifier(array $gateway): string
{
return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''));
}
/**
* @param array<string,mixed> $gateway
*/
private function gatewayNodeId(array $gateway): string
{
$nodeId = trim((string)($gateway['node_id'] ?? ''));
return $nodeId !== '' ? $nodeId : 'gateway:' . $this->gatewayIdentifier($gateway);
}
/**
* @param array<string,mixed> $binding
*/
private function bindingNodeId(string $gatewayId, string $relayId, int $bindingIndex, array $binding): string
{
$nodeId = trim((string)($binding['node_id'] ?? ''));
return $nodeId !== '' ? $nodeId : 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex;
}
/**
* @param array<string,mixed> $workspace
* @return array<int,array<string,mixed>>
*/
private function gatewayBindingReferences(array $workspace): array
{
$relayServices = $this->relayServicesFromWorkspace($workspace);
$references = [];
foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = $this->gatewayIdentifier($gateway);
if ($gatewayId === '') {
continue;
}
foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$services = $this->bindingServices($binding, $relayId, $relayServices);
if ($services === []) {
continue;
}
$references[] = [
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'binding_index' => (int)$bindingIndex,
'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding),
'services' => $services,
'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
];
}
}
return $references;
}
private function serviceForBindingNode(int $departmentId, string $nodeId): string
{
$workspace = $this->buildGatewayWorkspace($departmentId);
foreach ($this->gatewayBindingReferences($workspace) as $binding) {
if ((string)$binding['node_id'] === $nodeId) {
return (string)($binding['services'][0] ?? '');
}
}
return '';
}
private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void
{
if ($taskId <= 0 || $service === '') {
return;
}
$pdo = db::getPDO();
$statement = $pdo->prepare(
'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1'
);
$statement->execute([
':id' => $taskId,
':department' => $departmentId,
]);
$row = $statement->fetch(\PDO::FETCH_ASSOC);
if (!is_array($row)) {
throw new \RuntimeException('Task is not available in the selected department.');
}
$services = $this->normalizeServiceList($row['services'] ?? []);
$serviceSet = array_fill_keys($services, true);
if ($disconnect) {
unset($serviceSet[$service]);
} else {
$serviceSet[$service] = true;
}
$pdo->prepare(
'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)'
)->execute([
':services' => $this->jsonArray(array_keys($serviceSet)),
':id' => $taskId,
':department' => $departmentId,
]);
}
/**
* @return array<int,mixed>
*/
private function normalizeArrayPayload(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== '');
}
return is_array($value) ? array_values($value) : [];
}
/**
* @return array<int,string>
*/
private function normalizeServiceList(mixed $value): array
{
$services = [];
foreach ($this->normalizeArrayPayload($value) as $entry) {
$service = $this->normalizeServiceName($entry);
if ($service !== '') {
$services[$service] = true;
}
}
return array_keys($services);
}
private function normalizeServiceName(mixed $value): string
{
return strtoupper(trim((string)$value));
}
/**
* @param array<int,bool> $answers
* @return array<int,array{question_id:int,value:bool}>
*/
private function pathAnswerOverrides(array $answers): array
{
ksort($answers, SORT_NUMERIC);
$overrides = [];
foreach ($answers as $questionId => $answer) {
if ($answer !== true && $answer !== false) {
continue;
}
$overrides[] = [
'question_id' => (int)$questionId,
'value' => $answer,
];
}
return $overrides;
}
/**
* @param array<string,mixed> $simulation
* @param array<int,bool> $answers
* @return array<string,mixed>|null
*/
private function nextPathQuestion(array $simulation, array $answers): ?array
{
$debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : [];
foreach ($debugQuestions as $question) {
if (!is_array($question)) {
continue;
}
$questionId = (int)($question['id'] ?? 0);
if ($questionId <= 0 || array_key_exists($questionId, $answers)) {
continue;
}
$visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true;
$answer = $question['answer'] ?? null;
if ($visible && $answer !== true && $answer !== false) {
return $question;
}
}
$visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : [];
foreach ($visibleQuestions as $question) {
if (!is_array($question)) {
continue;
}
$questionId = (int)($question['id'] ?? 0);
if ($questionId <= 0 || array_key_exists($questionId, $answers)) {
continue;
}
$answer = $question['answer'] ?? null;
if ($answer !== true && $answer !== false) {
return [
'id' => $questionId,
'label' => (string)($question['question'] ?? ('Question ' . $questionId)),
'node_id' => 'question:' . $questionId,
'answer' => $answer,
'visible' => true,
];
}
}
return null;
}
private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
{
if ($value === null || $value === '') {
return max($min, min($max, $default));
}
$parsed = (int)$value;
if ($parsed < $min) {
return max($min, min($max, $default));
}
return min($max, $parsed);
}
/**
* @param array<string,mixed> $scope
* @param array<string,array<string,mixed>> $groups
* @param array<int,array<string,mixed>> $paths
* @param array<int,bool> $questionIds
* @return array<string,mixed>
*/
private function pathOutcomesProjectionPayload(
array $scope,
array $groups,
array $paths,
bool $truncated,
?int $maxStates,
int $stateCount,
int $terminalPathCount,
array $questionIds,
int $pendingStateCount,
array $confirmationRows = []
): array {
$warnings = [];
if ($truncated && $maxStates !== null) {
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).';
}
$knownStateCount = max(1, $stateCount + $pendingStateCount);
$complete = !$truncated && $pendingStateCount === 0;
$percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100)));
return $this->pathOutcomesPayload(
$scope,
$this->finalizePathOutcomeGroups($groups),
$paths,
$warnings,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
[
'complete' => $complete,
'percent' => $percent,
'state_count' => $stateCount,
'pending_state_count' => $pendingStateCount,
'terminal_path_count' => $terminalPathCount,
],
$confirmationRows
);
}
/**
* @param array<string,mixed> $scope
* @param array<int,array<string,mixed>> $outcomes
* @param array<int,array<string,mixed>> $paths
* @param array<int,string> $warnings
* @param array<int,bool>|array<int,int> $questionIds
* @param array<string,mixed> $progress
* @return array<string,mixed>
*/
private function pathOutcomesPayload(
array $scope,
array $outcomes,
array $paths,
array $warnings,
bool $truncated,
?int $maxStates,
int $stateCount,
int $terminalPathCount,
array $questionIds,
array $progress = [],
?array $confirmationRows = null
): array {
$confirmationRows = $confirmationRows ?? [];
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
foreach ($outcomes as $index => &$outcome) {
$outcome['id'] = 'outcome-' . ($index + 1);
}
unset($outcome);
foreach ($paths as $index => &$path) {
$path['id'] = 'path-' . ($index + 1);
}
unset($path);
$confirmations = $this->applyPathConfirmations($paths, $confirmationRows);
$questionIdValues = [];
foreach ($questionIds as $key => $value) {
$questionIdValues[] = $value === true ? (int)$key : (int)$value;
}
$questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0)));
sort($questionIdValues);
$progress = array_merge([
'complete' => !$truncated,
'percent' => $truncated ? 99 : 100,
'state_count' => $stateCount,
'pending_state_count' => 0,
'terminal_path_count' => $terminalPathCount,
], $progress);
return [
'scope' => $scope,
'summary' => [
'state_count' => $stateCount,
'terminal_path_count' => $terminalPathCount,
'outcome_count' => count($outcomes),
'question_count' => count($questionIdValues),
'question_ids' => $questionIdValues,
'max_states' => $maxStates,
'path_sample_count' => count($paths),
'confirmations' => $confirmations['summary'],
],
'outcomes' => array_values($outcomes),
'paths' => array_values($paths),
'confirmations' => $confirmations,
'warnings' => array_values(array_unique($warnings)),
'truncated' => $truncated,
'progress' => $progress,
];
}
/**
* @param array<int,array<string,mixed>> $paths
* @param array<int,array<string,mixed>> $confirmationRows
* @return array{summary:array<string,int>,removed:array<int,array<string,mixed>>}
*/
private function applyPathConfirmations(array &$paths, array $confirmationRows): array
{
$rowsBySignature = [];
foreach ($confirmationRows as $row) {
if (!is_array($row)) {
continue;
}
$signature = trim((string)($row['path_signature'] ?? ''));
if ($signature !== '') {
$rowsBySignature[$signature] = $row;
}
}
$matched = [];
$summary = [
'confirmed' => 0,
'unconfirmed' => 0,
'stale' => 0,
'removed' => 0,
'total' => count($paths),
];
foreach ($paths as &$path) {
if (!is_array($path)) {
continue;
}
$pathSignature = $this->pathSignature(
is_array($path['scope'] ?? null) ? (array)$path['scope'] : [],
is_array($path['answers'] ?? null) ? (array)$path['answers'] : []
);
$resultSignature = $this->pathResultSignature($path);
$path['path_signature'] = $pathSignature;
$path['result_signature'] = $resultSignature;
$path['confirmation_status'] = 'unconfirmed';
$path['confirmed_at'] = null;
$path['confirmed_by'] = null;
$path['stale_reason'] = null;
$row = $rowsBySignature[$pathSignature] ?? null;
if (is_array($row)) {
$matched[$pathSignature] = true;
$path['confirmed_at'] = $row['confirmed_at'] ?? null;
$path['confirmed_by'] = $row['confirmed_by'] ?? null;
if ((string)($row['result_signature'] ?? '') === $resultSignature) {
$path['confirmation_status'] = 'confirmed';
} else {
$path['confirmation_status'] = 'stale';
$path['stale_reason'] = 'Result changed since confirmation.';
}
}
$summary[(string)$path['confirmation_status']]++;
}
unset($path);
$removed = [];
foreach ($rowsBySignature as $signature => $row) {
if (isset($matched[$signature])) {
continue;
}
$removed[] = [
'id' => $row['id'] ?? null,
'path_signature' => $signature,
'result_signature' => (string)($row['result_signature'] ?? ''),
'confirmation_status' => 'stale',
'stale_reason' => 'Path no longer appears in the projected cases.',
'answers' => is_array($row['answers'] ?? null) ? $row['answers'] : [],
'result' => is_array($row['result'] ?? null) ? $row['result'] : [],
'scope' => is_array($row['scope'] ?? null) ? $row['scope'] : [],
'confirmed_at' => $row['confirmed_at'] ?? null,
'confirmed_by' => $row['confirmed_by'] ?? null,
];
}
$summary['removed'] = count($removed);
return [
'summary' => $summary,
'removed' => $removed,
];
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,mixed> $simulation
* @param array<int,array<string,mixed>> $chain
* @param array<string,mixed> $scope
*/
private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void
{
$tasks = $this->pathActiveTasks($simulation);
$services = $this->pathServices($simulation, $tasks);
$signals = $this->pathSignals($simulation);
$allowed = (bool)($simulation['allowed'] ?? false);
$key = $this->stableJson([
'scope' => $this->pathScopeKey($scope),
'allowed' => $allowed,
'services' => $services,
'tasks' => array_map(static fn(array $task): array => [
'id' => (int)($task['id'] ?? 0),
'services' => (array)($task['services'] ?? []),
], $tasks),
'signals' => $signals,
]);
if (!isset($groups[$key])) {
$groups[$key] = [
'path_count' => 0,
'allowed' => $allowed,
'services' => $services,
'tasks' => $tasks,
'signals' => $signals,
'sample_chains' => [],
'scopes' => [],
'node_ids' => [],
'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals),
];
}
$groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1;
$scopeKey = $this->stableJson($this->pathScopeKey($scope));
$groups[$key]['scopes'][$scopeKey] = $scope;
if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) {
$groups[$key]['sample_chains'][] = [
'scope' => $scope,
'answers' => array_values($chain),
];
}
foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) {
$groups[$key]['node_ids'][$nodeId] = true;
}
}
/**
* @param array<string,mixed> $simulation
* @param array<int,array<string,mixed>> $chain
* @param array<string,mixed> $scope
* @return array<string,mixed>
*/
private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array
{
$tasks = $this->pathActiveTasks($simulation);
$services = $this->pathServices($simulation, $tasks);
$signals = $this->pathSignals($simulation);
$allowed = (bool)($simulation['allowed'] ?? false);
return [
'id' => '',
'result' => $allowed ? 'Allowed' : 'Blocked',
'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals),
'allowed' => $allowed,
'services' => $services,
'tasks' => $tasks,
'signals' => $signals,
'task_count' => count($tasks),
'signal_count' => count($signals),
'answers' => array_values($chain),
'scope' => $scope,
'node_ids' => $this->pathNodeIds($chain, $tasks, $signals),
];
}
/**
* @param array<int,array<string,mixed>> $chain
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $signals
* @return array<int,string>
*/
private function pathNodeIds(array $chain, array $tasks, array $signals): array
{
$nodeIds = [];
foreach ($chain as $answer) {
if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') {
$nodeIds[(string)$answer['node_id']] = true;
}
}
foreach ($tasks as $task) {
if (trim((string)($task['node_id'] ?? '')) !== '') {
$nodeIds[(string)$task['node_id']] = true;
}
}
foreach ($signals as $signal) {
foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) {
if (trim((string)($signal[$field] ?? '')) !== '') {
$nodeIds[(string)$signal[$field]] = true;
}
}
}
$nodeIds = array_keys($nodeIds);
sort($nodeIds);
return $nodeIds;
}
/**
* @param array<string,array<string,mixed>> $groups
* @return array<int,array<string,mixed>>
*/
private function finalizePathOutcomeGroups(array $groups): array
{
$outcomes = [];
foreach ($groups as $group) {
$scopes = array_values((array)($group['scopes'] ?? []));
$nodeIds = array_values(array_keys((array)($group['node_ids'] ?? [])));
sort($nodeIds);
$outcomes[] = [
'id' => '',
'summary' => (string)($group['summary'] ?? ''),
'path_count' => (int)($group['path_count'] ?? 0),
'allowed' => (bool)($group['allowed'] ?? false),
'services' => array_values((array)($group['services'] ?? [])),
'tasks' => array_values((array)($group['tasks'] ?? [])),
'signals' => array_values((array)($group['signals'] ?? [])),
'sample_chains' => array_values((array)($group['sample_chains'] ?? [])),
'scopes' => $scopes,
'node_ids' => $nodeIds,
];
}
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
foreach ($outcomes as $index => &$outcome) {
$outcome['id'] = 'outcome-' . ($index + 1);
}
unset($outcome);
return $outcomes;
}
/**
* @param array<string,mixed> $simulation
* @return array<int,array<string,mixed>>
*/
private function pathActiveTasks(array $simulation): array
{
$tasks = [];
$debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : [];
foreach ($debugTasks as $task) {
if (!is_array($task) || (bool)($task['active'] ?? false) !== true) {
continue;
}
$tasks[] = [
'id' => (int)($task['id'] ?? 0),
'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))),
'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))),
'services' => $this->normalizeServiceList($task['services'] ?? []),
'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
'order_priority' => (int)($task['order_priority'] ?? 0),
];
}
if ($tasks !== []) {
return $tasks;
}
foreach ((array)($simulation['tasks'] ?? []) as $task) {
if (!is_array($task)) {
continue;
}
$taskId = (int)($task['id'] ?? $task['task_id'] ?? 0);
$tasks[] = [
'id' => $taskId,
'node_id' => 'task:' . $taskId,
'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)),
'services' => $this->normalizeServiceList($task['services'] ?? []),
'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
'order_priority' => (int)($task['order_priority'] ?? 0),
];
}
usort($tasks, 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)));
return $tasks;
}
/**
* @param array<string,mixed> $simulation
* @param array<int,array<string,mixed>> $tasks
* @return array<int,string>
*/
private function pathServices(array $simulation, array $tasks): array
{
$services = [];
foreach ((array)($simulation['allowed_services'] ?? []) as $service) {
$normalized = $this->normalizeServiceName($service);
if ($normalized !== '') {
$services[$normalized] = true;
}
}
foreach ($tasks as $task) {
foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
$services[$service] = true;
}
}
$values = array_keys($services);
sort($values);
return $values;
}
/**
* @param array<string,mixed> $simulation
* @return array<int,array<string,mixed>>
*/
private function pathSignals(array $simulation): array
{
$timeline = is_array($simulation['debug']['signal_timeline'] ?? null)
? (array)$simulation['debug']['signal_timeline']
: (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []);
$signals = [];
foreach ($timeline as $index => $signal) {
if (!is_array($signal)) {
continue;
}
$signals[] = [
'sequence' => (int)($signal['sequence'] ?? ($index + 1)),
'runtime_stage' => (string)($signal['runtime_stage'] ?? ''),
'signal_type' => (string)($signal['signal_type'] ?? ''),
'relay_role' => (string)($signal['relay_role'] ?? ''),
'relay_id' => $signal['relay_id'] ?? null,
'target_gateway_label' => $signal['target_gateway_label'] ?? null,
'target_binding' => $signal['target_binding'] ?? null,
'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null,
'target_relay_node_id' => $signal['target_relay_node_id'] ?? null,
'source' => (string)($signal['source'] ?? ''),
'virtual' => (bool)($signal['virtual'] ?? false),
'predicted_status' => (string)($signal['predicted_status'] ?? ''),
'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []),
'skip_block_reason' => $signal['skip_block_reason'] ?? null,
];
}
return $signals;
}
/**
* @param array<string,mixed> $scope
* @return array<string,mixed>
*/
private function pathScopeKey(array $scope): array
{
return [
'department_id' => $scope['department_id'] ?? null,
'lane_id' => $scope['lane_id'] ?? null,
'vehicle_type_id' => $scope['vehicle_type_id'] ?? null,
'machine_type_id' => $scope['machine_type_id'] ?? null,
'config_source' => $scope['config_source'] ?? null,
'config_version_id' => $scope['config_version_id'] ?? null,
'hardware_mode' => $scope['hardware_mode'] ?? null,
];
}
/**
* @param array<string,mixed> $path
*/
private function pathResultSignature(array $path): string
{
return hash('sha256', $this->stableJson([
'allowed' => (bool)($path['allowed'] ?? false),
'services' => $this->normalizeServiceList($path['services'] ?? []),
'tasks' => array_values(array_map(function (array $task): array {
return [
'id' => (int)($task['id'] ?? 0),
'label' => (string)($task['label'] ?? $task['task'] ?? ''),
'services' => $this->normalizeServiceList($task['services'] ?? []),
'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
'order_priority' => (int)($task['order_priority'] ?? 0),
];
}, array_values((array)($path['tasks'] ?? [])))),
'buttons' => array_values(array_reduce(
array_values((array)($path['tasks'] ?? [])),
function (array $carry, mixed $task): array {
if (!is_array($task)) {
return $carry;
}
foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) {
$key = (is_int($button) ? 'int:' : 'string:') . (string)$button;
$carry[$key] = $button;
}
return $carry;
},
[]
)),
'signals' => array_values(array_map(static fn(array $signal): array => [
'runtime_stage' => (string)($signal['runtime_stage'] ?? ''),
'signal_type' => (string)($signal['signal_type'] ?? ''),
'relay_role' => (string)($signal['relay_role'] ?? ''),
'relay_id' => $signal['relay_id'] ?? null,
'target_binding' => $signal['target_binding'] ?? null,
'source' => (string)($signal['source'] ?? ''),
'predicted_status' => (string)($signal['predicted_status'] ?? ''),
'payload' => is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : [],
], array_values((array)($path['signals'] ?? [])))),
]));
}
/**
* @return array<int,array<string,mixed>>
*/
private function loadPathConfirmationRows(int $departmentId, ?int $configVersionId, int $laneId, ?int $vehicleTypeId, string $configSource): array
{
if (!$this->tableExists('department_selfserve_path_confirmations')) {
return [];
}
$where = [
'department_id = :department_id',
'lane_id = :lane_id',
'config_source = :config_source',
'deleted_at IS NULL',
];
$params = [
':department_id' => $departmentId,
':lane_id' => $laneId,
':config_source' => $configSource,
];
if ($configVersionId === null) {
$where[] = 'config_version_id IS NULL';
} else {
$where[] = 'config_version_id = :config_version_id';
$params[':config_version_id'] = $configVersionId;
}
if ($vehicleTypeId !== null) {
$where[] = 'vehicle_type_id = :vehicle_type_id';
$params[':vehicle_type_id'] = $vehicleTypeId;
}
$statement = db::getPDO()->prepare(
'SELECT *
FROM department_selfserve_path_confirmations
WHERE ' . implode(' AND ', $where) . '
ORDER BY confirmed_at DESC, id DESC'
);
$statement->execute($params);
$rows = $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [];
return array_values(array_map(function (array $row): array {
$row['answers'] = $this->decodeJsonArray($row['answers_json'] ?? null);
$row['result'] = $this->decodeJsonArray($row['result_json'] ?? null);
$row['scope'] = $this->decodeJsonArray($row['scope_json'] ?? null);
return $row;
}, $rows));
}
/**
* @return array<string,mixed>|array<int,mixed>
*/
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
$decoded = json_decode((string)($value ?? '[]'), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<int,string> $services
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $signals
*/
private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string
{
$serviceLabel = $services === [] ? 'No services' : implode(', ', $services);
$taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks';
$signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals';
return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText;
}
private function stableJson(mixed $value): string
{
$json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE);
if ($json === false) {
return '';
}
return $json;
}
private function sortStableValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
$isList = array_keys($value) === range(0, count($value) - 1);
if (!$isList) {
ksort($value);
}
foreach ($value as $key => $item) {
$value[$key] = $this->sortStableValue($item);
}
return $value;
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function vehicleTypeRowsFromProducts(array $rows): array
{
$vehicleTypes = [];
foreach ($this->labelRows($rows, 'name') as $row) {
$productId = (int)($row['id'] ?? 0);
if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) {
continue;
}
$row['id'] = $productId;
$row['product'] = $productId;
$row['product_id'] = $productId;
$row['source'] = 'products';
$vehicleTypes[] = $row;
}
return $vehicleTypes;
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function labelRows(array $rows, string $labelField): array
{
return array_map(static function (array $row) use ($labelField): array {
$row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? '');
return $row;
}, $rows);
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function configLabelRows(array $rows, string $labelField): array
{
return array_map(static function (array $row) use ($labelField): array {
$row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? '');
return [
'id' => (int)($row['id'] ?? 0),
'label' => $row['label'],
'raw' => $row,
];
}, $rows);
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<int,array<string,mixed>>
*/
private function gatewayLabelRows(array $gateways): array
{
$rows = [];
foreach ($gateways as $gateway) {
if (is_array($gateway)) {
$gatewayId = $this->gatewayIdentifier($gateway);
if ($gatewayId === '') {
continue;
}
$rows[] = [
'id' => $gatewayId,
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)),
'status' => (string)($gateway['status'] ?? 'UNKNOWN'),
'virtual' => (bool)($gateway['virtual'] ?? false),
'raw' => $gateway,
];
}
}
return $rows;
}
/**
* @param array<int|string,array<string,mixed>> $relays
* @return array<int,array<string,mixed>>
*/
private function relayLabelRows(array $relays): array
{
$rows = [];
foreach ($relays as $relay) {
if (!is_array($relay)) {
continue;
}
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
if ($relayId === '') {
continue;
}
$rows[] = [
'id' => $relayId,
'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)),
'raw' => $relay,
];
}
return $rows;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<int,array<string,mixed>>
*/
private function bindingLabelRows(array $gateways): array
{
$rows = [];
foreach ($gateways as $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = $this->gatewayIdentifier($gateway);
foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($gatewayId === '' || $relayId === '') {
continue;
}
$services = $this->bindingServices($binding, $relayId, []);
$rows[] = [
'id' => $gatewayId . ':' . $relayId . ':' . $index,
'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)),
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'role' => (string)($binding['role'] ?? ''),
'services' => $services,
'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
];
}
}
return $rows;
}
/**
* @return array<int,array<string,mixed>>
*/
private function fetchRows(string $table, array $columns, array $where): array
{
if (!$this->tableExists($table)) {
return [];
}
$availableColumns = $this->tableColumns($table);
$columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true)));
if ($columns === []) {
return [];
}
$conditions = [];
$params = [];
foreach ($where as $field => $value) {
if (!in_array($field, $availableColumns, true)) {
continue;
}
$conditions[] = '`' . $field . '` = :' . $field;
$params[':' . $field] = $value;
}
if (in_array('deleted_at', $availableColumns, true)) {
$conditions[] = '`deleted_at` IS NULL';
}
$sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`';
if ($conditions !== []) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
if (in_array('order_priority', $availableColumns, true)) {
$sql .= ' ORDER BY `order_priority` ASC, `id` ASC';
} elseif (in_array('id', $availableColumns, true)) {
$sql .= ' ORDER BY `id` ASC';
}
$statement = db::getPDO()->prepare($sql);
$statement->execute($params);
return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
private function tableExists(string $table): bool
{
$statement = db::getPDO()->prepare(
'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table'
);
$statement->execute([':table' => $table]);
return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
}
/**
* @return array<int,string>
*/
private function tableColumns(string $table): array
{
if (isset($this->columnCache[$table])) {
return $this->columnCache[$table];
}
$statement = db::getPDO()->prepare(
'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table'
);
$statement->execute([':table' => $table]);
$this->columnCache[$table] = array_map(
static fn(array $row): string => (string)$row['COLUMN_NAME'],
$statement->fetchAll(\PDO::FETCH_ASSOC) ?: []
);
return $this->columnCache[$table];
}
private function normalizeEntity(string $entity): string
{
$entity = strtolower(trim($entity));
return match ($entity) {
'questions' => 'question',
'conditions' => 'condition',
'rules' => 'rule',
'tasks' => 'task',
'actions' => 'action',
'lanes' => 'lane',
'paths' => 'path',
default => $entity,
};
}
private function normalizeGateType(string $gateType): string
{
$gateType = strtoupper(trim($gateType));
return in_array($gateType, [
selfserve_task_gate_type::ALWAYS->value,
selfserve_task_gate_type::CONDITION->value,
selfserve_task_gate_type::QUESTION->value,
], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value;
}
private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool
{
if ($conditionId <= 0) {
return false;
}
$statement = db::getPDO()->prepare(
"SELECT COUNT(*) AS c
FROM department_selfserve_conditions
WHERE id = :id
AND department IN (0, :department)
AND deleted_at IS NULL"
);
$statement->execute([
':id' => $conditionId,
':department' => $departmentId,
]);
return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
}
private function laneBelongsToDepartment(int $laneId, int $departmentId): bool
{
if ($laneId <= 0) {
return false;
}
$statement = db::getPDO()->prepare(
"SELECT COUNT(*) AS c
FROM department_lanes
WHERE id = :id
AND department = :department
AND deleted_at IS NULL"
);
$statement->execute([
':id' => $laneId,
':department' => $departmentId,
]);
return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
}
/**
* @return array{0:string,1:string}
*/
private function parseNodeId(string $nodeId): array
{
$parts = explode(':', $nodeId, 2);
return [
strtolower((string)($parts[0] ?? '')),
(string)($parts[1] ?? ''),
];
}
private function jsonArray(mixed $value): string
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)));
}
if (!is_array($value)) {
$value = [];
}
$json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg());
}
return $json;
}
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === 'null') {
return null;
}
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
}