- Introduced `selfserve_task_attachment_payloads` class for managing task attachments, including formatting and download URL generation. - Added unit and API tests to validate attachment handling in self-serve tasks and customer-scoped workflows. - Enhanced wash start simulation and studio graph projections to integrate task attachment data.
1231 lines
49 KiB
PHP
1231 lines
49 KiB
PHP
<?php
|
|
|
|
namespace modules\selfserve\classes;
|
|
|
|
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
|
require_once WD . '/objects/departments_o.php';
|
|
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
|
|
require_once WD . '/objects/department_selfserve_conditions_o.php';
|
|
require_once WD . '/objects/department_selfserve_questions_o.php';
|
|
require_once WD . '/objects/department_selfserve_tasks_o.php';
|
|
require_once WD . '/objects/selfserve_config_versions_o.php';
|
|
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
|
|
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
|
|
|
use classes\selfserve_schema_bootstrap;
|
|
use modules\selfserve\helpers\selfserve_task_gate_type;
|
|
use modules\selfserve\classes\selfserve_studio_actions;
|
|
use objects\departments_o;
|
|
use objects\department_selfserve_condition_rules_o;
|
|
use objects\department_selfserve_conditions_o;
|
|
use objects\department_selfserve_questions_o;
|
|
use objects\department_selfserve_tasks_o;
|
|
use objects\selfserve_config_versions_o;
|
|
|
|
class selfserve_config_versioning
|
|
{
|
|
public const STATUS_DRAFT = 'DRAFT';
|
|
public const STATUS_PUBLISHED = 'PUBLISHED';
|
|
public const STATUS_ARCHIVED = 'ARCHIVED';
|
|
public const SCHEMA_VERSION_V2 = 2;
|
|
|
|
private const V2_PREDICATE_OPERATORS = [
|
|
'IS_TRUE',
|
|
'IS_FALSE',
|
|
'IS_SET',
|
|
'IS_TRUE_OR_NOT_SET',
|
|
'IS_FALSE_OR_NOT_SET',
|
|
];
|
|
|
|
public function __construct()
|
|
{
|
|
selfserve_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
/**
|
|
* @return array{version_id:int,config:array<string,mixed>}|null
|
|
*/
|
|
public function getPublishedConfig(int $departmentId): ?array
|
|
{
|
|
$version = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_PUBLISHED);
|
|
if (!$version->exists()) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'version_id' => (int)$version->id,
|
|
'config' => (array)($version->config_json->value() ?? []),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{version_id:int,config:array<string,mixed>}|null
|
|
*/
|
|
public function getPublishedV2Config(int $departmentId): ?array
|
|
{
|
|
$published = $this->getPublishedConfig($departmentId);
|
|
if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) {
|
|
return null;
|
|
}
|
|
|
|
$published['config'] = $this->normalizeV2Config((array)$published['config']);
|
|
return $published;
|
|
}
|
|
|
|
public function isV2Config(array $config): bool
|
|
{
|
|
return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function ensureDraftFromLegacy(int $departmentId, ?int $createdBy = null, bool $forceRefresh = false): array
|
|
{
|
|
$versionObject = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
|
|
$config = $this->snapshotLegacyConfig($departmentId);
|
|
$validation = $this->validateConfig($config);
|
|
|
|
if ($versionObject->exists()) {
|
|
$existingConfig = (array)($versionObject->config_json->value() ?? []);
|
|
if ($forceRefresh || !$this->isV2Config($existingConfig)) {
|
|
$nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]);
|
|
$versionObject->config_json->set($nextConfig);
|
|
$versionObject->validation_result_json->set($this->validateConfig($nextConfig));
|
|
} else {
|
|
$normalizedConfig = $this->normalizeV2Config($existingConfig + ['department_id' => $departmentId]);
|
|
if ($normalizedConfig !== $existingConfig) {
|
|
$versionObject->config_json->set($normalizedConfig);
|
|
$versionObject->validation_result_json->set($this->validateConfig($normalizedConfig));
|
|
}
|
|
}
|
|
return $versionObject->asArray();
|
|
}
|
|
|
|
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
|
|
$newVersion = (new selfserve_config_versions_o())->add(
|
|
$departmentId,
|
|
self::STATUS_DRAFT,
|
|
$latestVersionNumber + 1,
|
|
$config,
|
|
$validation,
|
|
null,
|
|
$createdBy,
|
|
null,
|
|
);
|
|
return $newVersion->asArray();
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public function listVersions(int $departmentId): array
|
|
{
|
|
return (new selfserve_config_versions_o())->listByDepartment($departmentId);
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function validateDraft(int $departmentId): array
|
|
{
|
|
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
|
|
if (!$draft->exists()) {
|
|
$created = $this->ensureDraftFromLegacy($departmentId);
|
|
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
|
|
}
|
|
|
|
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
|
|
$draft->validation_result_json->set($validation);
|
|
|
|
return [
|
|
'version' => $draft->asArray(),
|
|
'validation' => $validation,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function publishDraft(int $departmentId, ?int $publishedBy = null): array
|
|
{
|
|
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
|
|
if (!$draft->exists()) {
|
|
$created = $this->ensureDraftFromLegacy($departmentId, $publishedBy);
|
|
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
|
|
}
|
|
|
|
$config = (array)($draft->config_json->value() ?? []);
|
|
if (!$this->isV2Config($config)) {
|
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
|
$draft->config_json->set($config);
|
|
}
|
|
|
|
$validation = $this->validateConfig($config);
|
|
$draft->validation_result_json->set($validation);
|
|
if (($validation['valid'] ?? false) !== true) {
|
|
throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
|
|
}
|
|
|
|
$this->archivePublishedVersions($departmentId);
|
|
$draft->status->set(self::STATUS_PUBLISHED);
|
|
$draft->published_at->set(date('Y-m-d H:i:s'));
|
|
if ($publishedBy !== null) {
|
|
$draft->created_by->set($publishedBy);
|
|
}
|
|
|
|
// Keep editing path open by creating a new draft cloned from newly published version.
|
|
$publishedArray = $draft->asArray();
|
|
$this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy);
|
|
|
|
return $publishedArray;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function rollbackToVersion(int $departmentId, int $targetVersionId, ?int $createdBy = null): array
|
|
{
|
|
$target = (new selfserve_config_versions_o())->select($targetVersionId);
|
|
if (!$target->exists() || (int)$target->department_id->value() !== $departmentId) {
|
|
throw new \RuntimeException('Target version not found for department.');
|
|
}
|
|
|
|
$config = (array)($target->config_json->value() ?? []);
|
|
if (!$this->isV2Config($config)) {
|
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
|
}
|
|
$validation = $this->validateConfig($config);
|
|
if (($validation['valid'] ?? false) !== true) {
|
|
throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
|
|
}
|
|
|
|
$this->archivePublishedVersions($departmentId);
|
|
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
|
|
$rollbackVersion = (new selfserve_config_versions_o())->add(
|
|
$departmentId,
|
|
self::STATUS_PUBLISHED,
|
|
$latestVersionNumber + 1,
|
|
$config,
|
|
$validation,
|
|
$targetVersionId,
|
|
$createdBy,
|
|
date('Y-m-d H:i:s'),
|
|
);
|
|
|
|
$this->createDraftFromConfig($departmentId, $config, (int)$rollbackVersion->id, $createdBy);
|
|
|
|
return $rollbackVersion->asArray();
|
|
}
|
|
|
|
public function syncDraftFromLegacyForDepartment(int $departmentId): void
|
|
{
|
|
if ($departmentId > 0) {
|
|
$this->ensureDraftFromLegacy($departmentId, null, true);
|
|
return;
|
|
}
|
|
|
|
foreach ($this->getKnownDepartmentIdsForSync() as $id) {
|
|
$this->ensureDraftFromLegacy($id, null, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function snapshotLegacyConfig(int $departmentId): array
|
|
{
|
|
return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId));
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function snapshotLegacyTableConfig(int $departmentId): array
|
|
{
|
|
$questionsObject = new department_selfserve_questions_o();
|
|
$conditionsObject = new department_selfserve_conditions_o();
|
|
$tasksObject = new department_selfserve_tasks_o();
|
|
$rulesObject = new department_selfserve_condition_rules_o();
|
|
|
|
$departmentFilter = [0, $departmentId];
|
|
|
|
$questions = $questionsObject->getFieldsWhereIn([
|
|
'department' => $departmentFilter,
|
|
'deleted_at' => null,
|
|
], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);
|
|
|
|
$conditions = $conditionsObject->getFieldsWhereIn([
|
|
'department' => $departmentFilter,
|
|
'deleted_at' => null,
|
|
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);
|
|
|
|
$tasks = $tasksObject->getFieldsWhereIn([
|
|
'department' => $departmentFilter,
|
|
'deleted_at' => null,
|
|
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);
|
|
|
|
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
|
|
$rules = $conditionIds === []
|
|
? []
|
|
: $rulesObject->getFieldsWhereIn([
|
|
'condition_id' => $conditionIds,
|
|
'deleted_at' => null,
|
|
], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']);
|
|
|
|
$conditionIdMap = array_fill_keys($conditionIds, true);
|
|
$tasks = array_map(fn(array $task): array => $this->normalizeTaskGate($task, $conditionIdMap), $tasks);
|
|
|
|
usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
|
|
usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
|
|
usort($rules, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
|
|
usort($tasks, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
|
|
|
|
return [
|
|
'department_id' => $departmentId,
|
|
'questions' => $questions,
|
|
'conditions' => $conditions,
|
|
'rules' => $rules,
|
|
'tasks' => $tasks,
|
|
'actions' => [],
|
|
'snapshot_meta' => [
|
|
'captured_at' => date('c'),
|
|
'source' => 'legacy_tables',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $legacyConfig
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function migrateLegacyConfigToV2(array $legacyConfig): array
|
|
{
|
|
if ($this->isV2Config($legacyConfig)) {
|
|
return $this->normalizeV2Config($legacyConfig);
|
|
}
|
|
|
|
$rulesByCondition = [];
|
|
foreach ((array)($legacyConfig['rules'] ?? []) as $rule) {
|
|
if (!is_array($rule)) {
|
|
continue;
|
|
}
|
|
$rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule;
|
|
}
|
|
|
|
$migrationIssues = [];
|
|
$conditions = [];
|
|
foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) {
|
|
if (!is_array($condition)) {
|
|
continue;
|
|
}
|
|
$conditionId = (int)($condition['id'] ?? 0);
|
|
$conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? []));
|
|
$condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues);
|
|
$conditions[] = $condition;
|
|
}
|
|
|
|
$config = [
|
|
'schema_version' => self::SCHEMA_VERSION_V2,
|
|
'department_id' => (int)($legacyConfig['department_id'] ?? 0),
|
|
'questions' => array_values((array)($legacyConfig['questions'] ?? [])),
|
|
'conditions' => array_values($conditions),
|
|
'rules' => [],
|
|
'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])),
|
|
'actions' => array_values((array)($legacyConfig['actions'] ?? [])),
|
|
'v2_meta' => [
|
|
'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1),
|
|
'migrated_at' => date('c'),
|
|
'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'),
|
|
'next_ids' => $this->nextIdsForConfig($legacyConfig),
|
|
],
|
|
];
|
|
|
|
if ($migrationIssues !== []) {
|
|
$config['migration_issues'] = $migrationIssues;
|
|
}
|
|
|
|
return $this->normalizeV2Config($config);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function validateConfig(array $config): array
|
|
{
|
|
if ($this->isV2Config($config)) {
|
|
return $this->validateV2Config($this->normalizeV2Config($config));
|
|
}
|
|
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
$questions = is_array($config['questions'] ?? null) ? $config['questions'] : [];
|
|
$conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
|
|
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
|
|
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
|
|
$actions = is_array($config['actions'] ?? null) ? $config['actions'] : [];
|
|
|
|
$questionIds = [];
|
|
foreach ($questions as $question) {
|
|
$id = (int)($question['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
$errors[] = 'Question without valid id.';
|
|
continue;
|
|
}
|
|
$questionIds[$id] = true;
|
|
}
|
|
|
|
$conditionIds = [];
|
|
$conditionParents = [];
|
|
foreach ($conditions as $condition) {
|
|
$id = (int)($condition['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
$errors[] = 'Condition without valid id.';
|
|
continue;
|
|
}
|
|
$conditionIds[$id] = true;
|
|
$parentId = $this->nullableInt($condition['condition_id'] ?? null);
|
|
$conditionParents[$id] = $parentId;
|
|
}
|
|
|
|
foreach ($conditionParents as $id => $parentId) {
|
|
if ($parentId !== null && !isset($conditionIds[$parentId])) {
|
|
$errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId;
|
|
}
|
|
if ($parentId === $id) {
|
|
$errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.';
|
|
}
|
|
}
|
|
|
|
foreach ($this->detectConditionCycles($conditionParents) as $cycle) {
|
|
$errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
|
|
}
|
|
|
|
foreach ($rules as $rule) {
|
|
$conditionId = (int)($rule['condition_id'] ?? 0);
|
|
if ($conditionId <= 0 || !isset($conditionIds[$conditionId])) {
|
|
$errors[] = 'Rule references unknown condition_id: ' . $conditionId;
|
|
}
|
|
$objectType = (string)($rule['object_type'] ?? '');
|
|
$objectId = (int)($rule['object_id'] ?? 0);
|
|
if ($objectType === 'question' && !isset($questionIds[$objectId])) {
|
|
$errors[] = 'Rule references unknown question object_id: ' . $objectId;
|
|
}
|
|
if ($objectType === 'condition' && !isset($conditionIds[$objectId])) {
|
|
$errors[] = 'Rule references unknown condition object_id: ' . $objectId;
|
|
}
|
|
}
|
|
|
|
foreach ($tasks as $task) {
|
|
$resolvedGate = $this->resolveTaskGate($task, $conditionIds);
|
|
$gateTypeRaw = (string)($task['gate_type'] ?? '');
|
|
$gateType = $resolvedGate['gate_type'];
|
|
if (selfserve_task_gate_type::tryFrom($gateTypeRaw) === null && $gateTypeRaw !== '') {
|
|
$warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.';
|
|
}
|
|
|
|
$gateRefId = $resolvedGate['gate_ref_id'];
|
|
if ($gateType === selfserve_task_gate_type::ALWAYS) {
|
|
continue;
|
|
}
|
|
if ($gateRefId === null) {
|
|
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' requires gate_ref_id for gate_type ' . $gateType->value;
|
|
continue;
|
|
}
|
|
if ($gateType === selfserve_task_gate_type::CONDITION && !isset($conditionIds[$gateRefId])) {
|
|
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown condition gate_ref_id ' . $gateRefId;
|
|
}
|
|
if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
|
|
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown question gate_ref_id ' . $gateRefId;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'valid' => $errors === [],
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
'stats' => [
|
|
'questions' => count($questions),
|
|
'conditions' => count($conditions),
|
|
'rules' => count($rules),
|
|
'tasks' => count($tasks),
|
|
'actions' => count($actions),
|
|
],
|
|
'validated_at' => date('c'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function validateV2Config(array $config): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
foreach ((array)($config['migration_issues'] ?? []) as $issue) {
|
|
if (is_array($issue)) {
|
|
$errors[] = (string)($issue['message'] ?? 'Migration issue detected.');
|
|
} else {
|
|
$errors[] = (string)$issue;
|
|
}
|
|
}
|
|
|
|
$questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : [];
|
|
$conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : [];
|
|
$tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : [];
|
|
$actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : [];
|
|
|
|
$questionIds = [];
|
|
foreach ($questions as $question) {
|
|
$id = (int)($question['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
$errors[] = 'Question without valid id.';
|
|
continue;
|
|
}
|
|
$questionIds[$id] = true;
|
|
}
|
|
|
|
$conditionIds = [];
|
|
$conditionParents = [];
|
|
foreach ($conditions as $condition) {
|
|
$id = (int)($condition['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
$errors[] = 'Condition without valid id.';
|
|
continue;
|
|
}
|
|
$conditionIds[$id] = true;
|
|
$conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null);
|
|
}
|
|
|
|
$usedConditionIds = [];
|
|
$conditionEdges = [];
|
|
$conditionHasPredicate = [];
|
|
foreach ($conditions as $condition) {
|
|
$conditionId = (int)($condition['id'] ?? 0);
|
|
if ($conditionId <= 0) {
|
|
continue;
|
|
}
|
|
$parentId = $conditionParents[$conditionId] ?? null;
|
|
if ($parentId !== null) {
|
|
if (!isset($conditionIds[$parentId])) {
|
|
$errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId;
|
|
}
|
|
if ($parentId === $conditionId) {
|
|
$errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.';
|
|
}
|
|
$conditionEdges[$conditionId][] = $parentId;
|
|
}
|
|
|
|
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression();
|
|
$expressionValidation = $this->validateExpressionNode(
|
|
$expression,
|
|
$conditionId,
|
|
$questionIds,
|
|
$conditionIds,
|
|
$usedConditionIds,
|
|
$conditionEdges,
|
|
);
|
|
$conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate'];
|
|
foreach ($expressionValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
foreach ($questions as $question) {
|
|
$conditionId = $this->nullableInt($question['condition_id'] ?? null);
|
|
if ($conditionId === null) {
|
|
continue;
|
|
}
|
|
$usedConditionIds[$conditionId] = true;
|
|
if (!isset($conditionIds[$conditionId])) {
|
|
$errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId;
|
|
}
|
|
}
|
|
|
|
foreach ($tasks as $task) {
|
|
$taskId = (int)($task['id'] ?? 0);
|
|
$gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
|
|
$gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
|
|
if ($gateType === null) {
|
|
$errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.';
|
|
continue;
|
|
}
|
|
|
|
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
|
|
if ($gateType === selfserve_task_gate_type::ALWAYS) {
|
|
continue;
|
|
}
|
|
if ($gateRefId === null) {
|
|
$errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value;
|
|
continue;
|
|
}
|
|
if ($gateType === selfserve_task_gate_type::CONDITION) {
|
|
$usedConditionIds[$gateRefId] = true;
|
|
if (!isset($conditionIds[$gateRefId])) {
|
|
$errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId;
|
|
}
|
|
}
|
|
if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
|
|
$errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId;
|
|
}
|
|
}
|
|
|
|
$actionIds = [];
|
|
foreach ($actions as $action) {
|
|
if (!is_array($action)) {
|
|
$errors[] = 'Action has invalid payload.';
|
|
continue;
|
|
}
|
|
$actionId = (int)($action['id'] ?? 0);
|
|
if ($actionId <= 0) {
|
|
$errors[] = 'Action without valid id.';
|
|
continue;
|
|
}
|
|
if (isset($actionIds[$actionId])) {
|
|
$errors[] = 'Duplicate action id ' . $actionId . '.';
|
|
}
|
|
$actionIds[$actionId] = true;
|
|
|
|
$event = strtolower(trim((string)($action['event'] ?? '')));
|
|
if (!in_array($event, selfserve_studio_actions::events(), true)) {
|
|
$errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.';
|
|
}
|
|
|
|
$washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)));
|
|
if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) {
|
|
$errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.';
|
|
}
|
|
if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) {
|
|
$warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.';
|
|
}
|
|
|
|
$operation = strtolower(trim((string)($action['operation'] ?? '')));
|
|
if (!in_array($operation, selfserve_studio_actions::operations(), true)) {
|
|
$errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.';
|
|
}
|
|
if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) {
|
|
$errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.';
|
|
}
|
|
|
|
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
|
|
$failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE)));
|
|
if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) {
|
|
$errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.';
|
|
}
|
|
|
|
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
|
if ($conditionId !== null) {
|
|
$usedConditionIds[$conditionId] = true;
|
|
if (!isset($conditionIds[$conditionId])) {
|
|
$errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.';
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($usedConditionIds as $conditionId => $_used) {
|
|
if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) {
|
|
$errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.';
|
|
}
|
|
}
|
|
|
|
foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) {
|
|
$errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
|
|
}
|
|
|
|
return [
|
|
'valid' => $errors === [],
|
|
'errors' => array_values(array_unique($errors)),
|
|
'warnings' => $warnings,
|
|
'stats' => [
|
|
'schema_version' => self::SCHEMA_VERSION_V2,
|
|
'questions' => count($questions),
|
|
'conditions' => count($conditions),
|
|
'rules' => 0,
|
|
'tasks' => count($tasks),
|
|
'actions' => count($actions),
|
|
],
|
|
'validated_at' => date('c'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $expression
|
|
* @param array<int,bool> $questionIds
|
|
* @param array<int,bool> $conditionIds
|
|
* @param array<int,bool> $usedConditionIds
|
|
* @param array<int,array<int,int>> $conditionEdges
|
|
* @return array{errors:array<int,string>,has_predicate:bool}
|
|
*/
|
|
protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array
|
|
{
|
|
$errors = [];
|
|
$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'] ?? ''));
|
|
if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.';
|
|
}
|
|
if ($subjectType === 'question') {
|
|
if ($subjectId <= 0 || !isset($questionIds[$subjectId])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId;
|
|
}
|
|
} elseif ($subjectType === 'condition') {
|
|
$usedConditionIds[$subjectId] = true;
|
|
$conditionEdges[$ownerConditionId][] = $subjectId;
|
|
if ($subjectId === $ownerConditionId) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.';
|
|
}
|
|
if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId;
|
|
}
|
|
} else {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.';
|
|
}
|
|
|
|
return [
|
|
'errors' => $errors,
|
|
'has_predicate' => true,
|
|
];
|
|
}
|
|
|
|
if (in_array($type, ['branch', 'if', 'if_else'], true)) {
|
|
$branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : [];
|
|
if ($branches === []) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an empty if/else expression.';
|
|
}
|
|
|
|
$hasPredicate = false;
|
|
foreach ($branches as $index => $branch) {
|
|
if (!is_array($branch)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else clause.';
|
|
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);
|
|
if (!$isElse) {
|
|
if (!is_array($branch['when'] ?? null)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a when expression.';
|
|
} else {
|
|
$whenValidation = $this->validateExpressionNode((array)$branch['when'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $whenValidation['has_predicate'];
|
|
foreach ($whenValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!is_array($branch['then'] ?? null)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a then expression.';
|
|
continue;
|
|
}
|
|
|
|
$thenValidation = $this->validateExpressionNode((array)$branch['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $thenValidation['has_predicate'];
|
|
foreach ($thenValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('default', $expression)) {
|
|
if (!is_array($expression['default'])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else default expression.';
|
|
} else {
|
|
$defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $defaultValidation['has_predicate'];
|
|
foreach ($defaultValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'errors' => $errors,
|
|
'has_predicate' => $hasPredicate,
|
|
];
|
|
}
|
|
|
|
if ($type === 'case') {
|
|
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
|
|
$subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
|
|
if ($subjectType === 'question') {
|
|
if ($subjectId <= 0 || !isset($questionIds[$subjectId])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown question case subject_id ' . $subjectId;
|
|
}
|
|
} elseif ($subjectType === 'condition') {
|
|
$usedConditionIds[$subjectId] = true;
|
|
$conditionEdges[$ownerConditionId][] = $subjectId;
|
|
if ($subjectId === $ownerConditionId) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in a case expression.';
|
|
}
|
|
if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition case subject_id ' . $subjectId;
|
|
}
|
|
} else {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has unsupported case subject_type `' . $subjectType . '`.';
|
|
}
|
|
|
|
$cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : [];
|
|
if ($cases === []) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an empty case expression.';
|
|
}
|
|
|
|
$hasPredicate = false;
|
|
foreach ($cases as $case) {
|
|
if (!is_array($case)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case clause.';
|
|
continue;
|
|
}
|
|
if (!array_key_exists('value', $case)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a value.';
|
|
}
|
|
if (!is_array($case['then'] ?? null)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a then expression.';
|
|
continue;
|
|
}
|
|
|
|
$thenValidation = $this->validateExpressionNode((array)$case['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $thenValidation['has_predicate'];
|
|
foreach ($thenValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('default', $expression)) {
|
|
if (!is_array($expression['default'])) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case default expression.';
|
|
} else {
|
|
$defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $defaultValidation['has_predicate'];
|
|
foreach ($defaultValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'errors' => $errors,
|
|
'has_predicate' => $hasPredicate,
|
|
];
|
|
}
|
|
|
|
$operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
|
|
if (!in_array($operator, ['ALL', 'ANY'], true)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.';
|
|
}
|
|
|
|
$children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
|
|
$hasPredicate = false;
|
|
foreach ($children as $child) {
|
|
if (!is_array($child)) {
|
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.';
|
|
continue;
|
|
}
|
|
$childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
|
$hasPredicate = $hasPredicate || $childValidation['has_predicate'];
|
|
foreach ($childValidation['errors'] as $message) {
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'errors' => $errors,
|
|
'has_predicate' => $hasPredicate,
|
|
];
|
|
}
|
|
|
|
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
|
|
{
|
|
// Remove stale drafts first.
|
|
$this->deleteAllDrafts($departmentId);
|
|
|
|
if (!$this->isV2Config($config)) {
|
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
|
}
|
|
|
|
$validation = $this->validateConfig($config);
|
|
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
|
|
(new selfserve_config_versions_o())->add(
|
|
$departmentId,
|
|
self::STATUS_DRAFT,
|
|
$latestVersionNumber + 1,
|
|
$config,
|
|
$validation,
|
|
$sourceVersionId,
|
|
$createdBy,
|
|
null,
|
|
);
|
|
}
|
|
|
|
protected function archivePublishedVersions(int $departmentId): void
|
|
{
|
|
global $db;
|
|
$departmentId = (int)$departmentId;
|
|
$sql = "UPDATE selfserve_config_versions
|
|
SET status = '" . self::STATUS_ARCHIVED . "'
|
|
WHERE department_id = $departmentId
|
|
AND status = '" . self::STATUS_PUBLISHED . "'
|
|
AND deleted_at IS NULL";
|
|
$db->query($sql);
|
|
}
|
|
|
|
protected function deleteAllDrafts(int $departmentId): void
|
|
{
|
|
global $db;
|
|
$departmentId = (int)$departmentId;
|
|
$sql = "UPDATE selfserve_config_versions
|
|
SET deleted_at = NOW()
|
|
WHERE department_id = $departmentId
|
|
AND status = '" . self::STATUS_DRAFT . "'
|
|
AND deleted_at IS NULL";
|
|
$db->query($sql);
|
|
}
|
|
|
|
protected function getLatestVersionNumber(int $departmentId): int
|
|
{
|
|
global $db;
|
|
$departmentId = (int)$departmentId;
|
|
$sql = "SELECT MAX(version_number) AS latest_version
|
|
FROM selfserve_config_versions
|
|
WHERE department_id = $departmentId
|
|
AND deleted_at IS NULL";
|
|
$result = $db->query($sql);
|
|
$row = $db->fetch_assoc($result);
|
|
return (int)($row['latest_version'] ?? 0);
|
|
}
|
|
|
|
/**
|
|
* @return array<int>
|
|
*/
|
|
protected function getKnownDepartmentIdsForSync(): array
|
|
{
|
|
$departmentRows = (new departments_o())->getFieldsWhere([
|
|
'deleted_at' => null,
|
|
], ['id']);
|
|
$ids = array_map(static fn(array $row): int => (int)$row['id'], $departmentRows);
|
|
|
|
if ($ids === []) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
|
|
}
|
|
|
|
protected function nullableInt(mixed $value): ?int
|
|
{
|
|
if ($value === null || $value === '' || $value === 'null') {
|
|
return null;
|
|
}
|
|
|
|
$intValue = (int)$value;
|
|
return $intValue <= 0 ? null : $intValue;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,int|null> $parents
|
|
* @return array<int,array<int,int>>
|
|
*/
|
|
protected function detectConditionCycles(array $parents): array
|
|
{
|
|
$cycles = [];
|
|
$seenCycleKeys = [];
|
|
|
|
foreach (array_keys($parents) as $startId) {
|
|
$path = [];
|
|
$indexById = [];
|
|
$currentId = (int)$startId;
|
|
|
|
while ($currentId > 0 && array_key_exists($currentId, $parents)) {
|
|
if (isset($indexById[$currentId])) {
|
|
$cycle = array_slice($path, $indexById[$currentId]);
|
|
$cycle[] = $currentId;
|
|
$keyNodes = $cycle;
|
|
sort($keyNodes);
|
|
$key = implode(':', $keyNodes);
|
|
if (!isset($seenCycleKeys[$key])) {
|
|
$seenCycleKeys[$key] = true;
|
|
$cycles[] = $cycle;
|
|
}
|
|
break;
|
|
}
|
|
|
|
$indexById[$currentId] = count($path);
|
|
$path[] = $currentId;
|
|
$currentId = (int)($parents[$currentId] ?? 0);
|
|
}
|
|
}
|
|
|
|
return $cycles;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $rules
|
|
* @param array<int,array<string,mixed>> $migrationIssues
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array
|
|
{
|
|
$allChildren = [];
|
|
$anyChildren = [];
|
|
|
|
foreach ($rules as $rule) {
|
|
$predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues);
|
|
if ($predicate === null) {
|
|
continue;
|
|
}
|
|
|
|
if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') {
|
|
$anyChildren[] = $predicate;
|
|
} else {
|
|
$allChildren[] = $predicate;
|
|
}
|
|
}
|
|
|
|
if ($anyChildren !== []) {
|
|
$allChildren[] = [
|
|
'type' => 'group',
|
|
'operator' => 'ANY',
|
|
'children' => $anyChildren,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'group',
|
|
'operator' => 'ALL',
|
|
'children' => $allChildren,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $rule
|
|
* @param array<int,array<string,mixed>> $migrationIssues
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array
|
|
{
|
|
$ruleId = (int)($rule['id'] ?? 0);
|
|
$objectType = strtolower((string)($rule['object_type'] ?? ''));
|
|
if (!in_array($objectType, ['question', 'condition'], true)) {
|
|
$migrationIssues[] = [
|
|
'severity' => 'error',
|
|
'condition_id' => $conditionId,
|
|
'rule_id' => $ruleId,
|
|
'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.',
|
|
];
|
|
return null;
|
|
}
|
|
|
|
$legacyType = strtoupper((string)($rule['type'] ?? ''));
|
|
$operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType;
|
|
if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
|
|
$migrationIssues[] = [
|
|
'severity' => 'error',
|
|
'condition_id' => $conditionId,
|
|
'rule_id' => $ruleId,
|
|
'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.',
|
|
];
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'type' => 'predicate',
|
|
'subject_type' => $objectType,
|
|
'subject_id' => (int)($rule['object_id'] ?? 0),
|
|
'operator' => $operator,
|
|
'legacy_rule_id' => $ruleId > 0 ? $ruleId : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function normalizeV2Config(array $config): array
|
|
{
|
|
$config['schema_version'] = self::SCHEMA_VERSION_V2;
|
|
$config['questions'] = array_values((array)($config['questions'] ?? []));
|
|
$config['conditions'] = array_values(array_map(function ($condition): array {
|
|
$condition = is_array($condition) ? $condition : [];
|
|
if (!is_array($condition['expression'] ?? null)) {
|
|
$condition['expression'] = $this->emptyV2Expression();
|
|
}
|
|
return $condition;
|
|
}, (array)($config['conditions'] ?? [])));
|
|
$config['rules'] = [];
|
|
$conditionIds = [];
|
|
foreach ($config['conditions'] as $condition) {
|
|
$id = (int)($condition['id'] ?? 0);
|
|
if ($id > 0) {
|
|
$conditionIds[$id] = true;
|
|
}
|
|
}
|
|
$config['tasks'] = array_values(array_map(
|
|
fn($task): array => $this->normalizeTaskGate(is_array($task) ? (array)$task : [], $conditionIds),
|
|
(array)($config['tasks'] ?? [])
|
|
));
|
|
$config['actions'] = array_values(array_map(
|
|
static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []),
|
|
(array)($config['actions'] ?? [])
|
|
));
|
|
$config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : [];
|
|
$config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config);
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $task
|
|
* @param array<int,bool|int> $conditionIds
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function normalizeTaskGate(array $task, array $conditionIds): array
|
|
{
|
|
$resolvedGate = $this->resolveTaskGate($task, $conditionIds);
|
|
$task['gate_type'] = $resolvedGate['gate_type']->value;
|
|
$task['gate_ref_id'] = $resolvedGate['gate_ref_id'];
|
|
|
|
$task['condition_id'] = $resolvedGate['gate_type'] === selfserve_task_gate_type::ALWAYS
|
|
? null
|
|
: $resolvedGate['gate_ref_id'];
|
|
|
|
return $task;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $task
|
|
* @param array<int,bool|int> $conditionIds
|
|
* @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null}
|
|
*/
|
|
protected function resolveTaskGate(array $task, array $conditionIds): array
|
|
{
|
|
$gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? ''))));
|
|
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
|
|
$legacyGateId = $this->nullableInt($task['condition_id'] ?? null);
|
|
|
|
if (
|
|
$gateType === selfserve_task_gate_type::CONDITION
|
|
|| $gateType === selfserve_task_gate_type::QUESTION
|
|
) {
|
|
return [
|
|
'gate_type' => $gateType,
|
|
'gate_ref_id' => $gateRefId ?? $legacyGateId,
|
|
];
|
|
}
|
|
|
|
$shouldInferLegacyGate = $gateType === null
|
|
|| (
|
|
$gateType === selfserve_task_gate_type::ALWAYS
|
|
&& $gateRefId === null
|
|
&& $legacyGateId !== null
|
|
);
|
|
|
|
if ($shouldInferLegacyGate) {
|
|
$fallbackGateId = $gateRefId ?? $legacyGateId;
|
|
if ($fallbackGateId === null) {
|
|
return [
|
|
'gate_type' => selfserve_task_gate_type::ALWAYS,
|
|
'gate_ref_id' => null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'gate_type' => $this->containsIntegerId($conditionIds, $fallbackGateId)
|
|
? selfserve_task_gate_type::CONDITION
|
|
: selfserve_task_gate_type::QUESTION,
|
|
'gate_ref_id' => $fallbackGateId,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'gate_type' => selfserve_task_gate_type::ALWAYS,
|
|
'gate_ref_id' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int,bool|int> $ids
|
|
*/
|
|
protected function containsIntegerId(array $ids, int $id): bool
|
|
{
|
|
return isset($ids[$id]) || in_array($id, $ids, true);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,int>
|
|
*/
|
|
protected function nextIdsForConfig(array $config): array
|
|
{
|
|
$next = [];
|
|
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) {
|
|
$max = 0;
|
|
foreach ((array)($config[$key] ?? []) as $row) {
|
|
if (is_array($row)) {
|
|
$max = max($max, (int)($row['id'] ?? 0));
|
|
}
|
|
}
|
|
$next[$name] = $max + 1;
|
|
}
|
|
return $next;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function emptyV2Expression(): array
|
|
{
|
|
return [
|
|
'type' => 'group',
|
|
'operator' => 'ALL',
|
|
'children' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<int,int>> $edges
|
|
* @return array<int,array<int,int>>
|
|
*/
|
|
protected function detectDirectedConditionCycles(array $edges): array
|
|
{
|
|
$cycles = [];
|
|
$visiting = [];
|
|
$visited = [];
|
|
$stack = [];
|
|
|
|
$walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void {
|
|
if (isset($visited[$conditionId])) {
|
|
return;
|
|
}
|
|
if (isset($visiting[$conditionId])) {
|
|
$start = array_search($conditionId, $stack, true);
|
|
$cycle = array_slice($stack, $start === false ? 0 : (int)$start);
|
|
$cycle[] = $conditionId;
|
|
$cycles[] = $cycle;
|
|
return;
|
|
}
|
|
|
|
$visiting[$conditionId] = true;
|
|
$stack[] = $conditionId;
|
|
foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) {
|
|
if ($nextId > 0) {
|
|
$walk($nextId);
|
|
}
|
|
}
|
|
array_pop($stack);
|
|
unset($visiting[$conditionId]);
|
|
$visited[$conditionId] = true;
|
|
};
|
|
|
|
foreach (array_keys($edges) as $conditionId) {
|
|
$walk((int)$conditionId);
|
|
}
|
|
|
|
return $cycles;
|
|
}
|
|
}
|