Add support for selfserve_enabled lanes and synchronize behavior across tasks, sessions, and projections
- Introduced `selfserve_enabled` property for `department_lanes` with schema update, object properties, and associated methods/tests. - Enhanced `selfserve_wash_flow` and session logic to respect lane self-serve settings, including block handling and task filtering. - Updated API routes and Studio Graph projections to include `selfserve_enabled` in payloads and progress callbacks. - Added unit tests for session statuses, lane configuration, and blocking behavior due to disabled self-serve settings.
This commit is contained in:
@@ -15206,6 +15206,16 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveWashTaskSnapshot'
|
||||
allowed_services:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveLaneService'
|
||||
machine_available:
|
||||
type: boolean
|
||||
all_visible_questions_answered:
|
||||
type: boolean
|
||||
allowed:
|
||||
type: boolean
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -150,6 +150,11 @@ class selfserve_schema_bootstrap
|
||||
'machine_type_id',
|
||||
'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'department_lanes',
|
||||
'selfserve_enabled',
|
||||
'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'department_selfserve_conditions',
|
||||
'machine_type_id',
|
||||
|
||||
+8
-3
@@ -264,6 +264,7 @@ class edge_gateway_department_workspace_service
|
||||
'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(),
|
||||
'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(),
|
||||
'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(),
|
||||
'selfserve_enabled' => $lane->isSelfServeEnabled(),
|
||||
'status' => $laneStatus,
|
||||
'self_serve_products' => $lane->getSelfServeLaneProducts(),
|
||||
'relay_slots' => $relaySlots,
|
||||
@@ -385,7 +386,10 @@ class edge_gateway_department_workspace_service
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
|
||||
$enabledLanes = array_values(array_filter($lanes, static function (array $lane): bool {
|
||||
return ($lane['selfserve_enabled'] ?? true) !== false;
|
||||
}));
|
||||
$readyLanes = array_values(array_filter($enabledLanes, static function (array $lane): bool {
|
||||
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
|
||||
}));
|
||||
|
||||
@@ -404,12 +408,13 @@ class edge_gateway_department_workspace_service
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'lane_count' => count($lanes),
|
||||
'enabled_lanes' => count($enabledLanes),
|
||||
'ready_lanes' => count($readyLanes),
|
||||
'configured_task_count' => count($taskRows),
|
||||
'configured_product_count' => count($productIds),
|
||||
'readiness_state' => !$enabled
|
||||
? 'DISABLED'
|
||||
: (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')),
|
||||
: (count($enabledLanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($enabledLanes) ? 'READY' : 'PARTIAL')),
|
||||
'links' => [
|
||||
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
|
||||
'legacy' => '/superuser/selfserve',
|
||||
@@ -594,7 +599,7 @@ class edge_gateway_department_workspace_service
|
||||
}
|
||||
}
|
||||
|
||||
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
|
||||
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['enabled_lanes'] ?? 0)) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'code' => 'SELFSERVE_PARTIAL_READY',
|
||||
|
||||
@@ -13,6 +13,7 @@ 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/department_lanes_o.php';
|
||||
require_once WD . '/objects/selfserve_config_versions_o.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';
|
||||
|
||||
@@ -589,7 +590,13 @@ class selfserve_studio_graph
|
||||
* @param array<string,bool> $permissions
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function projectPathOutcomes(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
|
||||
public function projectPathOutcomes(
|
||||
int $departmentId,
|
||||
array $payload,
|
||||
?int $userId = null,
|
||||
array $permissions = [],
|
||||
?callable $progressCallback = null
|
||||
): array
|
||||
{
|
||||
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
|
||||
if (!in_array($configSource, ['draft', 'published'], true)) {
|
||||
@@ -669,8 +676,7 @@ class selfserve_studio_graph
|
||||
$vehicleTypeIds[] = null;
|
||||
}
|
||||
|
||||
$maxStates = (int)($payload['max_states'] ?? 2048);
|
||||
$maxStates = max(1, min(2048, $maxStates));
|
||||
$maxStates = $this->pathLimit($payload['max_states'] ?? null);
|
||||
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
|
||||
if ($reg === '') {
|
||||
$reg = 'TEST123';
|
||||
@@ -686,12 +692,13 @@ class selfserve_studio_graph
|
||||
$stateCount = 0;
|
||||
$terminalPathCount = 0;
|
||||
$questionIds = [];
|
||||
$pathSampleLimit = max(1, min(200, (int)($payload['path_sample_limit'] ?? 100)));
|
||||
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null);
|
||||
$paths = [];
|
||||
$scenarioCount = max(1, count($vehicleTypeIds));
|
||||
|
||||
foreach ($vehicleTypeIds as $scenarioVehicleTypeId) {
|
||||
$remainingStates = $maxStates - $stateCount;
|
||||
if ($remainingStates <= 0) {
|
||||
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
|
||||
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
|
||||
if ($remainingStates !== null && $remainingStates <= 0) {
|
||||
$truncated = true;
|
||||
break;
|
||||
}
|
||||
@@ -746,18 +753,104 @@ class selfserve_studio_graph
|
||||
);
|
||||
};
|
||||
|
||||
$projection = $this->projectPathOutcomesFromSimulator($simulate, [
|
||||
$projectionOptions = [
|
||||
'scope' => $scenarioScope,
|
||||
'max_states' => $remainingStates,
|
||||
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
|
||||
]);
|
||||
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)),
|
||||
'progress_callback' => function (array $projection) use (
|
||||
$progressCallback,
|
||||
&$outcomes,
|
||||
&$paths,
|
||||
&$stateCount,
|
||||
&$terminalPathCount,
|
||||
&$questionIds,
|
||||
$maxStates,
|
||||
$pathSampleLimit,
|
||||
$scenarioIndex,
|
||||
$scenarioCount,
|
||||
$departmentId,
|
||||
$lookups,
|
||||
$laneId,
|
||||
$vehicleTypeId,
|
||||
$reg,
|
||||
$customerNumber,
|
||||
$configSource,
|
||||
$versionId,
|
||||
$hardwareMode
|
||||
): void {
|
||||
if ($progressCallback === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
|
||||
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
|
||||
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) {
|
||||
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
|
||||
}
|
||||
|
||||
$partialQuestionIds = $questionIds;
|
||||
foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
|
||||
$partialQuestionIds[(int)$questionId] = true;
|
||||
}
|
||||
|
||||
$projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : [];
|
||||
$scenarioPercent = (float)($projectionProgress['percent'] ?? 0);
|
||||
$overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100);
|
||||
|
||||
$partialPayload = $this->pathOutcomesPayload(
|
||||
[
|
||||
'department_id' => $departmentId,
|
||||
'department' => $this->labelFor('departments', $departmentId, $lookups),
|
||||
'lane_id' => $laneId,
|
||||
'lane' => $this->labelFor('lanes', $laneId, $lookups),
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
|
||||
'vehicle_type_count' => $scenarioCount,
|
||||
'registration' => $reg,
|
||||
'customer_number' => $customerNumber,
|
||||
'config_source' => $configSource,
|
||||
'config_version_id' => $versionId,
|
||||
'hardware_mode' => $hardwareMode,
|
||||
'max_states' => $maxStates,
|
||||
],
|
||||
$partialOutcomes,
|
||||
$partialPaths,
|
||||
[],
|
||||
false,
|
||||
$maxStates,
|
||||
$stateCount + (int)($projection['summary']['state_count'] ?? 0),
|
||||
$terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
|
||||
$partialQuestionIds,
|
||||
[
|
||||
'complete' => false,
|
||||
'percent' => (int)floor($overallPercent),
|
||||
'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0),
|
||||
'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0),
|
||||
'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
|
||||
'scenario_index' => $scenarioIndex + 1,
|
||||
'scenario_count' => $scenarioCount,
|
||||
]
|
||||
);
|
||||
$progressCallback($partialPayload);
|
||||
},
|
||||
];
|
||||
if ($remainingStates === null) {
|
||||
unset($projectionOptions['max_states']);
|
||||
}
|
||||
if ($pathSampleLimit === null) {
|
||||
unset($projectionOptions['path_sample_limit']);
|
||||
}
|
||||
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
|
||||
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
|
||||
if (is_array($outcome)) {
|
||||
$outcomes[] = $outcome;
|
||||
}
|
||||
}
|
||||
foreach ((array)($projection['paths'] ?? []) as $path) {
|
||||
if (is_array($path) && count($paths) < $pathSampleLimit) {
|
||||
if (!is_array($path)) {
|
||||
continue;
|
||||
}
|
||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
@@ -772,23 +865,12 @@ class selfserve_studio_graph
|
||||
}
|
||||
}
|
||||
|
||||
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
|
||||
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
|
||||
foreach ($outcomes as $index => &$outcome) {
|
||||
$outcome['id'] = 'outcome-' . ($index + 1);
|
||||
}
|
||||
unset($outcome);
|
||||
foreach ($paths as $index => &$path) {
|
||||
$path['id'] = 'path-' . ($index + 1);
|
||||
}
|
||||
unset($path);
|
||||
|
||||
if ($truncated) {
|
||||
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.';
|
||||
}
|
||||
|
||||
return [
|
||||
'scope' => [
|
||||
return $this->pathOutcomesPayload(
|
||||
[
|
||||
'department_id' => $departmentId,
|
||||
'department' => $this->labelFor('departments', $departmentId, $lookups),
|
||||
'lane_id' => $laneId,
|
||||
@@ -803,20 +885,24 @@ class selfserve_studio_graph
|
||||
'hardware_mode' => $hardwareMode,
|
||||
'max_states' => $maxStates,
|
||||
],
|
||||
'summary' => [
|
||||
$outcomes,
|
||||
$paths,
|
||||
$warnings,
|
||||
$truncated,
|
||||
$maxStates,
|
||||
$stateCount,
|
||||
$terminalPathCount,
|
||||
$questionIds,
|
||||
[
|
||||
'complete' => true,
|
||||
'percent' => 100,
|
||||
'state_count' => $stateCount,
|
||||
'pending_state_count' => 0,
|
||||
'terminal_path_count' => $terminalPathCount,
|
||||
'outcome_count' => count($outcomes),
|
||||
'question_count' => count($questionIds),
|
||||
'question_ids' => array_values(array_map('intval', array_keys($questionIds))),
|
||||
'max_states' => $maxStates,
|
||||
'path_sample_count' => count($paths),
|
||||
],
|
||||
'outcomes' => array_values($outcomes),
|
||||
'paths' => array_values($paths),
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'truncated' => $truncated,
|
||||
];
|
||||
'scenario_index' => $scenarioCount,
|
||||
'scenario_count' => $scenarioCount,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -826,10 +912,11 @@ class selfserve_studio_graph
|
||||
*/
|
||||
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
|
||||
{
|
||||
$maxStates = (int)($options['max_states'] ?? 2048);
|
||||
$maxStates = max(1, min(2048, $maxStates));
|
||||
$maxStates = $this->pathLimit($options['max_states'] ?? null);
|
||||
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
|
||||
$pathSampleLimit = max(0, min(200, (int)($options['path_sample_limit'] ?? 100)));
|
||||
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
|
||||
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
|
||||
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
|
||||
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
|
||||
$stack = [[
|
||||
'answers' => [],
|
||||
@@ -844,7 +931,7 @@ class selfserve_studio_graph
|
||||
$truncated = false;
|
||||
|
||||
while ($stack !== []) {
|
||||
if ($stateCount >= $maxStates) {
|
||||
if ($maxStates !== null && $stateCount >= $maxStates) {
|
||||
$truncated = true;
|
||||
break;
|
||||
}
|
||||
@@ -883,39 +970,55 @@ class selfserve_studio_graph
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
|
||||
$progressCallback($this->pathOutcomesProjectionPayload(
|
||||
$scope,
|
||||
$groups,
|
||||
$paths,
|
||||
$truncated,
|
||||
$maxStates,
|
||||
$stateCount,
|
||||
$terminalPathCount,
|
||||
$questionIds,
|
||||
count($stack)
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$terminalPathCount++;
|
||||
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
|
||||
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
|
||||
if ($pathSampleLimit > 0 && count($paths) < $pathSampleLimit) {
|
||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
||||
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
|
||||
}
|
||||
|
||||
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
|
||||
$progressCallback($this->pathOutcomesProjectionPayload(
|
||||
$scope,
|
||||
$groups,
|
||||
$paths,
|
||||
$truncated,
|
||||
$maxStates,
|
||||
$stateCount,
|
||||
$terminalPathCount,
|
||||
$questionIds,
|
||||
count($stack)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$outcomes = $this->finalizePathOutcomeGroups($groups);
|
||||
foreach ($paths as $index => &$path) {
|
||||
$path['id'] = 'path-' . ($index + 1);
|
||||
}
|
||||
unset($path);
|
||||
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'summary' => [
|
||||
'state_count' => $stateCount,
|
||||
'terminal_path_count' => $terminalPathCount,
|
||||
'outcome_count' => count($outcomes),
|
||||
'question_count' => count($questionIds),
|
||||
'question_ids' => array_values(array_map('intval', array_keys($questionIds))),
|
||||
'max_states' => $maxStates,
|
||||
'path_sample_count' => count($paths),
|
||||
],
|
||||
'outcomes' => $outcomes,
|
||||
'paths' => array_values($paths),
|
||||
'warnings' => $truncated ? ['Path projection was truncated at ' . $maxStates . ' explored state(s).'] : [],
|
||||
'truncated' => $truncated,
|
||||
];
|
||||
return $this->pathOutcomesProjectionPayload(
|
||||
$scope,
|
||||
$groups,
|
||||
$paths,
|
||||
$truncated,
|
||||
$maxStates,
|
||||
$stateCount,
|
||||
$terminalPathCount,
|
||||
$questionIds,
|
||||
count($stack)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1043,6 +1146,7 @@ class selfserve_studio_graph
|
||||
'relay_machine_cleaner_id',
|
||||
'machine_type_id',
|
||||
'dynamic_image_id',
|
||||
'selfserve_enabled',
|
||||
], ['department' => $departmentId]);
|
||||
$machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []);
|
||||
$productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []);
|
||||
@@ -2064,6 +2168,15 @@ class selfserve_studio_graph
|
||||
$availableColumns = $this->tableColumns('department_lanes');
|
||||
$updates = [];
|
||||
$params = [':id' => $id];
|
||||
$wasSelfServeEnabled = null;
|
||||
|
||||
if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) {
|
||||
$statement = db::getPDO()->prepare(
|
||||
'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL'
|
||||
);
|
||||
$statement->execute([':id' => $id]);
|
||||
$wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1;
|
||||
}
|
||||
|
||||
$fields = ['name', ...$this->laneOptionalFields()];
|
||||
foreach ($fields as $field) {
|
||||
@@ -2083,6 +2196,14 @@ class selfserve_studio_graph
|
||||
db::getPDO()->prepare(
|
||||
'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL'
|
||||
)->execute($params);
|
||||
|
||||
if (
|
||||
$wasSelfServeEnabled === true
|
||||
&& array_key_exists(':selfserve_enabled', $params)
|
||||
&& (int)$params[':selfserve_enabled'] === 0
|
||||
) {
|
||||
\objects\department_lanes_o::disableSelfServeRelaysBestEffort($id);
|
||||
}
|
||||
}
|
||||
|
||||
private function softDeleteLane(int $departmentId, int $id): void
|
||||
@@ -2106,11 +2227,16 @@ class selfserve_studio_graph
|
||||
'relay_machine_cleaner_id',
|
||||
'dynamic_image_id',
|
||||
'machine_type_id',
|
||||
'selfserve_enabled',
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeLaneField(string $field, mixed $value): mixed
|
||||
{
|
||||
if ($field === 'selfserve_enabled') {
|
||||
return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) {
|
||||
return $this->nullableInt($value);
|
||||
}
|
||||
@@ -3396,6 +3522,130 @@ class selfserve_studio_graph
|
||||
return null;
|
||||
}
|
||||
|
||||
private function pathLimit(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = (int)$value;
|
||||
return $parsed > 0 ? $parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $scope
|
||||
* @param array<string,array<string,mixed>> $groups
|
||||
* @param array<int,array<string,mixed>> $paths
|
||||
* @param array<int,bool> $questionIds
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function pathOutcomesProjectionPayload(
|
||||
array $scope,
|
||||
array $groups,
|
||||
array $paths,
|
||||
bool $truncated,
|
||||
?int $maxStates,
|
||||
int $stateCount,
|
||||
int $terminalPathCount,
|
||||
array $questionIds,
|
||||
int $pendingStateCount
|
||||
): array {
|
||||
$warnings = [];
|
||||
if ($truncated && $maxStates !== null) {
|
||||
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).';
|
||||
}
|
||||
|
||||
$knownStateCount = max(1, $stateCount + $pendingStateCount);
|
||||
$complete = !$truncated && $pendingStateCount === 0;
|
||||
$percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100)));
|
||||
|
||||
return $this->pathOutcomesPayload(
|
||||
$scope,
|
||||
$this->finalizePathOutcomeGroups($groups),
|
||||
$paths,
|
||||
$warnings,
|
||||
$truncated,
|
||||
$maxStates,
|
||||
$stateCount,
|
||||
$terminalPathCount,
|
||||
$questionIds,
|
||||
[
|
||||
'complete' => $complete,
|
||||
'percent' => $percent,
|
||||
'state_count' => $stateCount,
|
||||
'pending_state_count' => $pendingStateCount,
|
||||
'terminal_path_count' => $terminalPathCount,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $scope
|
||||
* @param array<int,array<string,mixed>> $outcomes
|
||||
* @param array<int,array<string,mixed>> $paths
|
||||
* @param array<int,string> $warnings
|
||||
* @param array<int,bool>|array<int,int> $questionIds
|
||||
* @param array<string,mixed> $progress
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function pathOutcomesPayload(
|
||||
array $scope,
|
||||
array $outcomes,
|
||||
array $paths,
|
||||
array $warnings,
|
||||
bool $truncated,
|
||||
?int $maxStates,
|
||||
int $stateCount,
|
||||
int $terminalPathCount,
|
||||
array $questionIds,
|
||||
array $progress = []
|
||||
): array {
|
||||
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
|
||||
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
|
||||
foreach ($outcomes as $index => &$outcome) {
|
||||
$outcome['id'] = 'outcome-' . ($index + 1);
|
||||
}
|
||||
unset($outcome);
|
||||
|
||||
foreach ($paths as $index => &$path) {
|
||||
$path['id'] = 'path-' . ($index + 1);
|
||||
}
|
||||
unset($path);
|
||||
|
||||
$questionIdValues = [];
|
||||
foreach ($questionIds as $key => $value) {
|
||||
$questionIdValues[] = $value === true ? (int)$key : (int)$value;
|
||||
}
|
||||
$questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0)));
|
||||
sort($questionIdValues);
|
||||
|
||||
$progress = array_merge([
|
||||
'complete' => !$truncated,
|
||||
'percent' => $truncated ? 99 : 100,
|
||||
'state_count' => $stateCount,
|
||||
'pending_state_count' => 0,
|
||||
'terminal_path_count' => $terminalPathCount,
|
||||
], $progress);
|
||||
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'summary' => [
|
||||
'state_count' => $stateCount,
|
||||
'terminal_path_count' => $terminalPathCount,
|
||||
'outcome_count' => count($outcomes),
|
||||
'question_count' => count($questionIdValues),
|
||||
'question_ids' => $questionIdValues,
|
||||
'max_states' => $maxStates,
|
||||
'path_sample_count' => count($paths),
|
||||
],
|
||||
'outcomes' => array_values($outcomes),
|
||||
'paths' => array_values($paths),
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'truncated' => $truncated,
|
||||
'progress' => $progress,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>> $groups
|
||||
* @param array<string,mixed> $simulation
|
||||
|
||||
@@ -103,6 +103,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot);
|
||||
}
|
||||
|
||||
if (!$session->exists()) {
|
||||
$session = (new selfserve_wash_sessions_o())->add(
|
||||
$laneId,
|
||||
@@ -155,6 +161,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
throw new \RuntimeException('No active self-serve wash session found for the lane.');
|
||||
}
|
||||
$summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);
|
||||
if (empty($summary['session']['id'])) {
|
||||
throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.'));
|
||||
}
|
||||
$session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']);
|
||||
}
|
||||
|
||||
@@ -324,6 +333,24 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
$answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId);
|
||||
$answers = $this->buildSessionQuestions($session, $answerRows);
|
||||
$metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : [];
|
||||
$allowedServices = $this->normalizeServiceNames(
|
||||
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
||||
);
|
||||
$machineAvailable = array_key_exists('machine_available', $metadata)
|
||||
? (bool)$metadata['machine_available']
|
||||
: ($lane->exists() && !empty($lane->relay_machine_id->value()));
|
||||
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
|
||||
? (bool)$metadata['all_visible_questions_answered']
|
||||
: true;
|
||||
if (!array_key_exists('all_visible_questions_answered', $metadata)) {
|
||||
foreach ($answers as $answer) {
|
||||
if (($answer['answer'] ?? null) === null) {
|
||||
$allVisibleQuestionsAnswered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tasks = array_map(function (array $row): array {
|
||||
return [
|
||||
@@ -335,6 +362,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type']
|
||||
];
|
||||
}, (new selfserve_wash_session_tasks_o())->listBySession($sessionId));
|
||||
if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) {
|
||||
$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices);
|
||||
}
|
||||
$tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks);
|
||||
|
||||
$events = array_map(function (array $row): array {
|
||||
@@ -353,8 +383,12 @@ 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,
|
||||
'allowed_services' => $allowedServices,
|
||||
'machine_available' => $machineAvailable,
|
||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||
'allowed' => (bool)$session->allowed->value(),
|
||||
'config_version_id' => $metadata['config_version_id'] ?? null,
|
||||
'evaluation_trace' => $metadata['evaluation_trace'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -453,6 +487,56 @@ 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();
|
||||
if (!$lane->isSelfServeEnabled()) {
|
||||
$vehicle = $this->findVehicleByRegistration($normalizedReg);
|
||||
$vehicleData = $vehicle?->asArray();
|
||||
$vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
|
||||
$resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null);
|
||||
|
||||
return [
|
||||
'lane' => $lane->asArray(),
|
||||
'machine_type' => null,
|
||||
'vehicle' => $vehicleData,
|
||||
'reg' => $normalizedReg,
|
||||
'customer_number' => $resolvedCustomerNumber,
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'answers' => [],
|
||||
'persisted_answers' => [],
|
||||
'persisted_answer_customer_number' => null,
|
||||
'answer_overrides' => [],
|
||||
'answer_sources' => [],
|
||||
'questions' => [],
|
||||
'conditions' => [],
|
||||
'tasks' => [],
|
||||
'allowed_services' => [],
|
||||
'machine_available' => false,
|
||||
'all_visible_questions_answered' => false,
|
||||
'allowed' => false,
|
||||
'blocked_reason' => 'Self-serve is disabled for this lane.',
|
||||
'config_version_id' => null,
|
||||
'config_source' => (string)($options['config_source'] ?? 'published'),
|
||||
'evaluation_trace' => [
|
||||
'blocked' => true,
|
||||
'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'],
|
||||
'disabled_lane' => true,
|
||||
'message' => 'Self-serve is disabled for this lane.',
|
||||
'visibility_condition_results' => [],
|
||||
'condition_results' => [],
|
||||
'visibility_expression_traces' => [],
|
||||
'condition_expression_traces' => [],
|
||||
'task_gates' => [],
|
||||
'visible_question_ids' => [],
|
||||
],
|
||||
'debug_candidates' => [
|
||||
'questions' => [],
|
||||
'conditions' => [],
|
||||
'rules' => [],
|
||||
'tasks' => [],
|
||||
'actions' => [],
|
||||
'visible_answers' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
$configSource = (string)($options['config_source'] ?? 'published');
|
||||
$publishedConfigVersionId = $options['config_version_id'] ?? null;
|
||||
$publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null;
|
||||
@@ -585,6 +669,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$machineAllowed = $allVisibleQuestionsAnswered
|
||||
&& $machineAvailable
|
||||
&& in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true);
|
||||
$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices);
|
||||
|
||||
$machineType = null;
|
||||
if ($machineTypeId !== null) {
|
||||
@@ -608,7 +693,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'answer_sources' => $answerSources,
|
||||
'questions' => $visibleQuestions,
|
||||
'conditions' => $serviceConditionResults,
|
||||
'tasks' => $activeTasks,
|
||||
'tasks' => $visibleTasks,
|
||||
'allowed_services' => $allowedServices,
|
||||
'machine_available' => $machineAvailable,
|
||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||
@@ -713,6 +798,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'machine_available' => $snapshot['machine_available'],
|
||||
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
||||
'allowed' => $snapshot['allowed'],
|
||||
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
||||
'session' => $session,
|
||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||
'config_source' => $snapshot['config_source'] ?? null,
|
||||
@@ -720,6 +806,24 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatBlockedSessionSummary(array $snapshot): array
|
||||
{
|
||||
return [
|
||||
'session' => null,
|
||||
'lane' => $snapshot['lane'],
|
||||
'machine_type' => $snapshot['machine_type'],
|
||||
'questions' => [],
|
||||
'tasks' => [],
|
||||
'events' => [],
|
||||
'allowed' => false,
|
||||
'allowed_services' => [],
|
||||
'machine_available' => false,
|
||||
'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.',
|
||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $snapshot
|
||||
* @param array<string,mixed> $options
|
||||
@@ -2789,8 +2893,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'deleted_at' => null,
|
||||
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
|
||||
],
|
||||
['id']
|
||||
['id', 'status']
|
||||
);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null)
|
||||
));
|
||||
if ($rows === []) {
|
||||
return new selfserve_wash_sessions_o();
|
||||
}
|
||||
@@ -2900,6 +3008,37 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<int,string> $allowedServices
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array
|
||||
{
|
||||
if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) {
|
||||
return array_values($tasks);
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$tasks,
|
||||
fn(array $task): bool => !$this->taskUsesMachineControls($task)
|
||||
));
|
||||
}
|
||||
|
||||
protected function taskUsesMachineControls(array $task): bool
|
||||
{
|
||||
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null;
|
||||
return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== '';
|
||||
}
|
||||
|
||||
protected function normalizeServiceNames(array $services): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
@@ -33,7 +33,7 @@ use objects\department_variables_o;
|
||||
trait selfserve_lane_command_t
|
||||
{
|
||||
/**
|
||||
* Determine if the lane's department has self-serve enabled.
|
||||
* Determine if the lane and its department have self-serve enabled.
|
||||
* This method is intentionally protected to allow tests to override
|
||||
* and avoid I/O when needed.
|
||||
*/
|
||||
@@ -46,7 +46,8 @@ trait selfserve_lane_command_t
|
||||
$departmentId = (int)$this->department_lane->department->value();
|
||||
if ($departmentId <= 0) return false;
|
||||
$vars = (new department_variables_o())->selectDepartment($departmentId);
|
||||
return $vars->getVariable('selfserve_enabled') === true;
|
||||
return $vars->getVariable('selfserve_enabled') === true
|
||||
&& $this->department_lane->isSelfServeEnabled();
|
||||
} catch (\Throwable $e) {
|
||||
// If anything goes wrong, default to not enabled
|
||||
return false;
|
||||
@@ -394,7 +395,7 @@ trait selfserve_lane_command_t
|
||||
$gateLabel = $isAccessGate ? 'entrance' : 'exit';
|
||||
|
||||
if (!$this->isDepartmentSelfServeEnabled()) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane\'s department.');
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane.');
|
||||
}
|
||||
if (empty($this->department_lane) || empty($this->department_lane->department)) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');
|
||||
|
||||
@@ -22,6 +22,7 @@ class department_lanes_o extends db
|
||||
public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable)
|
||||
public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable)
|
||||
public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable)
|
||||
public object_property $selfserve_enabled; // Whether this lane can be used for self-serve when department self-serve is enabled
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
@@ -52,7 +53,7 @@ class department_lanes_o extends db
|
||||
* @return department_lanes_o
|
||||
* @throws Exception If the object was not created successfully
|
||||
*/
|
||||
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o
|
||||
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null, bool $selfserve_enabled = true): department_lanes_o
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
@@ -97,6 +98,7 @@ class department_lanes_o extends db
|
||||
...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []),
|
||||
...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database
|
||||
...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []),
|
||||
'selfserve_enabled' => $selfserve_enabled ? 1 : 0,
|
||||
]);
|
||||
$this->id = $tmp_id;
|
||||
self::getObjectProperties();
|
||||
@@ -115,6 +117,7 @@ class department_lanes_o extends db
|
||||
$this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false);
|
||||
$this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->selfserve_enabled = new object_property($this->table, $this->id, 'selfserve_enabled', 'bool', false, true);
|
||||
$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);
|
||||
@@ -138,6 +141,7 @@ class department_lanes_o extends db
|
||||
'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(),
|
||||
'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()),
|
||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||
// Status of the lane
|
||||
'status' => (string)$this->getLaneStatus()->name,
|
||||
// Timestamps
|
||||
@@ -146,6 +150,82 @@ class department_lanes_o extends db
|
||||
];
|
||||
}
|
||||
|
||||
public function isSelfServeEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
try {
|
||||
$value = $this->selfserve_enabled->value();
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function normalizeSelfServeEnabledValue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||
{
|
||||
if ($lane_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$lane = (new selfserve())->lane($lane_id);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||
$lane->setMachineProgramPickerRelayStatusHard(false);
|
||||
});
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||
$lane->setMachineCleanerRelayStatusHard(false);
|
||||
});
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_id', static function () use ($lane): void {
|
||||
$lane->setMachineRelayStatusHard(false);
|
||||
});
|
||||
}
|
||||
|
||||
private static function setLaneRelayOffIfConfigured(object $lane, string $relay_property, callable $callback): void
|
||||
{
|
||||
if (
|
||||
empty($lane->department_lane)
|
||||
|| !isset($lane->department_lane->{$relay_property})
|
||||
|| !is_object($lane->department_lane->{$relay_property})
|
||||
|| !method_exists($lane->department_lane->{$relay_property}, 'value')
|
||||
|| trim((string)$lane->department_lane->{$relay_property}->value()) === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; toggling lane self-serve should not fail on relay I/O.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the self-serve lane products available for this lane
|
||||
* @return array An array of product ids available for this lane
|
||||
|
||||
@@ -14,6 +14,11 @@ class selfserve_wash_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const TERMINAL_STATUSES = [
|
||||
'COMPLETED',
|
||||
'FORCE_STOPPED',
|
||||
];
|
||||
|
||||
public object_property $lane_id;
|
||||
public object_property $department_id;
|
||||
public object_property $machine_type_id;
|
||||
@@ -105,6 +110,25 @@ class selfserve_wash_sessions_o extends db
|
||||
$this->status->set($status->value);
|
||||
}
|
||||
|
||||
public static function isTerminalStatus(?string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim((string)$status)), self::TERMINAL_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function terminalStatusSqlList(): string
|
||||
{
|
||||
return "'" . implode("','", array_map(
|
||||
static fn(string $status): string => str_replace("'", "''", $status),
|
||||
self::TERMINAL_STATUSES
|
||||
)) . "'";
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->completed_at->value() === null
|
||||
&& !self::isTerminalStatus((string)$this->status->value());
|
||||
}
|
||||
|
||||
public function markRelayEnabled(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
@@ -167,7 +191,11 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id']);
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null)
|
||||
));
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
@@ -187,7 +215,11 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id']);
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null)
|
||||
));
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
@@ -232,6 +264,7 @@ class selfserve_wash_sessions_o extends db
|
||||
'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'open' => $this->isOpen(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
@@ -246,16 +279,17 @@ class selfserve_wash_sessions_o extends db
|
||||
|
||||
try {
|
||||
$start = new DateTime((string)$startAt);
|
||||
$now = new DateTime();
|
||||
$endAt = $this->completed_at->value() ?? date('Y-m-d H:i:s');
|
||||
$end = new DateTime((string)$endAt);
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($start > $now) {
|
||||
if ($start > $end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$diff = $start->diff($now);
|
||||
$diff = $start->diff($end);
|
||||
return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5389,6 +5389,29 @@ paths:
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/path-outcomes/stream:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Stream self-serve studio question path outcome progress
|
||||
description: Streams newline-delimited JSON progress events while enumerating the complete feasible yes/no answer path space. Progress events contain the same response shape as the final result with partial outcomes and paths.
|
||||
operationId: streamSelfserveStudioPathOutcomes
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Newline-delimited path outcome progress events
|
||||
content:
|
||||
application/x-ndjson:
|
||||
schema:
|
||||
type: string
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/publish:
|
||||
post:
|
||||
tags:
|
||||
@@ -15638,12 +15661,17 @@ components:
|
||||
max_states:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2048
|
||||
default: 2048
|
||||
nullable: true
|
||||
description: Optional debug cap. Omit for complete path projection.
|
||||
path_sample_limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
nullable: true
|
||||
description: Optional debug cap for returned path rows. Omit to return every terminal path row.
|
||||
|
||||
SelfserveStudioPathOutcomesResponse:
|
||||
type: object
|
||||
required: [scope, summary, outcomes, paths, warnings, truncated]
|
||||
required: [scope, summary, outcomes, paths, warnings, truncated, progress]
|
||||
properties:
|
||||
scope:
|
||||
type: object
|
||||
@@ -15674,6 +15702,27 @@ components:
|
||||
items: { type: string }
|
||||
truncated:
|
||||
type: boolean
|
||||
progress:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathProgress'
|
||||
|
||||
SelfserveStudioPathProgress:
|
||||
type: object
|
||||
required: [complete, percent, state_count, pending_state_count, terminal_path_count]
|
||||
properties:
|
||||
complete: { type: boolean }
|
||||
percent:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
state_count: { type: integer }
|
||||
pending_state_count: { type: integer }
|
||||
terminal_path_count: { type: integer }
|
||||
scenario_index:
|
||||
type: integer
|
||||
nullable: true
|
||||
scenario_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
SelfserveStudioPathOutcome:
|
||||
type: object
|
||||
@@ -15841,6 +15890,9 @@ components:
|
||||
type: boolean
|
||||
allowed:
|
||||
type: boolean
|
||||
blocked_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
session:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SelfserveWashSession'
|
||||
@@ -15874,6 +15926,16 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveWashTaskSnapshot'
|
||||
allowed_services:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveLaneService'
|
||||
machine_available:
|
||||
type: boolean
|
||||
all_visible_questions_answered:
|
||||
type: boolean
|
||||
allowed:
|
||||
type: boolean
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
@@ -16266,6 +16328,8 @@ components:
|
||||
type: integer
|
||||
machine_available:
|
||||
type: boolean
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentCreate:
|
||||
type: object
|
||||
@@ -16334,6 +16398,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
status:
|
||||
type: string
|
||||
created_at:
|
||||
@@ -16370,6 +16437,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
DepartmentLaneUpdate:
|
||||
type: object
|
||||
@@ -16399,6 +16469,8 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentGate:
|
||||
type: object
|
||||
|
||||
@@ -60,6 +60,7 @@ class departmentLanesRoute
|
||||
'relay_machine_cleaner_id',
|
||||
'dynamic_image_id',
|
||||
'machine_type_id',
|
||||
'selfserve_enabled',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($department_lane) use ($user) {
|
||||
@@ -317,6 +318,9 @@ class departmentLanesRoute
|
||||
|
||||
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
|
||||
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
|
||||
$selfserve_enabled = self::isParametersSet(['selfserve_enabled'])
|
||||
? department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled'))
|
||||
: true;
|
||||
if ($dynamic_image_id !== null) {
|
||||
$did = (int)$dynamic_image_id;
|
||||
$this->requireType($did, $this->type_int());
|
||||
@@ -333,9 +337,12 @@ class departmentLanesRoute
|
||||
// Check if the required fields are set
|
||||
if ($name && $department) {
|
||||
// Add the department lane
|
||||
(new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id);
|
||||
$created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled);
|
||||
// Return a success message
|
||||
$response->success('Department lane added');
|
||||
$response->success([
|
||||
'message' => 'Department lane added',
|
||||
'lane' => $created_lane->asArray(),
|
||||
]);
|
||||
} else {
|
||||
// Return an error
|
||||
$response->error('Missing required fields', 400);
|
||||
@@ -385,6 +392,7 @@ class departmentLanesRoute
|
||||
// Return an error
|
||||
$response->error('Department lane not found', 404);
|
||||
}
|
||||
$was_selfserve_enabled = $department_lane->isSelfServeEnabled();
|
||||
// Update the department lane fields that are set
|
||||
if (self::isParametersSet(['name'])) {
|
||||
$department_lane->name->set($name);
|
||||
@@ -429,8 +437,18 @@ class departmentLanesRoute
|
||||
$department_lane->machine_type_id->set($machineTypeId);
|
||||
}
|
||||
}
|
||||
if (self::isParametersSet(['selfserve_enabled'])) {
|
||||
$next_selfserve_enabled = department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled'));
|
||||
$department_lane->selfserve_enabled->set($next_selfserve_enabled);
|
||||
if ($was_selfserve_enabled && !$next_selfserve_enabled) {
|
||||
department_lanes_o::disableSelfServeRelaysBestEffort((int)$department_lane->id);
|
||||
}
|
||||
}
|
||||
// Return a success message
|
||||
$response->success('Department lane updated');
|
||||
$response->success([
|
||||
'message' => 'Department lane updated',
|
||||
'lane' => $department_lane->asArray(),
|
||||
]);
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('department_lanes', 'global', 1, 0, 'EDIT_DEPARTMENT_LANE', 'User tried to edit a department lane without being logged in');
|
||||
|
||||
@@ -132,6 +132,46 @@ class departmentSelfserveStudioRoute
|
||||
'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
|
||||
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
|
||||
self::requireParameters(['department']);
|
||||
$departmentId = (int)self::getParameter('department');
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
header('Content-Type: application/x-ndjson; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-transform');
|
||||
header('X-Accel-Buffering: no');
|
||||
|
||||
$emit = static function (array $event): void {
|
||||
echo json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
if (function_exists('ob_flush')) {
|
||||
@ob_flush();
|
||||
}
|
||||
@flush();
|
||||
};
|
||||
|
||||
try {
|
||||
$result = (new selfserve_studio_graph())->projectPathOutcomes(
|
||||
$departmentId,
|
||||
self::getParametersAsArray(),
|
||||
(int)$user->id,
|
||||
$this->studioPermissions(),
|
||||
static function (array $partial) use ($emit): void {
|
||||
$emit(['type' => 'progress', 'data' => $partial]);
|
||||
}
|
||||
);
|
||||
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes');
|
||||
$emit(['type' => 'complete', 'data' => $result]);
|
||||
} catch (\Throwable $exception) {
|
||||
$emit(['type' => 'error', 'message' => $exception->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}, [
|
||||
'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
|
||||
|
||||
@@ -76,7 +76,8 @@ class guestRoute
|
||||
'name' => (string)$lane->name->value(),
|
||||
'status' => (string)$lane->getLaneStatus()->name,
|
||||
'products' => $lane->getSelfServeLaneProducts(),
|
||||
'machine_available' => !empty($lane->relay_machine_id->value()),
|
||||
'selfserve_enabled' => $lane->isSelfServeEnabled(),
|
||||
'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()),
|
||||
'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null,
|
||||
];
|
||||
}, $department->getLanes());
|
||||
|
||||
@@ -300,7 +300,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$additional_where = null;
|
||||
if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) {
|
||||
$additional_where = '`completed_at` IS NULL';
|
||||
$additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')';
|
||||
}
|
||||
|
||||
$response->success($sessions->listObjectsWithPaginationIfSet(
|
||||
@@ -313,7 +313,7 @@ class moduleSelfServeRoute
|
||||
return [
|
||||
...$session->asArray(),
|
||||
'elapsed_minutes' => $session->getElapsedMinutes(),
|
||||
'open' => $session->completed_at->value() === null,
|
||||
'open' => $session->isOpen(),
|
||||
];
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -75,6 +75,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('/department/selfserve/studio/validate:');
|
||||
expect($content)->toContain('/department/selfserve/studio/simulate:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
|
||||
expect($content)->toContain('/department/selfserve/studio/publish:');
|
||||
expect($content)->toContain('/department/selfserve/studio/rollback:');
|
||||
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
|
||||
@@ -84,7 +85,9 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('SelfserveStudioPathOutcomesRequest:');
|
||||
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
|
||||
expect($content)->toContain('SelfserveStudioPathResult:');
|
||||
expect($content)->toContain('SelfserveStudioPathProgress:');
|
||||
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('runSelfserveStudioGatewayAction');
|
||||
});
|
||||
|
||||
@@ -96,6 +99,9 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi
|
||||
expect($content)->toContain('SelfserveWashSummary:');
|
||||
expect($content)->toContain('MachineButtonPressWebhookResponse:');
|
||||
expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:');
|
||||
expect($content)->toContain('DepartmentLane:');
|
||||
expect($content)->toContain('selfserve_enabled:');
|
||||
expect($content)->toContain('blocked_reason:');
|
||||
expect($content)->toContain('machine_type_id:');
|
||||
expect($content)->toContain('SelfServeLaneMachineRelayStatus:');
|
||||
expect($content)->toContain(' wash_started_at:');
|
||||
|
||||
@@ -49,6 +49,39 @@ it('keeps machine type support wired into lanes, tasks, and conditions routes',
|
||||
->toContain('product');
|
||||
});
|
||||
|
||||
it('wires lane-level self-serve toggles through lane APIs, guest payloads, and edge workspace readiness', function (): void {
|
||||
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
|
||||
$guestRoute = file_get_contents(app_path('routes/guestRoute.php'));
|
||||
$edgeWorkspace = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php'));
|
||||
|
||||
expect($lanesRoute)->not->toBeFalse()
|
||||
->and($lanesRoute)->toContain("'selfserve_enabled'")
|
||||
->and($lanesRoute)->toContain('normalizeSelfServeEnabledValue')
|
||||
->and($lanesRoute)->toContain('disableSelfServeRelaysBestEffort')
|
||||
->and($lanesRoute)->toContain("'lane' => \$created_lane->asArray()")
|
||||
->and($lanesRoute)->toContain("'lane' => \$department_lane->asArray()");
|
||||
|
||||
expect($laneObject)->not->toBeFalse()
|
||||
->and($laneObject)->toContain('public object_property $selfserve_enabled')
|
||||
->and($laneObject)->toContain('public function isSelfServeEnabled(): bool')
|
||||
->and($laneObject)->toContain('public static function normalizeSelfServeEnabledValue')
|
||||
->and($laneObject)->toContain('public static function disableSelfServeRelaysBestEffort')
|
||||
->and($laneObject)->toContain('setMachineRelayStatusHard(false)')
|
||||
->and($laneObject)->toContain('setMachineProgramPickerRelayStatusHard(false)')
|
||||
->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)');
|
||||
|
||||
expect($guestRoute)->not->toBeFalse()
|
||||
->and($guestRoute)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()")
|
||||
->and($guestRoute)->toContain("'machine_available' => \$lane->isSelfServeEnabled() && !empty(\$lane->relay_machine_id->value())");
|
||||
|
||||
expect($edgeWorkspace)->not->toBeFalse()
|
||||
->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()")
|
||||
->and($edgeWorkspace)->toContain("'enabled_lanes' => count(\$enabledLanes)")
|
||||
->and($edgeWorkspace)->toContain("'ready_lanes' => count(\$readyLanes)")
|
||||
->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)");
|
||||
});
|
||||
|
||||
it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void {
|
||||
$configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php'));
|
||||
|
||||
@@ -74,6 +107,8 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/validate');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
|
||||
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
|
||||
@@ -251,6 +286,39 @@ it('wires self-serve session management endpoints and OpenAPI coverage', functio
|
||||
}
|
||||
});
|
||||
|
||||
it('returns authoritative allowed service state from self-serve session summaries', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
|
||||
expect($washFlow)->not->toBeFalse();
|
||||
$start = strpos($washFlow, 'public function getSessionSummary');
|
||||
$end = strpos($washFlow, 'public function getLatestSessionSummary', $start);
|
||||
expect($start)->not->toBeFalse();
|
||||
expect($end)->not->toBeFalse();
|
||||
$summarySource = substr($washFlow, $start, $end - $start);
|
||||
|
||||
expect($summarySource)
|
||||
->toContain('$metadata = is_array($session->metadata_json->value())')
|
||||
->toContain('$allowedServices = $this->normalizeServiceNames(')
|
||||
->toContain('$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices)')
|
||||
->toContain("'allowed_services' => \$allowedServices")
|
||||
->toContain("'machine_available' => \$machineAvailable")
|
||||
->toContain("'all_visible_questions_answered' => \$allVisibleQuestionsAnswered")
|
||||
->toContain("'allowed' => (bool)\$session->allowed->value()");
|
||||
});
|
||||
|
||||
it('filters machine button tasks out of self-serve snapshots when MACHINE is not allowed', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
|
||||
expect($washFlow)->not->toBeFalse()
|
||||
->and($washFlow)->toContain('$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices)')
|
||||
->and($washFlow)->toContain("'tasks' => \$visibleTasks")
|
||||
->and($washFlow)->toContain('protected function filterTasksForAllowedServices')
|
||||
->and($washFlow)->toContain('protected function taskUsesMachineControls')
|
||||
->and($washFlow)->toContain("in_array(selfserve_lane_services::MACHINE->name, \$allowedServices, true)")
|
||||
->and($washFlow)->toContain("\$this->normalizeButtonList(\$task['buttons'] ?? null) !== []")
|
||||
->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null");
|
||||
});
|
||||
|
||||
it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
$sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
|
||||
@@ -311,6 +379,26 @@ it('wires vehicle type override into self-serve preview and synchronization rout
|
||||
expect($washFlow)->toContain("\$session->vehicle_id->set(\$snapshot['vehicle']['id'] ?? null);");
|
||||
});
|
||||
|
||||
it('blocks new self-serve eligibility and session sync for disabled lanes', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
|
||||
|
||||
expect($washFlow)->not->toBeFalse()
|
||||
->and($washFlow)->toContain('if (!$lane->isSelfServeEnabled())')
|
||||
->and($washFlow)->toContain("'allowed_services' => []")
|
||||
->and($washFlow)->toContain("'machine_available' => false")
|
||||
->and($washFlow)->toContain("'allowed' => false")
|
||||
->and($washFlow)->toContain("'blocked_reason' => 'Self-serve is disabled for this lane.'")
|
||||
->and($washFlow)->toContain("'blocking_reasons' => ['LANE_SELFSERVE_DISABLED']")
|
||||
->and($washFlow)->toContain("'disabled_lane' => true")
|
||||
->and($washFlow)->toContain('formatBlockedSessionSummary')
|
||||
->and($washFlow)->toContain('empty($summary[\'session\'][\'id\'])');
|
||||
|
||||
expect($commandTrait)->not->toBeFalse()
|
||||
->and($commandTrait)->toContain('$this->department_lane->isSelfServeEnabled()')
|
||||
->and($commandTrait)->toContain('Self-serve is not enabled for this lane.');
|
||||
});
|
||||
|
||||
it('keeps read-only self-serve preview and summary refreshes from touching relay hardware', function (): void {
|
||||
$vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php'));
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func
|
||||
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at');
|
||||
});
|
||||
|
||||
it('adds lane-level self-serve enablement for existing department lanes', function (): void {
|
||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||
|
||||
expect($bootstrapContent)->not->toBeFalse();
|
||||
expect($bootstrapContent)->toContain("'department_lanes'");
|
||||
expect($bootstrapContent)->toContain("'selfserve_enabled'");
|
||||
expect($bootstrapContent)->toContain('ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id');
|
||||
});
|
||||
|
||||
it('creates canvas-only self-serve studio layout storage', function (): void {
|
||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||
|
||||
|
||||
@@ -461,6 +461,7 @@ it('keeps lane management fields on lane scope nodes', function (): void {
|
||||
'relay_machine_id' => 'M-7',
|
||||
'machine_type_id' => 1001,
|
||||
'dynamic_image_id' => 1,
|
||||
'selfserve_enabled' => true,
|
||||
],
|
||||
],
|
||||
'machine_types' => [['id' => 1001, 'label' => 'Portal']],
|
||||
@@ -481,7 +482,8 @@ it('keeps lane management fields on lane scope nodes', function (): void {
|
||||
expect($laneNode)->not->toBeNull()
|
||||
->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7')
|
||||
->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001)
|
||||
->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1);
|
||||
->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1)
|
||||
->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('routes studio lane graph operations through department_lanes', function (): void {
|
||||
@@ -492,7 +494,11 @@ it('routes studio lane graph operations through department_lanes', function ():
|
||||
->and($source)->toContain('private function createLane')
|
||||
->and($source)->toContain('private function updateLane')
|
||||
->and($source)->toContain('INSERT INTO department_lanes')
|
||||
->and($source)->toContain("'dynamic_image_id'");
|
||||
->and($source)->toContain("'dynamic_image_id'")
|
||||
->and($source)->toContain("'selfserve_enabled'")
|
||||
->and($source)->toContain('normalizeLaneField')
|
||||
->and($source)->toContain('normalizeSelfServeEnabledValue')
|
||||
->and($source)->toContain('disableSelfServeRelaysBestEffort');
|
||||
});
|
||||
|
||||
it('applies saved layout without changing graph semantics', function (): void {
|
||||
@@ -793,7 +799,7 @@ it('truncates path outcome projection when the state cap is reached', function (
|
||||
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
||||
});
|
||||
|
||||
it('returns terminal path results before exhausting the state cap on wide question trees', function (): void {
|
||||
it('returns complete terminal path results for wide question trees and reports progress', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
$answers = [];
|
||||
@@ -840,20 +846,32 @@ it('returns terminal path results before exhausting the state cap on wide questi
|
||||
];
|
||||
};
|
||||
|
||||
$progressEvents = [];
|
||||
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
||||
'max_states' => 2048,
|
||||
'path_sample_limit' => 20,
|
||||
'progress_interval_states' => 512,
|
||||
'progress_callback' => static function (array $partial) use (&$progressEvents): void {
|
||||
$progressEvents[] = [
|
||||
'percent' => (int)($partial['progress']['percent'] ?? 0),
|
||||
'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0),
|
||||
'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0),
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
expect($projection['truncated'])->toBeTrue()
|
||||
->and($projection['summary']['state_count'])->toBe(2048)
|
||||
expect($projection['truncated'])->toBeFalse()
|
||||
->and($projection['summary']['state_count'])->toBe(8191)
|
||||
->and($projection['summary']['question_count'])->toBe(12)
|
||||
->and($projection['summary']['terminal_path_count'])->toBeGreaterThan(0)
|
||||
->and($projection['summary']['outcome_count'])->toBeGreaterThan(0)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(20)
|
||||
->and($projection['paths'])->toHaveCount(20)
|
||||
->and($projection['summary']['terminal_path_count'])->toBe(4096)
|
||||
->and($projection['summary']['outcome_count'])->toBe(2)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(4096)
|
||||
->and($projection['progress']['complete'])->toBeTrue()
|
||||
->and($projection['progress']['percent'])->toBe(100)
|
||||
->and($projection['paths'])->toHaveCount(4096)
|
||||
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
||||
->and($projection['paths'][0]['result'])->toBe('Allowed');
|
||||
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
||||
->and($progressEvents)->not->toBeEmpty()
|
||||
->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0)
|
||||
->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('resolves simulator gateway service bindings from lane relay slots', function (): void {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use classes\object_property;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
|
||||
app_require('classes/object_property.php');
|
||||
app_require('objects/selfserve_wash_sessions_o.php');
|
||||
|
||||
function selfserve_session_property(string $column, string $type, mixed $value): object_property
|
||||
{
|
||||
$property = new object_property('selfserve_wash_sessions', -1, $column, $type);
|
||||
$property->set($value);
|
||||
|
||||
return $property;
|
||||
}
|
||||
|
||||
function selfserve_session_harness(
|
||||
string $status,
|
||||
?string $completedAt,
|
||||
?string $washStartedAt = '2026-04-28 10:00:00',
|
||||
?string $machineStartTriggeredAt = null
|
||||
): selfserve_wash_sessions_o {
|
||||
$session = (new ReflectionClass(selfserve_wash_sessions_o::class))->newInstanceWithoutConstructor();
|
||||
$session->status = selfserve_session_property('status', 'string', $status);
|
||||
$session->completed_at = selfserve_session_property('completed_at', 'datetime', $completedAt);
|
||||
$session->wash_started_at = selfserve_session_property('wash_started_at', 'datetime', $washStartedAt);
|
||||
$session->machine_start_triggered_at = selfserve_session_property('machine_start_triggered_at', 'datetime', $machineStartTriggeredAt);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
it('treats terminal self-serve wash session statuses as closed', function (): void {
|
||||
expect(selfserve_wash_sessions_o::isTerminalStatus('COMPLETED'))->toBeTrue()
|
||||
->and(selfserve_wash_sessions_o::isTerminalStatus('FORCE_STOPPED'))->toBeTrue()
|
||||
->and(selfserve_wash_sessions_o::isTerminalStatus('MACHINE_STARTED'))->toBeFalse()
|
||||
->and(selfserve_wash_sessions_o::terminalStatusSqlList())->toBe("'COMPLETED','FORCE_STOPPED'");
|
||||
|
||||
expect(selfserve_session_harness('MACHINE_STARTED', null)->isOpen())->toBeTrue()
|
||||
->and(selfserve_session_harness('COMPLETED', null)->isOpen())->toBeFalse()
|
||||
->and(selfserve_session_harness('MACHINE_STARTED', '2026-04-28 10:30:00')->isOpen())->toBeFalse();
|
||||
});
|
||||
|
||||
it('freezes elapsed self-serve wash minutes at completion time', function (): void {
|
||||
expect(selfserve_session_harness('COMPLETED', '2026-04-28 10:42:00')->getElapsedMinutes())->toBe(42)
|
||||
->and(selfserve_session_harness('COMPLETED', '2026-04-28 10:35:00', null, '2026-04-28 10:05:00')->getElapsedMinutes())->toBe(30)
|
||||
->and(selfserve_session_harness('COMPLETED', '2026-04-28 09:59:00')->getElapsedMinutes())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user