5211 lines
212 KiB
PHP
5211 lines
212 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
|
|
{
|
|
/** @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);
|
|
$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);
|
|
$paths = [];
|
|
$scenarioCount = max(1, count($vehicleTypeIds));
|
|
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
|
|
|
|
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
|
|
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
|
|
if ($remainingStates !== null && $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' => $pathSampleLimit === null ? null : 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 ($pathSampleLimit !== null && 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,
|
|
];
|
|
if ($remainingStates === null) {
|
|
unset($projectionOptions['max_states']);
|
|
}
|
|
if ($pathSampleLimit === null) {
|
|
unset($projectionOptions['path_sample_limit']);
|
|
}
|
|
$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 ($pathSampleLimit === null || 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);
|
|
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
|
|
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
|
|
$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 ($maxStates !== null && $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 ($pathSampleLimit === null || 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
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
$parsed = (int)$value;
|
|
return $parsed > 0 ? $parsed : null;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|