From 76729f1b99083ef1f3be6bfc5aed3d258d67ca66 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 25 Mar 2026 17:54:41 +0100 Subject: [PATCH] Add department self-serve config versioning routes with lifecycle actions (list, validate, publish, rollback) and associated logic for managing config versions. --- openapi.yaml | 42 ++ .../classes/selfserve_schema_bootstrap.php | 27 ++ .../classes/selfserve_condition_evaluator.php | 16 + .../classes/selfserve_config_versioning.php | 424 ++++++++++++++++++ .../selfserve/classes/selfserve_wash_flow.php | 245 +++++++++- .../helpers/selfserve_task_gate_type.php | 10 + .../selfserve_condition_evaluator_i.php | 9 + .../interfaces/selfserve_wash_flow_i.php | 4 +- .../department_selfserve_questions_o.php | 12 +- .../objects/department_selfserve_tasks_o.php | 95 +++- .../objects/selfserve_config_versions_o.php | 139 ++++++ ...departmentSelfserveConditionRulesRoute.php | 13 + .../departmentSelfserveConditionsRoute.php | 6 + ...departmentSelfserveConfigVersionsRoute.php | 205 +++++++++ .../departmentSelfserveQuestionsRoute.php | 6 + .../routes/departmentSelfserveTasksRoute.php | 83 +++- ...artmentSelfserveVehicleConditionsRoute.php | 108 ++++- .../nginx/app/routes/moduleSelfServeRoute.php | 46 +- .../SelfserveConditionEvaluatorTest.php | 11 + .../SelfserveConfigVersioningTest.php | 141 ++++++ .../Selfserve/SelfserveOpenApiSpecTest.php | 34 ++ .../Selfserve/SelfserveRouteWiringTest.php | 54 +++ .../SelfserveStartCleanerRelayWiringTest.php | 17 + ...SelfserveWashCompletionRelayWiringTest.php | 18 +- 24 files changed, 1722 insertions(+), 43 deletions(-) create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php create mode 100644 services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php create mode 100644 services/nginx/app/objects/selfserve_config_versions_o.php create mode 100644 services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php diff --git a/openapi.yaml b/openapi.yaml index 4ba23c42..7060df5a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4075,6 +4075,20 @@ paths: required: true schema: type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used when no vehicle is found by registration plate. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 responses: '200': description: Successfully evaluated self-serve eligibility @@ -4109,6 +4123,20 @@ paths: required: false schema: type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used to refresh summary data for unknown or reassigned plates. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 responses: '200': description: Successfully retrieved self-serve wash summary @@ -7334,14 +7362,25 @@ paths: vehicle_type_id: type: integer nullable: true + included_minutes: + type: integer + nullable: true machine_type_id: type: integer nullable: true + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + nullable: true machine_start_triggered: type: boolean machine_start_triggered_at: type: string nullable: true + wash_started_at: + type: string + nullable: true created_at: type: string updated_at: @@ -12850,6 +12889,9 @@ components: customer_number: type: integer nullable: true + vehicle_type_id: + type: integer + nullable: true questions: type: array items: diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index e07c3d15..f7877b5b 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -21,6 +21,23 @@ class selfserve_schema_bootstrap global $db; $queries = [ + "CREATE TABLE IF NOT EXISTS selfserve_config_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version_number INT NOT NULL, + config_json JSON NOT NULL, + validation_result_json JSON NULL, + source_version_id INT NULL, + created_by INT NULL, + published_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_selfserve_config_versions_department_status (department_id, status), + INDEX idx_selfserve_config_versions_department_version (department_id, version_number) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + "CREATE TABLE IF NOT EXISTS selfserve_machine_types ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -116,6 +133,16 @@ class selfserve_schema_bootstrap 'machine_type_id', 'ALTER TABLE department_selfserve_tasks ADD COLUMN machine_type_id INT NULL AFTER product' ); + self::ensureColumn( + 'department_selfserve_tasks', + 'gate_type', + "ALTER TABLE department_selfserve_tasks ADD COLUMN gate_type VARCHAR(16) NULL DEFAULT 'ALWAYS' AFTER condition_id" + ); + self::ensureColumn( + 'department_selfserve_tasks', + 'gate_ref_id', + 'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type' + ); self::$initialized = true; } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php index 35e043ed..5fde45b2 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -5,9 +5,11 @@ namespace modules\selfserve\classes; require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php'; require_once WD . '/modules/selfserve/helpers/selfserve_condition_rule_object_type.php'; require_once WD . '/modules/selfserve/helpers/selfserve_condition_rule_type.php'; +require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php'; use modules\selfserve\helpers\selfserve_condition_rule_object_type; use modules\selfserve\helpers\selfserve_condition_rule_type; +use modules\selfserve\helpers\selfserve_task_gate_type; use modules\selfserve\interfaces\selfserve_condition_evaluator_i; class selfserve_condition_evaluator implements selfserve_condition_evaluator_i @@ -98,6 +100,20 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i return ($answers[$gateId] ?? null) === true; } + public function taskGateSatisfiedTyped(string $gateType, ?int $gateRefId, array $conditionResults, array $answers): bool + { + $typed = selfserve_task_gate_type::tryFrom(strtoupper(trim($gateType))); + if ($typed === null) { + return $this->taskGateSatisfied($gateRefId, $conditionResults, $answers); + } + + return match ($typed) { + selfserve_task_gate_type::ALWAYS => true, + selfserve_task_gate_type::CONDITION => ($gateRefId !== null && $gateRefId > 0) ? (($conditionResults[$gateRefId] ?? false) === true) : false, + selfserve_task_gate_type::QUESTION => ($gateRefId !== null && $gateRefId > 0) ? (($answers[$gateRefId] ?? null) === true) : false, + }; + } + /** * @param array $rule * @param array $answers diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php new file mode 100644 index 00000000..47d343ef --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -0,0 +1,424 @@ +}|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 + */ + 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> + */ + public function listVersions(int $departmentId): array + { + return (new selfserve_config_versions_o())->listByDepartment($departmentId); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 $config + * @return array + */ + 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 + */ + 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; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php index ea69ee74..7882e29c 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -5,10 +5,12 @@ namespace modules\selfserve\classes; require_once WD . '/classes/selfserve.php'; require_once WD . '/classes/selfserve_schema_bootstrap.php'; require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php'; +require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php'; +require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php'; require_once WD . '/modules/selfserve/helpers/selfserve_wash_event_type.php'; require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php'; require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php'; @@ -28,6 +30,8 @@ require_once WD . '/objects/selfserve_wash_sessions_o.php'; use classes\selfserve; use classes\selfserve_schema_bootstrap; +use modules\selfserve\classes\selfserve_config_versioning; +use modules\selfserve\helpers\selfserve_task_gate_type; use modules\selfserve\helpers\selfserve_lane_relay; use modules\selfserve\helpers\selfserve_lane_services; use modules\selfserve\helpers\selfserve_lane_state; @@ -58,17 +62,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $this->conditionEvaluator ??= new selfserve_condition_evaluator(); } - public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null): array + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array { - $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber); + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); } - public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true): array + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array { - $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber); + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); if (!$session->exists()) { @@ -85,6 +89,11 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $this->buildSessionMetadata($snapshot), ); } else { + $session->machine_type_id->set($snapshot['machine_type']['id'] ?? null); + $session->customer_number->set($snapshot['customer_number']); + $session->vehicle_id->set($snapshot['vehicle']['id'] ?? null); + $session->vehicle_type_id->set($snapshot['vehicle_type_id']); + $session->reg->set($snapshot['reg']); $session->allowed->set((bool)$snapshot['allowed']); $session->metadata_json->set($this->buildSessionMetadata($snapshot)); $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); @@ -172,19 +181,54 @@ class selfserve_wash_flow implements selfserve_wash_flow_i { try { $lane = (new selfserve())->lane($laneId); - if ( - empty($lane->department_lane) - || empty($lane->department_lane->relay_machine_id) - || trim((string)$lane->department_lane->relay_machine_id->value()) === '' - ) { + if (empty($lane->department_lane)) { return; } - $lane->setMachineRelayStatusHard(false); + + $this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE); + $this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER); } catch (\Throwable) { // Best effort only; session completion flow must continue. } } + protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void + { + if (!$this->isRelayConfiguredForLane($lane, $relay)) { + return; + } + + try { + $status = $lane->getRelayStatus($relay); + if ((bool)($status['on'] ?? false) !== true) { + return; + } + } catch (\Throwable) { + // If relay status can't be read, still attempt turn-off as best effort. + } + + try { + $lane->setRelayStatusHard($relay, false); + } catch (\Throwable) { + // Best effort only; session completion flow must continue. + } + } + + protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool + { + if (empty($lane->department_lane)) { + return false; + } + + $relayId = match ($relay) { + selfserve_lane_relay::MACHINE => (string)$lane->department_lane->relay_machine_id->value(), + selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$lane->department_lane->relay_machine_program_picker_id->value(), + selfserve_lane_relay::MACHINE_CLEANER => (string)$lane->department_lane->relay_machine_cleaner_id->value(), + }; + + return trim($relayId) !== ''; + } + public function getSessionSummary(int $sessionId): array { $session = (new selfserve_wash_sessions_o())->select($sessionId); @@ -230,6 +274,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'questions' => $answers, 'tasks' => $tasks, 'events' => $events, + 'config_version_id' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['config_version_id'] ?? null) : null, + 'evaluation_trace' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['evaluation_trace'] ?? null) : null, ]; } @@ -265,7 +311,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $this->getSessionSummary((int)$session->id); } - protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null): array + protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array { $normalizedReg = selfserve::standardize_registration($reg); $lane = (new department_lanes_o())->select($laneId); @@ -275,14 +321,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $departmentId = (int)$lane->department->value(); $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(); + $publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId); + $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; + $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); - $vehicleTypeId = $vehicle !== null ? (int)$vehicle->type->value() : null; + $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); - $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId); - $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId); - $rules = $this->loadConditionRules($conditions); + $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload); + $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); + $rules = $this->loadConditionRules($conditions, $publishedConfigPayload); $answers = (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle($departmentId, $laneId, $normalizedReg); $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); @@ -309,11 +358,44 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); - $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId); + $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); $activeTasks = []; + $taskGateTrace = []; + $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions); foreach ($tasks as $task) { $gateId = $this->nullableInt($task['condition_id'] ?? null); - if (!$this->conditionEvaluator->taskGateSatisfied($gateId, $serviceConditionResults, $visibleAnswers)) { + $typedGateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); + $typedGateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + + if ($typedGateType === null) { + if ($gateId === null) { + $typedGateType = selfserve_task_gate_type::ALWAYS; + $typedGateRefId = null; + } elseif (in_array($gateId, $conditionIds, true)) { + $typedGateType = selfserve_task_gate_type::CONDITION; + $typedGateRefId = $gateId; + } else { + $typedGateType = selfserve_task_gate_type::QUESTION; + $typedGateRefId = $gateId; + } + } + + $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped( + $typedGateType->value, + $typedGateRefId, + $serviceConditionResults, + $visibleAnswers + ); + + $taskGateTrace[] = [ + 'task_id' => (int)$task['id'], + 'legacy_gate_id' => $gateId, + 'gate_type' => $typedGateType->value, + 'gate_ref_id' => $typedGateRefId, + 'satisfied' => $gateSatisfied, + ]; + + if (!$gateSatisfied) { continue; } @@ -322,6 +404,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'task' => (string)$task['task'], 'description' => (string)($task['description'] ?? ''), 'condition_id' => $gateId, + 'gate_type' => $typedGateType->value, + 'gate_ref_id' => $typedGateRefId, 'order_priority' => (int)($task['order_priority'] ?? 0), 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), 'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)), @@ -374,6 +458,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'machine_available' => $machineAvailable, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'allowed' => $machineAllowed, + 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, + 'evaluation_trace' => [ + 'condition_results' => $serviceConditionResults, + 'task_gates' => $taskGateTrace, + 'visible_question_ids' => $visibleQuestionIds, + ], ]; } @@ -387,6 +477,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $lane = (new selfserve())->lane($laneId); $lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $snapshot['allowed_services']); $lane->turnOnRelay(selfserve_lane_relay::MACHINE); + $this->enableCleanerRelayForStartedWash($lane); $session->markRelayEnabled(); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [ @@ -430,6 +521,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'vehicle' => $snapshot['vehicle'], 'reg' => $snapshot['reg'], 'customer_number' => $snapshot['customer_number'], + 'vehicle_type_id' => $snapshot['vehicle_type_id'], 'questions' => $snapshot['questions'], 'tasks' => $snapshot['tasks'], 'allowed_services' => $snapshot['allowed_services'], @@ -437,11 +529,41 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'allowed' => $snapshot['allowed'], 'session' => $session, + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, ]; } - protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId): array + /** + * @param array|null $publishedConfig + */ + protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?array $publishedConfig = null): array { + if (is_array($publishedConfig) && isset($publishedConfig['questions']) && is_array($publishedConfig['questions'])) { + $questions = array_values(array_filter($publishedConfig['questions'], static function (array $question) use ($departmentId): bool { + return ((int)($question['department'] ?? 0) === 0) || ((int)($question['department'] ?? 0) === $departmentId); + })); + + $sharedQuestions = array_values(array_filter($questions, static function (array $question): bool { + return (int)($question['department'] ?? 0) === 0 + && (int)($question['lane'] ?? 0) === 0 + && (int)($question['product'] ?? 0) === 0; + })); + if ($sharedQuestions !== []) { + return $sharedQuestions; + } + + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($question['department'] ?? 0) === $departmentId + && (int)($question['lane'] ?? 0) === $laneId + && (int)($question['product'] ?? 0) === $vehicleTypeId; + })); + } + $questionsObject = new department_selfserve_questions_o(); $sharedQuestions = $questionsObject->getSharedQuestions(); if ($sharedQuestions !== []) { @@ -454,8 +576,37 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); } - protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array + /** + * @param array|null $publishedConfig + */ + protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array { + if (is_array($publishedConfig) && isset($publishedConfig['conditions']) && is_array($publishedConfig['conditions'])) { + $conditions = array_values(array_filter($publishedConfig['conditions'], static function (array $condition) use ($departmentId): bool { + return ((int)($condition['department'] ?? 0) === 0) || ((int)($condition['department'] ?? 0) === $departmentId); + })); + + if ($machineTypeId !== null) { + $machineTypeConditions = array_values(array_filter($conditions, static function (array $condition) use ($machineTypeId): bool { + return (int)($condition['machine_type_id'] ?? 0) === $machineTypeId; + })); + if ($machineTypeConditions !== []) { + return $machineTypeConditions; + } + } + + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($condition['machine_type_id'] ?? 0) === 0 + && (int)($condition['department'] ?? 0) === $departmentId + && (int)($condition['lane'] ?? 0) === $laneId + && (int)($condition['product'] ?? 0) === $vehicleTypeId; + })); + } + $conditionsObject = new department_selfserve_conditions_o(); if ($machineTypeId !== null) { $machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId); @@ -470,8 +621,36 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); } - protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array + /** + * @param array|null $publishedConfig + */ + protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array { + if (is_array($publishedConfig) && isset($publishedConfig['tasks']) && is_array($publishedConfig['tasks'])) { + $tasks = array_values(array_filter($publishedConfig['tasks'], static function (array $task) use ($departmentId): bool { + return ((int)($task['department'] ?? 0) === 0) || ((int)($task['department'] ?? 0) === $departmentId); + })); + + if ($machineTypeId !== null) { + $machineTypeTasks = array_values(array_filter($tasks, static function (array $task) use ($machineTypeId): bool { + return (int)($task['machine_type_id'] ?? 0) === $machineTypeId; + })); + if ($machineTypeTasks !== []) { + return $machineTypeTasks; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($task['machine_type_id'] ?? 0) === 0 + && (int)($task['department'] ?? 0) === $departmentId + && (int)($task['lane'] ?? 0) === $laneId + && (int)($task['product'] ?? 0) === $vehicleTypeId; + })); + } + $tasksObject = new department_selfserve_tasks_o(); if ($machineTypeId !== null) { $machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId); @@ -486,7 +665,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); } - protected function loadConditionRules(array $conditions): array + /** + * @param array|null $publishedConfig + */ + protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array { if ($conditions === []) { return []; @@ -494,6 +676,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); + if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) { + return array_values(array_filter($publishedConfig['rules'], static function (array $rule) use ($conditionIds): bool { + return in_array((int)($rule['condition_id'] ?? 0), $conditionIds, true); + })); + } + return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([ 'condition_id' => $conditionIds, 'deleted_at' => null, @@ -547,6 +735,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'allowed_services' => $snapshot['allowed_services'], 'machine_available' => (bool)$snapshot['machine_available'], 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, 'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']), 'visible_questions' => array_map(static fn(array $question): array => [ 'id' => (int)$question['id'], @@ -681,6 +871,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $vehicle->exists() ? $vehicle : null; } + protected function resolveVehicleTypeId(?customer_vehicles_o $vehicle, ?int $vehicleTypeIdOverride = null): ?int + { + if ($vehicleTypeIdOverride !== null && $vehicleTypeIdOverride > 0) { + return $vehicleTypeIdOverride; + } + if ($vehicle === null) { + return null; + } + + $vehicleTypeId = (int)$vehicle->type->value(); + return $vehicleTypeId > 0 ? $vehicleTypeId : null; + } + protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o { $session = new selfserve_wash_sessions_o(); diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php b/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php new file mode 100644 index 00000000..9e4ff74d --- /dev/null +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php @@ -0,0 +1,10 @@ + $conditionResults + * @param array $answers + * @return bool + */ + public function taskGateSatisfiedTyped(string $gateType, ?int $gateRefId, array $conditionResults, array $answers): bool; } diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php index 66026620..7804b9c4 100644 --- a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php @@ -4,9 +4,9 @@ namespace modules\selfserve\interfaces; interface selfserve_wash_flow_i { - public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null): array; + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array; - public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true): array; + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array; public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array; diff --git a/services/nginx/app/objects/department_selfserve_questions_o.php b/services/nginx/app/objects/department_selfserve_questions_o.php index 427b7080..653fcb57 100644 --- a/services/nginx/app/objects/department_selfserve_questions_o.php +++ b/services/nginx/app/objects/department_selfserve_questions_o.php @@ -9,9 +9,7 @@ use traits\db_object_t; class department_selfserve_questions_o extends db { - use db_object_t { - delete as trait_delete; - } + use db_object_t; public object_property $department; // The department id public object_property $lane; // The lane id @@ -107,7 +105,13 @@ class department_selfserve_questions_o extends db // `department_selfserve_tasks.condition_id` link (which semantically stores question id). $sql = "UPDATE department_selfserve_tasks SET condition_id = NULL WHERE condition_id = $id"; $db->query($sql); - $this->trait_delete(); + + if (self::columnsExist(['deleted_at'])) { + self::update(['deleted_at' => date('Y-m-d H:i:s')]); + return; + } + + self::deletePermanently(); } public function asArray(): array diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 13e142c2..16f0fb8b 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -6,8 +6,8 @@ use classes\db; use classes\object_property; use classes\selfserve_schema_bootstrap; use Exception; -use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_services; +use modules\selfserve\helpers\selfserve_task_gate_type; use traits\db_object_t; class department_selfserve_tasks_o extends db @@ -27,6 +27,8 @@ class department_selfserve_tasks_o extends db public object_property $product; // The product id public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product) public object_property $condition_id; // Legacy column name: stores question id that gates this task (nullable) + public object_property $gate_type; // Canonical gate type: ALWAYS|CONDITION|QUESTION + public object_property $gate_ref_id; // Canonical gate reference id (nullable) public object_property $task; // The task public object_property $description; // The task description public object_property $order_priority; // The order priority of the task (lower numbers are shown first) @@ -75,7 +77,21 @@ class department_selfserve_tasks_o extends db * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, int|null $question_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null, ?int $machine_type_id = null): self + public function add( + int $department, + int $lane, + int $product, + int|null $question_id, + string $task, + string $description, + int $order_priority = 0, + ?array $services = null, + array|string|null $buttons = null, + int|null $dynamic_images_vehicle_type = null, + ?int $machine_type_id = null, + ?selfserve_task_gate_type $gate_type = null, + ?int $gate_ref_id = null, + ): self { global /** @var db $db */ $db; @@ -89,6 +105,9 @@ class department_selfserve_tasks_o extends db if (!is_null($machine_type_id)) { $machine_type_id = (int)$machine_type_id; } + if (!is_null($gate_ref_id)) { + $gate_ref_id = (int)$gate_ref_id; + } $task = $db->escape_string($task); $description = $db->escape_string($description); $order_priority = (int)$order_priority; @@ -133,6 +152,16 @@ class department_selfserve_tasks_o extends db } } + if ($gate_type === null) { + $resolvedGate = self::resolveLegacyGateDefinition($question_id); + $gate_type = $resolvedGate['gate_type']; + $gate_ref_id = $resolvedGate['gate_ref_id']; + } elseif ($gate_type === selfserve_task_gate_type::ALWAYS) { + $gate_ref_id = null; + } elseif ($gate_ref_id === null || $gate_ref_id <= 0) { + throw new Exception('gate_ref_id must be provided for CONDITION and QUESTION task gate types'); + } + // Add the object $tmp_id = self::add_object([ 'department' => $department, @@ -140,6 +169,8 @@ class department_selfserve_tasks_o extends db 'product' => $product, ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), ...(!is_null($question_id) ? ['condition_id' => $question_id] : []), // Legacy column name; contains the gating question id + 'gate_type' => $gate_type->value, + 'gate_ref_id' => $gate_ref_id, 'task' => $task, 'description' => $description, 'order_priority' => $order_priority, @@ -161,6 +192,8 @@ class department_selfserve_tasks_o extends db $this->product = new object_property($this->table, $this->id, 'product', 'int', false); $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); $this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', true); + $this->gate_type = new object_property($this->table, $this->id, 'gate_type', 'string', false); + $this->gate_ref_id = new object_property($this->table, $this->id, 'gate_ref_id', 'int', true); $this->task = new object_property($this->table, $this->id, 'task', 'string', false); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); @@ -186,6 +219,8 @@ class department_selfserve_tasks_o extends db 'product' => (int)$this->product->value(), 'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(), 'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(), + 'gate_type' => (string)($this->gate_type->value() ?? selfserve_task_gate_type::ALWAYS->value), + 'gate_ref_id' => is_null($this->gate_ref_id->value()) ? null : (int)$this->gate_ref_id->value(), 'task' => (string)$this->task->value(), 'description' => (string)$this->description->value(), 'order_priority' => (int)$this->order_priority->value(), @@ -216,6 +251,9 @@ class department_selfserve_tasks_o extends db public function setQuestionId(?int $question_id): void { $this->condition_id->set(is_null($question_id) ? null : (int)$question_id); + $resolvedGate = self::resolveLegacyGateDefinition($question_id); + $this->gate_type->set($resolvedGate['gate_type']->value); + $this->gate_ref_id->set($resolvedGate['gate_ref_id']); } public function getTasksForMachineType(int $machineTypeId): array @@ -223,7 +261,7 @@ class department_selfserve_tasks_o extends db return self::getFieldsWhere([ 'machine_type_id' => $machineTypeId, 'deleted_at' => null, - ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + ], ['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']); } public function getLegacyTasksForLaneProduct(int $departmentId, int $laneId, int $productId): array @@ -233,7 +271,56 @@ class department_selfserve_tasks_o extends db 'lane' => $laneId, 'product' => $productId, 'deleted_at' => null, - ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + ], ['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']); + } + + /** + * Normalize external gate_type inputs. + * @param mixed $input + * @return selfserve_task_gate_type + * @throws Exception + */ + public static function normalizeGateTypeInput(mixed $input): selfserve_task_gate_type + { + if ($input instanceof selfserve_task_gate_type) { + return $input; + } + if (is_string($input)) { + $normalized = strtoupper(trim($input)); + $gateType = selfserve_task_gate_type::tryFrom($normalized); + if ($gateType !== null) { + return $gateType; + } + } + throw new Exception('Invalid gate_type. Expected ALWAYS, CONDITION, or QUESTION.'); + } + + /** + * Resolve a typed gate from legacy `condition_id` input. + * @param int|null $legacyGateId + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + public static function resolveLegacyGateDefinition(?int $legacyGateId): array + { + if ($legacyGateId === null || $legacyGateId <= 0) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + $condition = (new department_selfserve_conditions_o())->select((int)$legacyGateId); + if ($condition->exists()) { + return [ + 'gate_type' => selfserve_task_gate_type::CONDITION, + 'gate_ref_id' => (int)$legacyGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => (int)$legacyGateId, + ]; } /** diff --git a/services/nginx/app/objects/selfserve_config_versions_o.php b/services/nginx/app/objects/selfserve_config_versions_o.php new file mode 100644 index 00000000..c05381a1 --- /dev/null +++ b/services/nginx/app/objects/selfserve_config_versions_o.php @@ -0,0 +1,139 @@ +setTable('selfserve_config_versions'); + } + + public function getObjectProperties(): void + { + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->version_number = new object_property($this->table, $this->id, 'version_number', 'int', false); + $this->config_json = new object_property($this->table, $this->id, 'config_json', 'json', false); + $this->validation_result_json = new object_property($this->table, $this->id, 'validation_result_json', 'json', false); + $this->source_version_id = new object_property($this->table, $this->id, 'source_version_id', 'int', false); + $this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false); + $this->published_at = new object_property($this->table, $this->id, 'published_at', 'datetime', false); + $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); + $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); + $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); + } + + public function objectChanged(): void + { + // No-op for now. + } + + public function add( + int $departmentId, + string $status, + int $versionNumber, + array $config, + ?array $validationResult = null, + ?int $sourceVersionId = null, + ?int $createdBy = null, + ?string $publishedAt = null, + ): self { + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); + if ($configJson === false) { + throw new \RuntimeException('Failed to encode self-serve config JSON: ' . json_last_error_msg()); + } + + $validationJson = null; + if ($validationResult !== null) { + $validationJson = json_encode($validationResult, JSON_UNESCAPED_UNICODE); + if ($validationJson === false) { + throw new \RuntimeException('Failed to encode self-serve validation JSON: ' . json_last_error_msg()); + } + } + + $this->id = $this->add_object([ + 'department_id' => $departmentId, + 'status' => $status, + 'version_number' => $versionNumber, + // Pass JSON as escaped strings to avoid SQL quoting issues inside generic add_object(). + 'config_json' => $configJson, + 'validation_result_json' => $validationJson, + 'source_version_id' => $sourceVersionId, + 'created_by' => $createdBy, + 'published_at' => $publishedAt, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + return $this; + } + + public function selectLatestByDepartmentAndStatus(int $departmentId, string $status): self + { + $rows = $this->getFieldsWhere([ + 'department_id' => $departmentId, + 'status' => $status, + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return $this; + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function listByDepartment(int $departmentId): array + { + $rows = $this->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return []; + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + return array_map(function (array $row): array { + return (new selfserve_config_versions_o())->select((int)$row['id'])->asArray(); + }, $rows); + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'department_id' => (int)$this->department_id->value(), + 'status' => (string)$this->status->value(), + 'version_number' => (int)$this->version_number->value(), + 'config' => (array)($this->config_json->value() ?? []), + 'validation_result' => (array)($this->validation_result_json->value() ?? []), + 'source_version_id' => $this->source_version_id->value() === null ? null : (int)$this->source_version_id->value(), + 'created_by' => $this->created_by->value() === null ? null : (int)$this->created_by->value(), + 'published_at' => $this->published_at->value() === null ? null : (string)$this->published_at->value(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php index 9fcbfa53..a068024e 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_conditions_o; use objects\department_selfserve_condition_rules_o; @@ -131,6 +132,7 @@ class departmentSelfserveConditionRulesRoute $name, $description ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'ADD_RULE', 'User added department self-serve condition rule ' . $rule_o->id); $response->success($rule_o->asArray()); } catch (\Exception $e) { @@ -169,6 +171,9 @@ class departmentSelfserveConditionRulesRoute if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $this->forbidDepartmentAccess((int)$condition_o->department->value()); } + $originalDepartment = $condition_o->exists() + ? (int)$condition_o->department->value() + : 0; if ($response->isRequestParameterSet('condition_id')) { $new_condition_id = (int)$response->getRequestParameter('condition_id'); @@ -198,6 +203,11 @@ class departmentSelfserveConditionRulesRoute $rule_o->description->update((string)$response->getRequestParameter('description')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + $updatedCondition = (new department_selfserve_conditions_o())->select((int)$rule_o->condition_id->value()); + if ($updatedCondition->exists()) { + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$updatedCondition->department->value()); + } (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'UPDATE_RULE', 'User updated department self-serve condition rule ' . $id); $response->success($rule_o->asArray()); } else { @@ -235,6 +245,9 @@ class departmentSelfserveConditionRulesRoute } $rule_o->delete(); + if ($condition_o->exists()) { + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); + } (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'DELETE_RULE', 'User deleted department self-serve condition rule ' . $id); $response->success('Rule deleted'); } else { diff --git a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php index 1980da48..9f00bb3b 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_conditions_o; use objects\logs_o; @@ -138,6 +139,7 @@ class departmentSelfserveConditionsRoute $condition_id, $machine_type_id ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'ADD_CONDITION', 'User added department self-serve condition ' . $condition_o->id); $response->success($condition_o->asArray()); } catch (\Exception $e) { @@ -168,6 +170,7 @@ class departmentSelfserveConditionsRoute if (!$condition_o->exists()) { $response->error('Condition not found', 404); } + $originalDepartment = (int)$condition_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { @@ -202,6 +205,8 @@ class departmentSelfserveConditionsRoute $condition_o->description->update((string)$response->getRequestParameter('description')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'UPDATE_CONDITION', 'User updated department self-serve condition ' . $id); $response->success($condition_o->asArray()); } else { @@ -236,6 +241,7 @@ class departmentSelfserveConditionsRoute } $condition_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'DELETE_CONDITION', 'User deleted department self-serve condition ' . $id); $response->success('Condition deleted'); } else { diff --git a/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php b/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php new file mode 100644 index 00000000..df579ff9 --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php @@ -0,0 +1,205 @@ +get('/department/selfserve/config/versions', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $versions = $service->listVersions($departmentId); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'LIST_CONFIG_VERSIONS', 'User listed self-serve config versions'); + $response->success($versions); + }, [ + 'list_department_selfserve_config_versions' => 'List self-serve config versions for a department', + 'view_all_department_selfserve_config_versions' => 'List self-serve config versions across all departments', + ]); + + $this->get('/department/selfserve/config/history', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $response->success($service->listVersions($departmentId)); + }, [ + 'list_department_selfserve_config_versions' => 'List self-serve config history for a department', + 'view_all_department_selfserve_config_versions' => 'List self-serve config history across all departments', + ]); + + $this->get('/department/selfserve/config/active', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $published = $service->getPublishedConfig($departmentId); + if ($published === null) { + $response->success([ + 'version_id' => null, + 'config' => null, + 'source' => 'legacy', + ]); + } + + $response->success([ + 'version_id' => (int)$published['version_id'], + 'config' => (array)$published['config'], + 'source' => 'published', + ]); + }, [ + 'list_department_selfserve_config_versions' => 'View active self-serve config version for a department', + 'view_all_department_selfserve_config_versions' => 'View active self-serve config version across all departments', + ]); + + $this->post('/department/selfserve/config/draft', function () { + global $response; + $this->requirePermission('edit_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $forceRefresh = self::isParametersSet(['force']) + ? filter_var(self::getParameter('force'), FILTER_VALIDATE_BOOLEAN) + : false; + + $service = new selfserve_config_versioning(); + $draft = $service->ensureDraftFromLegacy($departmentId, (int)$user->id, $forceRefresh === true); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'UPSERT_CONFIG_DRAFT', 'User upserted self-serve config draft'); + $response->success($draft); + }, [ + 'edit_department_selfserve_config_versions' => 'Create or update a self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/validate', function () { + global $response; + $this->requirePermission('edit_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + $validation = $service->validateDraft($departmentId); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'VALIDATE_CONFIG_DRAFT', 'User validated self-serve config draft'); + $response->success($validation); + }, [ + 'edit_department_selfserve_config_versions' => 'Validate a self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/publish', function () { + global $response; + $this->requirePermission('publish_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + try { + $published = $service->publishDraft($departmentId, (int)$user->id); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'PUBLISH_CONFIG_DRAFT', 'User published self-serve config draft'); + $response->success($published); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'publish_department_selfserve_config_versions' => 'Publish self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/rollback', function () { + global $response; + $this->requirePermission('rollback_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department', 'target_version_id']); + $departmentId = (int)self::getParameter('department'); + $targetVersionId = (int)self::getParameter('target_version_id'); + if ($targetVersionId <= 0) { + $response->error('Invalid target_version_id', 400); + } + + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + try { + $rolledBack = $service->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'ROLLBACK_CONFIG_VERSION', 'User rolled back self-serve config to version ' . $targetVersionId); + $response->success($rolledBack); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'rollback_department_selfserve_config_versions' => 'Rollback self-serve config to a previous version for a department', + ]); + } + + private function assertDepartmentAccess(object $user, int $departmentId): void + { + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true)) { + $this->forbidDepartmentAccess($departmentId); + } + } +} diff --git a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php index 5dd09cd1..a4003212 100644 --- a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_questions_o; use objects\logs_o; @@ -129,6 +130,7 @@ class departmentSelfserveQuestionsRoute $condition_id, $order_priority ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'ADD_QUESTION', 'User added a department self-serve question: ' . $question); $response->success($question_o->asArray()); } catch (\Exception $e) { @@ -157,6 +159,7 @@ class departmentSelfserveQuestionsRoute if (!$question_o->exists()) { $response->error('Question not found', 404); } + $originalDepartment = (int)$question_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { @@ -190,6 +193,8 @@ class departmentSelfserveQuestionsRoute $question_o->order_priority->set((int)self::getParameter('order_priority')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$question_o->department->value()); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'EDIT_QUESTION', 'User updated department self-serve question ID: ' . $id); $response->success($question_o->asArray()); } else { @@ -222,6 +227,7 @@ class departmentSelfserveQuestionsRoute } $question_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$question_o->department->value()); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'DELETE_QUESTION', 'User deleted department self-serve question ID: ' . $id); $response->success('Question deleted'); } else { diff --git a/services/nginx/app/routes/departmentSelfserveTasksRoute.php b/services/nginx/app/routes/departmentSelfserveTasksRoute.php index 52663fb1..d6eababa 100644 --- a/services/nginx/app/routes/departmentSelfserveTasksRoute.php +++ b/services/nginx/app/routes/departmentSelfserveTasksRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_tasks_o; use objects\logs_o; @@ -13,6 +14,7 @@ use attachments\helpers\attachment_content; use classes\attachment_store; use classes\attachments; use modules\selfserve\helpers\selfserve_lane_services; +use modules\selfserve\helpers\selfserve_task_gate_type; use traits\route_t; class departmentSelfserveTasksRoute @@ -79,13 +81,19 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['condition_id'])) { $filters['condition_id'] = (int)self::getParameter('condition_id'); } + if (self::isParametersSet(['gate_type'])) { + $filters['gate_type'] = (string)self::getParameter('gate_type'); + } + if (self::isParametersSet(['gate_ref_id'])) { + $filters['gate_ref_id'] = (int)self::getParameter('gate_ref_id'); + } if (self::isParametersSet(['machine_type_id'])) { $filters['machine_type_id'] = (int)self::getParameter('machine_type_id'); } $response->success( - $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'deleted_at']) + $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'deleted_at']) ->listObjectsWithPaginationIfSet(function ($task) { $t = new department_selfserve_tasks_o(); $t->select((int)$task['id']); @@ -125,6 +133,38 @@ class departmentSelfserveTasksRoute $question_id = (int)$condition_id_param; } } + $gate_type = null; + if ($response->isRequestParameterSet('gate_type')) { + try { + $gate_type = department_selfserve_tasks_o::normalizeGateTypeInput($response->getRequestParameter('gate_type')); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + $gate_ref_id = null; + if ($response->isRequestParameterSet('gate_ref_id')) { + $gate_ref_param = $response->getRequestParameter('gate_ref_id'); + if (!is_null($gate_ref_param) && $gate_ref_param !== '' && $gate_ref_param !== 'null') { + $gate_ref_id = (int)$gate_ref_param; + } + } + if ($gate_type !== null) { + if ($gate_type === selfserve_task_gate_type::ALWAYS) { + $gate_ref_id = null; + $question_id = null; + } else { + if ($gate_ref_id === null) { + $gate_ref_id = $question_id; + } + if ($gate_ref_id === null || $gate_ref_id <= 0) { + $response->error('gate_ref_id is required when gate_type is CONDITION or QUESTION', 400); + } + // Keep legacy contract field in sync. + $question_id = $gate_ref_id; + } + } elseif ($gate_ref_id !== null && $gate_ref_id > 0) { + $question_id = $gate_ref_id; + } $task = (string)$response->getRequestParameter('task'); $description = (string)$response->getRequestParameter('description'); $order_priority = (int)($response->getRequestParameter('order_priority') ?? 0); @@ -211,7 +251,10 @@ class departmentSelfserveTasksRoute $buttons_ids, $vehicle_type, $machine_type_id, + $gate_type, + $gate_ref_id, ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'ADD_TASK', 'User added a department self-serve task: ' . $task); $response->success($task_o->asArray()); } catch (\Exception $e) { @@ -240,6 +283,7 @@ class departmentSelfserveTasksRoute if (!$task_o->exists()) { $response->error('Task not found', 404); } + $originalDepartment = (int)$task_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { @@ -267,6 +311,40 @@ class departmentSelfserveTasksRoute $param = self::getParameter('condition_id'); $task_o->setQuestionId($param === null || $param === '' || $param === 'null' ? null : (int)$param); } + if (self::isParametersSet(['gate_type']) || self::isParametersSet(['gate_ref_id'])) { + try { + $effectiveGateType = self::isParametersSet(['gate_type']) + ? department_selfserve_tasks_o::normalizeGateTypeInput(self::getParameter('gate_type')) + : department_selfserve_tasks_o::normalizeGateTypeInput((string)($task_o->gate_type->value() ?? selfserve_task_gate_type::ALWAYS->value)); + $effectiveGateRefId = self::isParametersSet(['gate_ref_id']) + ? ( + (self::getParameter('gate_ref_id') === null || self::getParameter('gate_ref_id') === '' || self::getParameter('gate_ref_id') === 'null') + ? null + : (int)self::getParameter('gate_ref_id') + ) + : ($task_o->gate_ref_id->value() === null ? null : (int)$task_o->gate_ref_id->value()); + + if (!self::isParametersSet(['gate_ref_id']) && self::isParametersSet(['condition_id']) && $effectiveGateType !== selfserve_task_gate_type::ALWAYS) { + $conditionParam = self::getParameter('condition_id'); + $effectiveGateRefId = ($conditionParam === null || $conditionParam === '' || $conditionParam === 'null') + ? null + : (int)$conditionParam; + } + + if ($effectiveGateType === selfserve_task_gate_type::ALWAYS) { + $effectiveGateRefId = null; + } elseif ($effectiveGateRefId === null || $effectiveGateRefId <= 0) { + $response->error('gate_ref_id is required when gate_type is CONDITION or QUESTION', 400); + } + + $task_o->gate_type->set($effectiveGateType->value); + $task_o->gate_ref_id->set($effectiveGateRefId); + // Preserve legacy contract field. + $task_o->condition_id->set($effectiveGateRefId); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } if (self::isParametersSet(['task'])) { $task_o->task->set((string)self::getParameter('task')); } @@ -344,6 +422,8 @@ class departmentSelfserveTasksRoute } } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$task_o->department->value()); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'EDIT_TASK', 'User updated department self-serve task ID: ' . $id); $response->success($task_o->asArray()); } else { @@ -376,6 +456,7 @@ class departmentSelfserveTasksRoute } $task_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$task_o->department->value()); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'DELETE_TASK', 'User deleted department self-serve task ID: ' . $id); $response->success('Task deleted'); } else { diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 53750c0b..4aed931c 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -11,6 +11,7 @@ use classes\selfserve; use modules\selfserve\classes\selfserve_wash_flow; use objects\customer_vehicles_o; use objects\department_lanes_o; +use objects\department_selfserve_tasks_o; use objects\department_selfserve_vehicle_conditions_o; use objects\logs_o; use traits\route_t; @@ -136,9 +137,15 @@ class departmentSelfserveVehicleConditionsRoute $vehicle = $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); $customer_number = (int)$vehicle->customer_id->value(); } + $vehicle_type_id = $this->resolveVehicleTypeIdFromQuery(); + $flow = $this->getWashFlow(); + + if ($vehicle_type_id !== null) { + $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id); + } (new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg); - $response->success($this->getWashFlow()->previewVehicleEligibility($lane_id, $reg, $customer_number)); + $response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)); }, [ 'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle', 'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for an owned vehicle' @@ -161,10 +168,24 @@ class departmentSelfserveVehicleConditionsRoute } $flow = $this->getWashFlow(); + $vehicle_type_id = $this->resolveVehicleTypeIdFromQuery(); try { if (self::isParametersSet(['session_id'])) { $summary = $flow->getSessionSummary((int)self::getParameter('session_id')); $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); + + if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) { + $summary = $flow->synchronizeSession( + (int)($summary['session']['lane_id'] ?? 0), + (string)($summary['session']['reg'] ?? ''), + isset($summary['session']['customer_number']) && $summary['session']['customer_number'] !== null + ? (int)$summary['session']['customer_number'] + : null, + false, + $vehicle_type_id + ); + } + $response->success($summary); } @@ -173,11 +194,17 @@ class departmentSelfserveVehicleConditionsRoute $reg = selfserve::standardize_registration((string)self::getParameter('reg')); $this->assertLaneAccess($user, $lane_id, $has_global); + $customer_number = null; if (!$has_global && $has_own) { - $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); + $vehicle = $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); + $customer_number = (int)$vehicle->customer_id->value(); } - $summary = $flow->getLatestSessionSummary($lane_id, $reg); + if ($vehicle_type_id !== null) { + $summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id); + } else { + $summary = $flow->getLatestSessionSummary($lane_id, $reg); + } $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); $response->success($summary); } catch (\RuntimeException $e) { @@ -214,6 +241,7 @@ class departmentSelfserveVehicleConditionsRoute if (!$department || !$lane || !$reg || !$question) { $response->error('Missing required fields', 400); } + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); if ($has_global) { $customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null; @@ -230,7 +258,7 @@ class departmentSelfserveVehicleConditionsRoute try { $condition_o = new department_selfserve_vehicle_conditions_o(); $condition_o->add($department, $lane, $reg, $question, $value, $customer_id); - $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id); + $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id); $response->success([ 'condition' => $condition_o->asArray(), @@ -321,12 +349,15 @@ class departmentSelfserveVehicleConditionsRoute } $condition_o->customer_id->update($new_customer_id); } + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); try { $summary = $this->getWashFlow()->synchronizeSession( (int)$condition_o->lane->value(), (string)$condition_o->reg->value(), - $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value() + $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(), + true, + $vehicle_type_id ); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); $response->success([ @@ -383,11 +414,12 @@ class departmentSelfserveVehicleConditionsRoute $lane_id = (int)$condition_o->lane->value(); $reg = (string)$condition_o->reg->value(); $customer_id = $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(); + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); $condition_o->delete(); try { - $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id); + $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, true, $vehicle_type_id); } catch (\Throwable) { $summary = null; } @@ -408,6 +440,70 @@ class departmentSelfserveVehicleConditionsRoute return new selfserve_wash_flow(); } + private function resolveVehicleTypeIdFromQuery(): ?int + { + $rawVehicleType = null; + if (self::isParametersSet(['vehicle_type_id'])) { + $rawVehicleType = self::getParameter('vehicle_type_id'); + } elseif (self::isParametersSet(['vehicle_type'])) { + $rawVehicleType = self::getParameter('vehicle_type'); + } + + return $this->normalizeVehicleTypeOverride($rawVehicleType); + } + + private function resolveVehicleTypeIdFromRequest(): ?int + { + global $response; + + $rawVehicleType = null; + if ($response->isRequestParameterSet('vehicle_type_id')) { + $rawVehicleType = $response->getRequestParameter('vehicle_type_id'); + } elseif ($response->isRequestParameterSet('vehicle_type')) { + $rawVehicleType = $response->getRequestParameter('vehicle_type'); + } elseif (self::isParametersSet(['vehicle_type_id'])) { + $rawVehicleType = self::getParameter('vehicle_type_id'); + } elseif (self::isParametersSet(['vehicle_type'])) { + $rawVehicleType = self::getParameter('vehicle_type'); + } + + return $this->normalizeVehicleTypeOverride($rawVehicleType); + } + + private function normalizeVehicleTypeOverride(mixed $rawVehicleType): ?int + { + global $response; + + if ($rawVehicleType === null) { + return null; + } + + try { + return department_selfserve_tasks_o::normalizeVehicleTypeInput($rawVehicleType); + } catch (\Exception $e) { + $response->error('Invalid vehicle_type_id parameter: ' . $e->getMessage(), 400); + } + } + + private function shouldRefreshSummaryForVehicleType(array $summary, ?int $vehicleTypeIdOverride): bool + { + if ($vehicleTypeIdOverride === null) { + return false; + } + + $session = is_array($summary['session'] ?? null) ? $summary['session'] : []; + $sessionVehicleTypeId = isset($session['vehicle_type_id']) && $session['vehicle_type_id'] !== null + ? (int)$session['vehicle_type_id'] + : null; + if ($sessionVehicleTypeId !== $vehicleTypeIdOverride) { + return true; + } + + $questions = is_array($summary['questions'] ?? null) ? $summary['questions'] : []; + $tasks = is_array($summary['tasks'] ?? null) ? $summary['tasks'] : []; + return $questions === [] && $tasks === []; + } + private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o { global $response; diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 48252c30..8db5bdfe 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -117,6 +117,15 @@ class moduleSelfServeRoute ]; }; + $format_wash_started_at = static function (?int $wash_start_time): ?string { + if ($wash_start_time === null || $wash_start_time <= 0) { + return null; + } + + return date('Y-m-d H:i:s', $wash_start_time); + }; + $included_minutes_when_machine_enabled = 20; + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([ 'lane_id' => $lane_id, 'completed_at' => null, @@ -136,6 +145,15 @@ class moduleSelfServeRoute $lane = $selfserve->lane($lane_id); $lane_status = $lane->getLaneStatus(); $lane_state = $lane->getLaneState(); + $wash_started_at = $format_wash_started_at($lane->getWashStartTime()); + $machine_start_triggered = $wash_started_at !== null; + $machine_relay_enabled = $machine_start_triggered + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN_QUEUED) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN); + $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; $in_progress = $lane_status->equals(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED) || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); @@ -171,9 +189,13 @@ class moduleSelfServeRoute 'customer_number' => $runtime_customer_number, 'vehicle_id' => $vehicle['id'] ?? null, 'vehicle_type_id' => $vehicle['type'] ?? null, + 'included_minutes' => $included_minutes, 'machine_type_id' => null, - 'machine_start_triggered' => $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH), - 'machine_start_triggered_at' => null, + 'machine_relay_enabled' => $machine_relay_enabled, + 'machine_relay_enabled_at' => $machine_relay_enabled ? $wash_started_at : null, + 'machine_start_triggered' => $machine_start_triggered, + 'machine_start_triggered_at' => $wash_started_at, + 'wash_started_at' => $wash_started_at, 'created_at' => null, 'updated_at' => null, ], @@ -191,10 +213,14 @@ class moduleSelfServeRoute } $customer = $build_customer($customer_number); $vehicle = $build_vehicle($vehicle_id, $session_reg); + $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); + $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); + $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; $response->success([ 'lane_id' => $lane_id, 'in_progress' => true, + 'elapsed_minutes' => 0, // TODO: Start when the session was created. 'session' => [ 'id' => (int)$session->id, 'status' => (string)$session->status->value(), @@ -202,9 +228,13 @@ class moduleSelfServeRoute 'customer_number' => $customer_number, 'vehicle_id' => $vehicle_id, 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + 'included_minutes' => $included_minutes, 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), + 'machine_relay_enabled' => $machine_relay_enabled, + 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), - 'machine_start_triggered_at' => $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(), + 'machine_start_triggered_at' => $machine_start_triggered_at, + 'wash_started_at' => $machine_start_triggered_at, 'created_at' => (string)$session->created_at->value(), 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), ], @@ -643,6 +673,16 @@ class moduleSelfServeRoute $lane = $selfserve->lane($lane_id); try { $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); + // A started wash should also turn on cleaner when configured. + try { + if ( + !empty($lane->department_lane) + && !empty($lane->department_lane->relay_machine_cleaner_id) + && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' + ) { + $lane->setMachineCleanerRelayStatusHard(true); + } + } catch (\Throwable $ignored) {} $response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE', 'enabled' => true, 'duration' => $duration]); } catch (\Exception $e) { $msg = $e->getMessage(); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php index d03199c0..9b801c1f 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -5,6 +5,7 @@ app_require('modules/selfserve/classes/selfserve_condition_evaluator.php'); use modules\selfserve\classes\selfserve_condition_evaluator; use modules\selfserve\helpers\selfserve_condition_rule_object_type; use modules\selfserve\helpers\selfserve_condition_rule_type; +use modules\selfserve\helpers\selfserve_task_gate_type; it('evaluates self-serve conditions with combined AND and OR semantics', function (): void { $evaluator = new selfserve_condition_evaluator(); @@ -67,3 +68,13 @@ it('prefers condition results over question answers when evaluating task gates', expect($evaluator->taskGateSatisfied(15, [], [15 => true]))->toBeTrue(); expect($evaluator->taskGateSatisfied(null, [], []))->toBeTrue(); }); + +it('evaluates typed task gates with strict semantics', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::ALWAYS->value, null, [], []))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::CONDITION->value, 31, [31 => true], [31 => false]))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::CONDITION->value, 31, [31 => false], [31 => true]))->toBeFalse(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php new file mode 100644 index 00000000..7740b01b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -0,0 +1,141 @@ +newInstanceWithoutConstructor(); +} + +it('validates typed task gates for known references', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [ + ['id' => 10], + ], + 'conditions' => [ + ['id' => 20], + ], + 'rules' => [], + 'tasks' => [ + [ + 'id' => 100, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + [ + 'id' => 101, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => 20, + ], + [ + 'id' => 102, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => 10, + ], + ], + ]); + + expect($validation['valid'])->toBeTrue(); + expect($validation['errors'])->toBe([]); +}); + +it('fails validation when typed task gates reference unknown entities', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + ['id' => 2], + ], + 'rules' => [], + 'tasks' => [ + [ + 'id' => 200, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => 999, + ], + [ + 'id' => 201, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => 998, + ], + [ + 'id' => 202, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unknown condition gate_ref_id 999'); + expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 998'); + expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id'); +}); + +it('encodes config json payloads with apostrophes before persistence', function (): void { + $version = new class extends selfserve_config_versions_o { + /** @var array */ + public array $capturedPayload = []; + + public function __construct() + { + // Skip db bootstrap for this unit test. + } + + public function add_object(array $data): int + { + $this->capturedPayload = $data; + return 123; + } + + public function getObjectProperties(): void + { + // No-op for this unit test. + } + + public function objectChanged(): void + { + // No-op for this unit test. + } + }; + + $version->add( + 42, + selfserve_config_versioning::STATUS_DRAFT, + 1, + [ + 'questions' => [ + [ + 'id' => 1, + 'question' => "Driver's side check", + ], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], + [ + 'valid' => true, + 'errors' => [], + 'warnings' => [], + ], + ); + + $configJson = $version->capturedPayload['config_json'] ?? null; + $validationJson = $version->capturedPayload['validation_result_json'] ?? null; + + expect($configJson)->toBeString(); + expect($validationJson)->toBeString(); + expect(json_decode($configJson, true)['questions'][0]['question'] ?? null)->toBe("Driver's side check"); + expect(json_decode($validationJson, true)['valid'] ?? null)->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index de78a642..d33fe505 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -24,8 +24,27 @@ function selfserve_openapi_content_or_skip(): string test()->markTestSkipped('openapi.yaml is not available in this runtime environment.'); } +function selfserve_openapi_path_block_or_fail(string $content, string $path): string +{ + $path_marker = ' ' . $path . ':'; + $start = strpos($content, $path_marker); + if ($start === false) { + throw new RuntimeException("OpenAPI path block not found: {$path}"); + } + + $rest = substr($content, $start + strlen($path_marker)); + $next_path = strpos($rest, "\n /"); + if ($next_path === false) { + return substr($content, $start); + } + + return substr($content, $start, strlen($path_marker) + $next_path); +} + it('documents self-serve machine type, eligibility, summary, and webhook endpoints', function (): void { $content = selfserve_openapi_content_or_skip(); + $allowedPathBlock = selfserve_openapi_path_block_or_fail($content, '/department/selfserve/vehicle/allowed'); + $summaryPathBlock = selfserve_openapi_path_block_or_fail($content, '/department/selfserve/washes/summary'); expect($content)->toContain('/department/selfserve/machine-types:'); expect($content)->toContain('/department/selfserve/vehicle/allowed:'); @@ -39,6 +58,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:'); + expect($allowedPathBlock)->toContain('vehicle_type_id:'); + expect($allowedPathBlock)->toContain('vehicle_type:'); + expect($summaryPathBlock)->toContain('vehicle_type_id:'); + expect($summaryPathBlock)->toContain('vehicle_type:'); }); it('defines reusable self-serve wash and machine type schemas', function (): void { @@ -52,3 +75,14 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi expect($content)->toContain('machine_type_id:'); expect($content)->toContain('SelfServeLaneMachineRelayStatus:'); }); + +it('documents in-progress self-serve wash start and machine relay fields', function (): void { + $content = selfserve_openapi_content_or_skip(); + $inProgressPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/wash/in-progress'); + + expect($inProgressPathBlock)->toContain('included_minutes:'); + expect($inProgressPathBlock)->toContain('machine_relay_enabled:'); + expect($inProgressPathBlock)->toContain('machine_relay_enabled_at:'); + expect($inProgressPathBlock)->toContain('machine_start_triggered_at:'); + expect($inProgressPathBlock)->toContain('wash_started_at:'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index d80932ad..136acf73 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -32,6 +32,33 @@ it('keeps machine type support wired into lanes, tasks, and conditions routes', ->toContain('product'); }); +it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void { + $configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php')); + + expect($configRoute)->not->toBeFalse(); + expect($configRoute)->toContain('/department/selfserve/config/versions'); + expect($configRoute)->toContain('/department/selfserve/config/history'); + expect($configRoute)->toContain('/department/selfserve/config/active'); + expect($configRoute)->toContain('/department/selfserve/config/draft'); + expect($configRoute)->toContain('/department/selfserve/config/validate'); + expect($configRoute)->toContain('/department/selfserve/config/publish'); + expect($configRoute)->toContain('/department/selfserve/config/rollback'); + expect($configRoute)->toContain('publish_department_selfserve_config_versions'); + expect($configRoute)->toContain('rollback_department_selfserve_config_versions'); +}); + +it('keeps legacy self-serve CRUD routes syncing canonical drafts', function (): void { + $questionsRoute = file_get_contents(app_path('routes/departmentSelfserveQuestionsRoute.php')); + $conditionsRoute = file_get_contents(app_path('routes/departmentSelfserveConditionsRoute.php')); + $rulesRoute = file_get_contents(app_path('routes/departmentSelfserveConditionRulesRoute.php')); + $tasksRoute = file_get_contents(app_path('routes/departmentSelfserveTasksRoute.php')); + + expect($questionsRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($conditionsRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($rulesRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($tasksRoute)->toContain('syncDraftFromLegacyForDepartment'); +}); + it('wires machine relay status get and set endpoints', function (): void { $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); @@ -72,4 +99,31 @@ it('wires in-progress self-serve wash details endpoint', function (): void { expect($moduleSelfServeRoute)->toContain("'in_progress' => false"); expect($moduleSelfServeRoute)->toContain('selfserve_lane_status::OCCUPIED'); expect($moduleSelfServeRoute)->toContain('selfserve_lane_state::IN_WASH'); + expect($moduleSelfServeRoute)->toContain('getWashStartTime'); + expect($moduleSelfServeRoute)->toContain("'included_minutes' =>"); + expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled' =>"); + expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled_at' =>"); + expect($moduleSelfServeRoute)->toContain("'wash_started_at' =>"); +}); + +it('wires vehicle type override into self-serve preview and synchronization routes', function (): void { + $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('vehicle_type_id'); + expect($vehicleConditionsRoute)->toContain('vehicle_type'); + expect($vehicleConditionsRoute)->toContain('resolveVehicleTypeIdFromQuery'); + expect($vehicleConditionsRoute)->toContain('resolveVehicleTypeIdFromRequest'); + expect($vehicleConditionsRoute)->toContain('normalizeVehicleTypeOverride'); + expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType'); + expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id)'); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride)'); + expect($washFlow)->toContain("'vehicle_type_id' => \$snapshot['vehicle_type_id']"); + expect($washFlow)->toContain("\$session->vehicle_type_id->set(\$snapshot['vehicle_type_id']);"); + expect($washFlow)->toContain("\$session->vehicle_id->set(\$snapshot['vehicle']['id'] ?? null);"); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php index ec3cbfdd..eb5186cf 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php @@ -12,3 +12,20 @@ it('enables cleaner relay on wash start command and webhook flow', function (): expect($washFlow)->toContain('enableCleanerRelayForStartedWash('); expect($washFlow)->toContain('setMachineCleanerRelayStatusHard(true)'); }); + +it('keeps cleaner relay enable wired into machine relay start paths', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $moduleRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($washFlow)->not->toBeFalse(); + $enableMachineRelayMethodOffset = strpos($washFlow, 'protected function enableMachineRelayIfAllowed'); + expect($enableMachineRelayMethodOffset)->not->toBeFalse(); + $enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200); + expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);'); + + expect($moduleRoute)->not->toBeFalse(); + $machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable'); + expect($machineEnableRouteOffset)->not->toBeFalse(); + $machineEnableRoute = substr($moduleRoute, (int)$machineEnableRouteOffset, 1800); + expect($machineEnableRoute)->toContain('$lane->setMachineCleanerRelayStatusHard(true);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php index 55d96d37..c4fdf8b5 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php @@ -1,9 +1,23 @@ not->toBeFalse(); expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);'); - expect($washFlow)->toContain('setMachineRelayStatusHard(false)'); + + $methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash'); + expect($methodOffset)->not->toBeFalse(); + $methodBody = substr($washFlow, (int)$methodOffset, 1500); + + expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);'); + expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);'); + + $helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfiguredAndOn'); + expect($helperOffset)->not->toBeFalse(); + $helperBody = substr($washFlow, (int)$helperOffset, 1500); + + expect($helperBody)->toContain('$status = $lane->getRelayStatus($relay);'); + expect($helperBody)->toContain("if ((bool)(\$status['on'] ?? false) !== true)"); + expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);'); });