1704 lines
69 KiB
PHP
1704 lines
69 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_wash_flow.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';
|
|
|
|
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\helpers\selfserve_task_gate_type;
|
|
|
|
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);
|
|
$graph = $this->buildGraphFromConfig($config, [
|
|
'department_id' => $departmentId,
|
|
'lookups' => $lookups,
|
|
'gateway_workspace' => $gatewayWorkspace,
|
|
], $layout);
|
|
|
|
$validation = $versioning->validateConfig($config);
|
|
$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),
|
|
'gateway_workspace' => $gatewayWorkspace,
|
|
'permissions' => $permissions,
|
|
'meta' => [
|
|
'department_id' => $departmentId,
|
|
'layout_affects_runtime' => false,
|
|
'generated_at' => date('c'),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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'] : [];
|
|
$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),
|
|
'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');
|
|
}
|
|
$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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
|
|
$this->appendTaskServiceEdges($edges, $taskRows, $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'] : [];
|
|
foreach ($operations as $operation) {
|
|
if (is_array($operation)) {
|
|
$this->applyOperation($departmentId, $operation);
|
|
}
|
|
}
|
|
|
|
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'] : [],
|
|
]);
|
|
}
|
|
|
|
(new selfserve_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
|
|
{
|
|
$config = (new selfserve_config_versioning())->snapshotLegacyConfig($departmentId);
|
|
$validation = (new selfserve_config_versioning())->validateConfig($config);
|
|
$validation['items'] = $this->buildValidationItems($validation);
|
|
return $validation;
|
|
}
|
|
|
|
/**
|
|
* @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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildGatewayWorkspace(int $departmentId): array
|
|
{
|
|
if (!class_exists(edge_gateway_department_workspace_service::class)) {
|
|
return [
|
|
'gateways' => [],
|
|
'relays' => [],
|
|
'lanes' => [],
|
|
'issues' => [],
|
|
'actions' => [],
|
|
'available' => false,
|
|
];
|
|
}
|
|
|
|
try {
|
|
return (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId);
|
|
} catch (\Throwable $exception) {
|
|
return [
|
|
'gateways' => [],
|
|
'relays' => [],
|
|
'lanes' => [],
|
|
'issues' => [
|
|
[
|
|
'severity' => 'warning',
|
|
'message' => $exception->getMessage(),
|
|
],
|
|
],
|
|
'actions' => [],
|
|
'available' => false,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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', 'machine_type_id'], ['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'], []);
|
|
|
|
$lookups = [
|
|
'departments' => $this->labelRows($departmentRows, 'name'),
|
|
'lanes' => $this->labelRows($laneRows, 'name'),
|
|
'products' => $this->labelRows($productRows, 'name'),
|
|
'machine_types' => $this->labelRows($machineTypeRows, 'name'),
|
|
'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'),
|
|
'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>> $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 = (int)($gateway['id'] ?? 0);
|
|
if ($gatewayId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$nodeId = 'gateway:' . $gatewayId;
|
|
$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 = 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex;
|
|
$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<string,mixed> $operation
|
|
*/
|
|
private function applyOperation(int $departmentId, array $operation): 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 ($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 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<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
|
|
{
|
|
$lane = $this->lookupRows($lookups, 'lanes')[0] ?? null;
|
|
$vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null;
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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',
|
|
default => 'label',
|
|
};
|
|
$label = trim((string)($row[$field] ?? ''));
|
|
if ($label !== '') {
|
|
return $label;
|
|
}
|
|
return $this->labelFor($entity . 's', $id, $lookups);
|
|
}
|
|
|
|
/**
|
|
* @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'] ?? []);
|
|
return $task;
|
|
}
|
|
|
|
/**
|
|
* @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> $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 = (int)($gateway['id'] ?? 0);
|
|
if ($gatewayId <= 0) {
|
|
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' => 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex,
|
|
'services' => $services,
|
|
];
|
|
}
|
|
}
|
|
|
|
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,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) && (int)($gateway['id'] ?? 0) > 0) {
|
|
$rows[] = [
|
|
'id' => (int)$gateway['id'],
|
|
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
|
|
'status' => (string)($gateway['status'] ?? 'UNKNOWN'),
|
|
];
|
|
}
|
|
}
|
|
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 = (int)($gateway['id'] ?? 0);
|
|
foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
|
|
if (!is_array($binding)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
|
if ($gatewayId <= 0 || $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,
|
|
];
|
|
}
|
|
}
|
|
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',
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|