425 lines
16 KiB
PHP
425 lines
16 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';
|
|
|
|
use classes\selfserve_schema_bootstrap;
|
|
use modules\selfserve\helpers\selfserve_task_gate_type;
|
|
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 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<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()) {
|
|
if ($forceRefresh) {
|
|
$versionObject->config_json->set($config);
|
|
$versionObject->validation_result_json->set($validation);
|
|
}
|
|
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']);
|
|
}
|
|
|
|
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
|
|
$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, (array)$draft->config_json->value(), (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() ?? []);
|
|
$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
|
|
{
|
|
$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']);
|
|
|
|
$tasks = array_map(function (array $task): array {
|
|
$gateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? ''));
|
|
if ($gateType === null) {
|
|
$legacyGateId = $this->nullableInt($task['condition_id'] ?? null);
|
|
$task['gate_type'] = $legacyGateId === null
|
|
? selfserve_task_gate_type::ALWAYS->value
|
|
: selfserve_task_gate_type::CONDITION->value;
|
|
$task['gate_ref_id'] = $legacyGateId;
|
|
}
|
|
return $task;
|
|
}, $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,
|
|
'snapshot_meta' => [
|
|
'captured_at' => date('c'),
|
|
'source' => 'legacy_tables',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function validateConfig(array $config): array
|
|
{
|
|
$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'] : [];
|
|
|
|
$questionIds = [];
|
|
foreach ($questions as $question) {
|
|
$id = (int)($question['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
$errors[] = 'Question without valid id.';
|
|
continue;
|
|
}
|
|
$questionIds[$id] = true;
|
|
}
|
|
|
|
$conditionIds = [];
|
|
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);
|
|
if ($parentId !== null && !isset($conditionIds[$parentId])) {
|
|
$warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.';
|
|
}
|
|
}
|
|
|
|
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) {
|
|
$gateTypeRaw = (string)($task['gate_type'] ?? '');
|
|
$gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
|
|
if ($gateType === null) {
|
|
$warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.';
|
|
continue;
|
|
}
|
|
|
|
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
|
|
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),
|
|
],
|
|
'validated_at' => date('c'),
|
|
];
|
|
}
|
|
|
|
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
|
|
{
|
|
// Remove stale drafts first.
|
|
$this->deleteAllDrafts($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;
|
|
}
|
|
}
|