Add department self-serve config versioning routes with lifecycle actions (list, validate, publish, rollback) and associated logic for managing config versions.

This commit is contained in:
Jeppe Bundgaard
2026-03-25 17:54:41 +01:00
parent 482a17f093
commit 76729f1b99
24 changed files with 1722 additions and 43 deletions
+42
View File
@@ -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:
@@ -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;
}
@@ -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<string,mixed> $rule
* @param array<int,bool|null> $answers
@@ -0,0 +1,424 @@
<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/objects/departments_o.php';
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
use classes\selfserve_schema_bootstrap;
use modules\selfserve\helpers\selfserve_task_gate_type;
use objects\departments_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
use objects\selfserve_config_versions_o;
class selfserve_config_versioning
{
public const STATUS_DRAFT = 'DRAFT';
public const STATUS_PUBLISHED = 'PUBLISHED';
public const STATUS_ARCHIVED = 'ARCHIVED';
public function __construct()
{
selfserve_schema_bootstrap::ensureTables();
}
/**
* @return array{version_id:int,config:array<string,mixed>}|null
*/
public function getPublishedConfig(int $departmentId): ?array
{
$version = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_PUBLISHED);
if (!$version->exists()) {
return null;
}
return [
'version_id' => (int)$version->id,
'config' => (array)($version->config_json->value() ?? []),
];
}
/**
* @return array<string,mixed>
*/
public function ensureDraftFromLegacy(int $departmentId, ?int $createdBy = null, bool $forceRefresh = false): array
{
$versionObject = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
$config = $this->snapshotLegacyConfig($departmentId);
$validation = $this->validateConfig($config);
if ($versionObject->exists()) {
if ($forceRefresh) {
$versionObject->config_json->set($config);
$versionObject->validation_result_json->set($validation);
}
return $versionObject->asArray();
}
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
$newVersion = (new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_DRAFT,
$latestVersionNumber + 1,
$config,
$validation,
null,
$createdBy,
null,
);
return $newVersion->asArray();
}
/**
* @return array<int,array<string,mixed>>
*/
public function listVersions(int $departmentId): array
{
return (new selfserve_config_versions_o())->listByDepartment($departmentId);
}
/**
* @return array<string,mixed>
*/
public function validateDraft(int $departmentId): array
{
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
if (!$draft->exists()) {
$created = $this->ensureDraftFromLegacy($departmentId);
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
}
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
$draft->validation_result_json->set($validation);
return [
'version' => $draft->asArray(),
'validation' => $validation,
];
}
/**
* @return array<string,mixed>
*/
public function publishDraft(int $departmentId, ?int $publishedBy = null): array
{
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
if (!$draft->exists()) {
$created = $this->ensureDraftFromLegacy($departmentId, $publishedBy);
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
}
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
$draft->validation_result_json->set($validation);
if (($validation['valid'] ?? false) !== true) {
throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
}
$this->archivePublishedVersions($departmentId);
$draft->status->set(self::STATUS_PUBLISHED);
$draft->published_at->set(date('Y-m-d H:i:s'));
if ($publishedBy !== null) {
$draft->created_by->set($publishedBy);
}
// Keep editing path open by creating a new draft cloned from newly published version.
$publishedArray = $draft->asArray();
$this->createDraftFromConfig($departmentId, (array)$draft->config_json->value(), (int)$draft->id, $publishedBy);
return $publishedArray;
}
/**
* @return array<string,mixed>
*/
public function rollbackToVersion(int $departmentId, int $targetVersionId, ?int $createdBy = null): array
{
$target = (new selfserve_config_versions_o())->select($targetVersionId);
if (!$target->exists() || (int)$target->department_id->value() !== $departmentId) {
throw new \RuntimeException('Target version not found for department.');
}
$config = (array)($target->config_json->value() ?? []);
$validation = $this->validateConfig($config);
if (($validation['valid'] ?? false) !== true) {
throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
}
$this->archivePublishedVersions($departmentId);
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
$rollbackVersion = (new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_PUBLISHED,
$latestVersionNumber + 1,
$config,
$validation,
$targetVersionId,
$createdBy,
date('Y-m-d H:i:s'),
);
$this->createDraftFromConfig($departmentId, $config, (int)$rollbackVersion->id, $createdBy);
return $rollbackVersion->asArray();
}
public function syncDraftFromLegacyForDepartment(int $departmentId): void
{
if ($departmentId > 0) {
$this->ensureDraftFromLegacy($departmentId, null, true);
return;
}
foreach ($this->getKnownDepartmentIdsForSync() as $id) {
$this->ensureDraftFromLegacy($id, null, true);
}
}
/**
* @return array<string,mixed>
*/
public function snapshotLegacyConfig(int $departmentId): array
{
$questionsObject = new department_selfserve_questions_o();
$conditionsObject = new department_selfserve_conditions_o();
$tasksObject = new department_selfserve_tasks_o();
$rulesObject = new department_selfserve_condition_rules_o();
$departmentFilter = [0, $departmentId];
$questions = $questionsObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);
$conditions = $conditionsObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);
$tasks = $tasksObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
$rules = $conditionIds === []
? []
: $rulesObject->getFieldsWhereIn([
'condition_id' => $conditionIds,
'deleted_at' => null,
], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']);
$tasks = array_map(function (array $task): array {
$gateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? ''));
if ($gateType === null) {
$legacyGateId = $this->nullableInt($task['condition_id'] ?? null);
$task['gate_type'] = $legacyGateId === null
? selfserve_task_gate_type::ALWAYS->value
: selfserve_task_gate_type::CONDITION->value;
$task['gate_ref_id'] = $legacyGateId;
}
return $task;
}, $tasks);
usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($rules, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($tasks, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
return [
'department_id' => $departmentId,
'questions' => $questions,
'conditions' => $conditions,
'rules' => $rules,
'tasks' => $tasks,
'snapshot_meta' => [
'captured_at' => date('c'),
'source' => 'legacy_tables',
],
];
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
public function validateConfig(array $config): array
{
$errors = [];
$warnings = [];
$questions = is_array($config['questions'] ?? null) ? $config['questions'] : [];
$conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
$questionIds = [];
foreach ($questions as $question) {
$id = (int)($question['id'] ?? 0);
if ($id <= 0) {
$errors[] = 'Question without valid id.';
continue;
}
$questionIds[$id] = true;
}
$conditionIds = [];
foreach ($conditions as $condition) {
$id = (int)($condition['id'] ?? 0);
if ($id <= 0) {
$errors[] = 'Condition without valid id.';
continue;
}
$conditionIds[$id] = true;
$parentId = $this->nullableInt($condition['condition_id'] ?? null);
if ($parentId !== null && !isset($conditionIds[$parentId])) {
$warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.';
}
}
foreach ($rules as $rule) {
$conditionId = (int)($rule['condition_id'] ?? 0);
if ($conditionId <= 0 || !isset($conditionIds[$conditionId])) {
$errors[] = 'Rule references unknown condition_id: ' . $conditionId;
}
$objectType = (string)($rule['object_type'] ?? '');
$objectId = (int)($rule['object_id'] ?? 0);
if ($objectType === 'question' && !isset($questionIds[$objectId])) {
$errors[] = 'Rule references unknown question object_id: ' . $objectId;
}
if ($objectType === 'condition' && !isset($conditionIds[$objectId])) {
$errors[] = 'Rule references unknown condition object_id: ' . $objectId;
}
}
foreach ($tasks as $task) {
$gateTypeRaw = (string)($task['gate_type'] ?? '');
$gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
if ($gateType === null) {
$warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.';
continue;
}
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
if ($gateType === selfserve_task_gate_type::ALWAYS) {
continue;
}
if ($gateRefId === null) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' requires gate_ref_id for gate_type ' . $gateType->value;
continue;
}
if ($gateType === selfserve_task_gate_type::CONDITION && !isset($conditionIds[$gateRefId])) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown condition gate_ref_id ' . $gateRefId;
}
if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown question gate_ref_id ' . $gateRefId;
}
}
return [
'valid' => $errors === [],
'errors' => $errors,
'warnings' => $warnings,
'stats' => [
'questions' => count($questions),
'conditions' => count($conditions),
'rules' => count($rules),
'tasks' => count($tasks),
],
'validated_at' => date('c'),
];
}
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
{
// Remove stale drafts first.
$this->deleteAllDrafts($departmentId);
$validation = $this->validateConfig($config);
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
(new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_DRAFT,
$latestVersionNumber + 1,
$config,
$validation,
$sourceVersionId,
$createdBy,
null,
);
}
protected function archivePublishedVersions(int $departmentId): void
{
global $db;
$departmentId = (int)$departmentId;
$sql = "UPDATE selfserve_config_versions
SET status = '" . self::STATUS_ARCHIVED . "'
WHERE department_id = $departmentId
AND status = '" . self::STATUS_PUBLISHED . "'
AND deleted_at IS NULL";
$db->query($sql);
}
protected function deleteAllDrafts(int $departmentId): void
{
global $db;
$departmentId = (int)$departmentId;
$sql = "UPDATE selfserve_config_versions
SET deleted_at = NOW()
WHERE department_id = $departmentId
AND status = '" . self::STATUS_DRAFT . "'
AND deleted_at IS NULL";
$db->query($sql);
}
protected function getLatestVersionNumber(int $departmentId): int
{
global $db;
$departmentId = (int)$departmentId;
$sql = "SELECT MAX(version_number) AS latest_version
FROM selfserve_config_versions
WHERE department_id = $departmentId
AND deleted_at IS NULL";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
return (int)($row['latest_version'] ?? 0);
}
/**
* @return array<int>
*/
protected function getKnownDepartmentIdsForSync(): array
{
$departmentRows = (new departments_o())->getFieldsWhere([
'deleted_at' => null,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $departmentRows);
if ($ids === []) {
return [];
}
return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
}
protected function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === 'null') {
return null;
}
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
}
@@ -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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed>|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();
@@ -0,0 +1,10 @@
<?php
namespace modules\selfserve\helpers;
enum selfserve_task_gate_type: string
{
case ALWAYS = 'ALWAYS';
case CONDITION = 'CONDITION';
case QUESTION = 'QUESTION';
}
@@ -19,4 +19,13 @@ interface selfserve_condition_evaluator_i
* @return bool
*/
public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool;
/**
* @param string $gateType ALWAYS|CONDITION|QUESTION
* @param int|null $gateRefId
* @param array<int,bool> $conditionResults
* @param array<int,bool|null> $answers
* @return bool
*/
public function taskGateSatisfiedTyped(string $gateType, ?int $gateRefId, array $conditionResults, array $answers): bool;
}
@@ -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;
@@ -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
@@ -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,
];
}
/**
@@ -0,0 +1,139 @@
<?php
namespace objects;
use classes\db;
use classes\object_property;
use classes\selfserve_schema_bootstrap;
use traits\db_object_t;
class selfserve_config_versions_o extends db
{
use db_object_t;
public object_property $department_id;
public object_property $status;
public object_property $version_number;
public object_property $config_json;
public object_property $validation_result_json;
public object_property $source_version_id;
public object_property $created_by;
public object_property $published_at;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
selfserve_schema_bootstrap::ensureTables();
$this->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(),
];
}
}
@@ -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 {
@@ -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 {
@@ -0,0 +1,205 @@
<?php
/**
* Route for self-serve config draft/publish lifecycle
*/
namespace routes;
use classes\authentication;
use modules\selfserve\classes\selfserve_config_versioning;
use objects\logs_o;
use traits\route_t;
class departmentSelfserveConfigVersionsRoute
{
use route_t;
public function run(): void
{
$this->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);
}
}
}
@@ -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 {
@@ -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 {
@@ -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;
@@ -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();
@@ -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();
});
@@ -0,0 +1,141 @@
<?php
app_require('modules/selfserve/classes/selfserve_config_versioning.php');
app_require('objects/selfserve_config_versions_o.php');
use modules\selfserve\classes\selfserve_config_versioning;
use objects\selfserve_config_versions_o;
function selfserve_config_versioning_without_constructor(): selfserve_config_versioning
{
$reflection = new ReflectionClass(selfserve_config_versioning::class);
return $reflection->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<string,mixed> */
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();
});
@@ -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:');
});
@@ -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);");
});
@@ -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);');
});
@@ -1,9 +1,23 @@
<?php
it('forces machine relay off when a self-serve wash session is completed', function (): void {
it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void {
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
expect($washFlow)->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);');
});