Introduce selfserve_studio_action_runner and related classes for configurable Studio action workflows
- Added `selfserve_studio_action_runner` to manage Studio action execution, including conditional validation, retry mechanisms, and operation dispatching. - Introduced `selfserve_studio_actions` to define action constants, normalize configurations, and validate operations and policies. - Updated `selfserve_config_versioning` to support action nodes, including validation hooks, schema migration normalization, and legacy action parsing. - Enhanced `SelfserveStudioGraphTest` and `SelfserveStudioDebugPayload` tests to validate action serialization and runtime signal processing. - Added test cases for event-driven Studio actions and non-blocking configuration warnings.
This commit is contained in:
@@ -10,9 +10,11 @@ require_once WD . '/objects/department_selfserve_questions_o.php';
|
||||
require_once WD . '/objects/department_selfserve_tasks_o.php';
|
||||
require_once WD . '/objects/selfserve_config_versions_o.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\helpers\selfserve_task_gate_type;
|
||||
use modules\selfserve\classes\selfserve_studio_actions;
|
||||
use objects\departments_o;
|
||||
use objects\department_selfserve_condition_rules_o;
|
||||
use objects\department_selfserve_conditions_o;
|
||||
@@ -287,6 +289,7 @@ class selfserve_config_versioning
|
||||
'conditions' => $conditions,
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => [],
|
||||
'snapshot_meta' => [
|
||||
'captured_at' => date('c'),
|
||||
'source' => 'legacy_tables',
|
||||
@@ -331,6 +334,7 @@ class selfserve_config_versioning
|
||||
'conditions' => array_values($conditions),
|
||||
'rules' => [],
|
||||
'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])),
|
||||
'actions' => array_values((array)($legacyConfig['actions'] ?? [])),
|
||||
'v2_meta' => [
|
||||
'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1),
|
||||
'migrated_at' => date('c'),
|
||||
@@ -363,6 +367,7 @@ class selfserve_config_versioning
|
||||
$conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
|
||||
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
|
||||
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
|
||||
$actions = is_array($config['actions'] ?? null) ? $config['actions'] : [];
|
||||
|
||||
$questionIds = [];
|
||||
foreach ($questions as $question) {
|
||||
@@ -448,6 +453,7 @@ class selfserve_config_versioning
|
||||
'conditions' => count($conditions),
|
||||
'rules' => count($rules),
|
||||
'tasks' => count($tasks),
|
||||
'actions' => count($actions),
|
||||
],
|
||||
'validated_at' => date('c'),
|
||||
];
|
||||
@@ -473,6 +479,7 @@ class selfserve_config_versioning
|
||||
$questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : [];
|
||||
$conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : [];
|
||||
$tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : [];
|
||||
$actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : [];
|
||||
|
||||
$questionIds = [];
|
||||
foreach ($questions as $question) {
|
||||
@@ -569,6 +576,58 @@ class selfserve_config_versioning
|
||||
}
|
||||
}
|
||||
|
||||
$actionIds = [];
|
||||
foreach ($actions as $action) {
|
||||
if (!is_array($action)) {
|
||||
$errors[] = 'Action has invalid payload.';
|
||||
continue;
|
||||
}
|
||||
$actionId = (int)($action['id'] ?? 0);
|
||||
if ($actionId <= 0) {
|
||||
$errors[] = 'Action without valid id.';
|
||||
continue;
|
||||
}
|
||||
if (isset($actionIds[$actionId])) {
|
||||
$errors[] = 'Duplicate action id ' . $actionId . '.';
|
||||
}
|
||||
$actionIds[$actionId] = true;
|
||||
|
||||
$event = strtolower(trim((string)($action['event'] ?? '')));
|
||||
if (!in_array($event, selfserve_studio_actions::events(), true)) {
|
||||
$errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.';
|
||||
}
|
||||
|
||||
$washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)));
|
||||
if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) {
|
||||
$errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.';
|
||||
}
|
||||
if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) {
|
||||
$warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.';
|
||||
}
|
||||
|
||||
$operation = strtolower(trim((string)($action['operation'] ?? '')));
|
||||
if (!in_array($operation, selfserve_studio_actions::operations(), true)) {
|
||||
$errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.';
|
||||
}
|
||||
if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) {
|
||||
$errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.';
|
||||
}
|
||||
|
||||
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
|
||||
$failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE)));
|
||||
if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) {
|
||||
$errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.';
|
||||
}
|
||||
|
||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||
if ($conditionId !== null) {
|
||||
$usedConditionIds[$conditionId] = true;
|
||||
if (!isset($conditionIds[$conditionId])) {
|
||||
$errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($usedConditionIds as $conditionId => $_used) {
|
||||
if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) {
|
||||
$errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.';
|
||||
@@ -589,6 +648,7 @@ class selfserve_config_versioning
|
||||
'conditions' => count($conditions),
|
||||
'rules' => 0,
|
||||
'tasks' => count($tasks),
|
||||
'actions' => count($actions),
|
||||
],
|
||||
'validated_at' => date('c'),
|
||||
];
|
||||
@@ -880,6 +940,10 @@ class selfserve_config_versioning
|
||||
}, (array)($config['conditions'] ?? [])));
|
||||
$config['rules'] = [];
|
||||
$config['tasks'] = array_values((array)($config['tasks'] ?? []));
|
||||
$config['actions'] = array_values(array_map(
|
||||
static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []),
|
||||
(array)($config['actions'] ?? [])
|
||||
));
|
||||
$config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : [];
|
||||
$config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config);
|
||||
return $config;
|
||||
@@ -892,7 +956,7 @@ class selfserve_config_versioning
|
||||
protected function nextIdsForConfig(array $config): array
|
||||
{
|
||||
$next = [];
|
||||
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task'] as $key => $name) {
|
||||
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) {
|
||||
$max = 0;
|
||||
foreach ((array)($config[$key] ?? []) as $row) {
|
||||
if (is_array($row)) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\classes;
|
||||
|
||||
require_once WD . '/classes/selfserve.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\helpers\selfserve_lane_port;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
|
||||
class selfserve_studio_action_runner
|
||||
{
|
||||
/**
|
||||
* @param int|object $lane Lane id or selfserve lane instance.
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function executeForLaneEvent(int|object $lane, string $event, string $washMode = selfserve_studio_actions::MODE_BOTH, array $context = []): array
|
||||
{
|
||||
$laneObject = is_int($lane) ? (new selfserve())->lane($lane) : $lane;
|
||||
$departmentId = $this->departmentIdForLane($laneObject);
|
||||
if ($departmentId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$published = (new selfserve_config_versioning())->getPublishedV2Config($departmentId);
|
||||
$config = is_array($published['config'] ?? null) ? (array)$published['config'] : [];
|
||||
$actions = $this->matchingActions($config, $laneObject, $event, $washMode, $context);
|
||||
$results = [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$results[] = $this->executeAction($laneObject, $action);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @param object $lane
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function matchingActions(array $config, object $lane, string $event, string $washMode, array $context = []): array
|
||||
{
|
||||
$event = strtolower(trim($event));
|
||||
$washMode = strtolower(trim($washMode));
|
||||
$laneId = (int)($lane->id ?? 0);
|
||||
$departmentId = $this->departmentIdForLane($lane);
|
||||
$machineTypeId = $this->machineTypeIdForLane($lane);
|
||||
$productId = $this->nullableInt($context['product'] ?? $context['product_id'] ?? $context['vehicle_type_id'] ?? null);
|
||||
$conditionResults = is_array($context['condition_results'] ?? null) ? (array)$context['condition_results'] : null;
|
||||
|
||||
$actions = [];
|
||||
foreach ((array)($config['actions'] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$action = selfserve_studio_actions::normalize((array)$row);
|
||||
if (!$action['enabled'] || $action['event'] !== $event) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $washMode], true)) {
|
||||
continue;
|
||||
}
|
||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||
continue;
|
||||
}
|
||||
if ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||
continue;
|
||||
}
|
||||
if ((int)$action['product'] !== 0 && ($productId === null || (int)$action['product'] !== $productId)) {
|
||||
continue;
|
||||
}
|
||||
if ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== $machineTypeId) {
|
||||
continue;
|
||||
}
|
||||
$conditionId = $action['condition_id'];
|
||||
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||
continue;
|
||||
}
|
||||
$actions[] = $action;
|
||||
}
|
||||
|
||||
usort($actions, static fn(array $left, array $right): int => ((int)$left['order_priority'] <=> (int)$right['order_priority']) ?: ((int)$left['id'] <=> (int)$right['id']));
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $lane
|
||||
* @param array<string,mixed> $action
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function executeAction(object $lane, array $action): array
|
||||
{
|
||||
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
|
||||
$attempts = max(1, min(4, ((int)($options['retry_count'] ?? 0)) + 1));
|
||||
$delayMs = max(0, min(10000, (int)($options['delay_ms'] ?? 0)));
|
||||
$lastError = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
|
||||
try {
|
||||
if ($delayMs > 0) {
|
||||
usleep($delayMs * 1000);
|
||||
}
|
||||
$this->dispatchAction($lane, $action);
|
||||
return [
|
||||
'action_id' => (int)$action['id'],
|
||||
'name' => (string)$action['name'],
|
||||
'event' => (string)$action['event'],
|
||||
'operation' => (string)$action['operation'],
|
||||
'status' => 'sent',
|
||||
'attempts' => $attempt,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = $e;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'action_id' => (int)$action['id'],
|
||||
'name' => (string)$action['name'],
|
||||
'event' => (string)$action['event'],
|
||||
'operation' => (string)$action['operation'],
|
||||
'status' => 'failed',
|
||||
'attempts' => $attempts,
|
||||
'error' => $lastError?->getMessage(),
|
||||
];
|
||||
|
||||
if (($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE) === selfserve_studio_actions::FAILURE_BLOCK) {
|
||||
throw new \RuntimeException('Self-serve studio action failed: ' . (string)$action['name'], 0, $lastError);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $lane
|
||||
* @param array<string,mixed> $action
|
||||
*/
|
||||
private function dispatchAction(object $lane, array $action): void
|
||||
{
|
||||
$toggleAfter = $this->nullableInt($action['options']['toggle_after_seconds'] ?? null);
|
||||
switch ((string)$action['operation']) {
|
||||
case selfserve_studio_actions::OP_OPEN_PROPERTY_ENTRANCE_GATE:
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
|
||||
return;
|
||||
case selfserve_studio_actions::OP_OPEN_PROPERTY_EXIT_GATE:
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments());
|
||||
return;
|
||||
case selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT:
|
||||
$lane->open(selfserve_lane_port::ENTRANCE, $toggleAfter);
|
||||
return;
|
||||
case selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT:
|
||||
$lane->open(selfserve_lane_port::EXIT, $toggleAfter);
|
||||
return;
|
||||
case selfserve_studio_actions::OP_SET_CLEANER_RELAY:
|
||||
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, (bool)$action['relay_state']);
|
||||
return;
|
||||
case selfserve_studio_actions::OP_SET_MACHINE_RELAY:
|
||||
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE, (bool)$action['relay_state']);
|
||||
return;
|
||||
case selfserve_studio_actions::OP_SET_PROGRAM_PICKER_RELAY:
|
||||
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, (bool)$action['relay_state']);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unsupported self-serve studio action operation: ' . (string)$action['operation']);
|
||||
}
|
||||
|
||||
private function departmentIdForLane(object $lane): int
|
||||
{
|
||||
try {
|
||||
return empty($lane->department_lane) || empty($lane->department_lane->department)
|
||||
? 0
|
||||
: (int)$lane->department_lane->department->value();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function machineTypeIdForLane(object $lane): int
|
||||
{
|
||||
try {
|
||||
return empty($lane->department_lane) || empty($lane->department_lane->machine_type_id)
|
||||
? 0
|
||||
: (int)$lane->department_lane->machine_type_id->value();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function nullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '' || $value === 'null') {
|
||||
return null;
|
||||
}
|
||||
$intValue = (int)$value;
|
||||
return $intValue <= 0 ? null : $intValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\classes;
|
||||
|
||||
class selfserve_studio_actions
|
||||
{
|
||||
public const EVENT_WASH_START_COMMAND = 'wash_start_command';
|
||||
public const EVENT_WASH_STOP_COMMAND = 'wash_stop_command';
|
||||
public const EVENT_MACHINE_START_TRIGGERED = 'machine_start_triggered';
|
||||
|
||||
public const MODE_MANUAL = 'manual';
|
||||
public const MODE_MACHINE = 'machine';
|
||||
public const MODE_BOTH = 'both';
|
||||
|
||||
public const OP_OPEN_PROPERTY_ENTRANCE_GATE = 'open_property_entrance_gate';
|
||||
public const OP_OPEN_PROPERTY_EXIT_GATE = 'open_property_exit_gate';
|
||||
public const OP_OPEN_LANE_ENTRANCE_PORT = 'open_lane_entrance_port';
|
||||
public const OP_OPEN_LANE_EXIT_PORT = 'open_lane_exit_port';
|
||||
public const OP_SET_CLEANER_RELAY = 'set_cleaner_relay';
|
||||
public const OP_SET_MACHINE_RELAY = 'set_machine_relay';
|
||||
public const OP_SET_PROGRAM_PICKER_RELAY = 'set_program_picker_relay';
|
||||
|
||||
public const FAILURE_CONTINUE = 'continue';
|
||||
public const FAILURE_BLOCK = 'block';
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function events(): array
|
||||
{
|
||||
return [
|
||||
self::EVENT_WASH_START_COMMAND,
|
||||
self::EVENT_WASH_STOP_COMMAND,
|
||||
self::EVENT_MACHINE_START_TRIGGERED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function washModes(): array
|
||||
{
|
||||
return [
|
||||
self::MODE_MANUAL,
|
||||
self::MODE_MACHINE,
|
||||
self::MODE_BOTH,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function operations(): array
|
||||
{
|
||||
return [
|
||||
self::OP_OPEN_PROPERTY_ENTRANCE_GATE,
|
||||
self::OP_OPEN_PROPERTY_EXIT_GATE,
|
||||
self::OP_OPEN_LANE_ENTRANCE_PORT,
|
||||
self::OP_OPEN_LANE_EXIT_PORT,
|
||||
self::OP_SET_CLEANER_RELAY,
|
||||
self::OP_SET_MACHINE_RELAY,
|
||||
self::OP_SET_PROGRAM_PICKER_RELAY,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function failurePolicies(): array
|
||||
{
|
||||
return [
|
||||
self::FAILURE_CONTINUE,
|
||||
self::FAILURE_BLOCK,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isRelayOperation(string $operation): bool
|
||||
{
|
||||
return in_array($operation, [
|
||||
self::OP_SET_CLEANER_RELAY,
|
||||
self::OP_SET_MACHINE_RELAY,
|
||||
self::OP_SET_PROGRAM_PICKER_RELAY,
|
||||
], true);
|
||||
}
|
||||
|
||||
public static function isOpenOperation(string $operation): bool
|
||||
{
|
||||
return in_array($operation, [
|
||||
self::OP_OPEN_PROPERTY_ENTRANCE_GATE,
|
||||
self::OP_OPEN_PROPERTY_EXIT_GATE,
|
||||
self::OP_OPEN_LANE_ENTRANCE_PORT,
|
||||
self::OP_OPEN_LANE_EXIT_PORT,
|
||||
], true);
|
||||
}
|
||||
|
||||
public static function relayRoleForOperation(string $operation): string
|
||||
{
|
||||
return match ($operation) {
|
||||
self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'PROPERTY_ENTRANCE',
|
||||
self::OP_OPEN_PROPERTY_EXIT_GATE => 'PROPERTY_EXIT',
|
||||
self::OP_OPEN_LANE_ENTRANCE_PORT => 'ENTRY',
|
||||
self::OP_OPEN_LANE_EXIT_PORT => 'EXIT',
|
||||
self::OP_SET_CLEANER_RELAY => 'CLEANER',
|
||||
self::OP_SET_MACHINE_RELAY => 'MACHINE',
|
||||
self::OP_SET_PROGRAM_PICKER_RELAY => 'PROGRAM_PICKER',
|
||||
default => 'ACTION',
|
||||
};
|
||||
}
|
||||
|
||||
public static function eventLabel(string $event): string
|
||||
{
|
||||
return match ($event) {
|
||||
self::EVENT_WASH_START_COMMAND => 'On self-serve wash start command',
|
||||
self::EVENT_WASH_STOP_COMMAND => 'On self-serve wash stop command',
|
||||
self::EVENT_MACHINE_START_TRIGGERED => 'On self-serve wash machine start triggered',
|
||||
default => 'On action event',
|
||||
};
|
||||
}
|
||||
|
||||
public static function operationLabel(string $operation, ?bool $relayState = null): string
|
||||
{
|
||||
$state = $relayState === null ? '' : ($relayState ? 'ON ' : 'OFF ');
|
||||
return match ($operation) {
|
||||
self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'Open property entrance gate',
|
||||
self::OP_OPEN_PROPERTY_EXIT_GATE => 'Open property exit gate',
|
||||
self::OP_OPEN_LANE_ENTRANCE_PORT => 'Open lane entrance port',
|
||||
self::OP_OPEN_LANE_EXIT_PORT => 'Open lane exit port',
|
||||
self::OP_SET_CLEANER_RELAY => 'Turn ' . $state . 'CLEANER',
|
||||
self::OP_SET_MACHINE_RELAY => 'Turn ' . $state . 'MACHINE',
|
||||
self::OP_SET_PROGRAM_PICKER_RELAY => 'Turn ' . $state . 'PROGRAM PICKER',
|
||||
default => 'Action',
|
||||
};
|
||||
}
|
||||
|
||||
public static function runtimeStageForEvent(string $event): string
|
||||
{
|
||||
return match ($event) {
|
||||
self::EVENT_WASH_START_COMMAND => 'start',
|
||||
self::EVENT_WASH_STOP_COMMAND => 'stop',
|
||||
self::EVENT_MACHINE_START_TRIGGERED => 'machine_start',
|
||||
default => 'action',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $action
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function normalize(array $action): array
|
||||
{
|
||||
$event = strtolower(trim((string)($action['event'] ?? self::EVENT_WASH_START_COMMAND)));
|
||||
if (!in_array($event, self::events(), true)) {
|
||||
$event = self::EVENT_WASH_START_COMMAND;
|
||||
}
|
||||
|
||||
$operation = strtolower(trim((string)($action['operation'] ?? self::OP_OPEN_LANE_ENTRANCE_PORT)));
|
||||
if (!in_array($operation, self::operations(), true)) {
|
||||
$operation = self::OP_OPEN_LANE_ENTRANCE_PORT;
|
||||
}
|
||||
|
||||
$washMode = strtolower(trim((string)($action['wash_mode'] ?? self::MODE_BOTH)));
|
||||
if (!in_array($washMode, self::washModes(), true)) {
|
||||
$washMode = self::MODE_BOTH;
|
||||
}
|
||||
|
||||
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
|
||||
$failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? self::FAILURE_CONTINUE)));
|
||||
if (!in_array($failurePolicy, self::failurePolicies(), true)) {
|
||||
$failurePolicy = self::FAILURE_CONTINUE;
|
||||
}
|
||||
|
||||
$relayState = array_key_exists('relay_state', $action)
|
||||
? filter_var($action['relay_state'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)
|
||||
: null;
|
||||
if (self::isRelayOperation($operation) && $relayState === null) {
|
||||
$relayState = true;
|
||||
}
|
||||
|
||||
$label = trim((string)($action['name'] ?? $action['label'] ?? ''));
|
||||
if ($label === '') {
|
||||
$label = self::operationLabel($operation, $relayState);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)($action['id'] ?? 0),
|
||||
'department' => (int)($action['department'] ?? 0),
|
||||
'lane' => (int)($action['lane'] ?? 0),
|
||||
'product' => (int)($action['product'] ?? 0),
|
||||
'machine_type_id' => self::nullableInt($action['machine_type_id'] ?? null),
|
||||
'condition_id' => self::nullableInt($action['condition_id'] ?? null),
|
||||
'name' => $label,
|
||||
'description' => (string)($action['description'] ?? ''),
|
||||
'event' => $event,
|
||||
'wash_mode' => $washMode,
|
||||
'operation' => $operation,
|
||||
'relay_state' => $relayState,
|
||||
'enabled' => filter_var($action['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
'order_priority' => (int)($action['order_priority'] ?? 0),
|
||||
'options' => [
|
||||
'delay_ms' => max(0, (int)($options['delay_ms'] ?? 0)),
|
||||
'toggle_after_seconds' => self::nullableInt($options['toggle_after_seconds'] ?? null),
|
||||
'retry_count' => max(0, min(3, (int)($options['retry_count'] ?? 0))),
|
||||
'failure_policy' => $failurePolicy,
|
||||
'record_event' => filter_var($options['record_event'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function nullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '' || $value === 'null') {
|
||||
return null;
|
||||
}
|
||||
$intValue = (int)$value;
|
||||
return $intValue <= 0 ? null : $intValue;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_virtual_hardware.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
|
||||
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
|
||||
require_once WD . '/objects/department_selfserve_conditions_o.php';
|
||||
@@ -36,6 +37,7 @@ use classes\edge_gateway_operation_service;
|
||||
use classes\edge_gateway_registry_service;
|
||||
use classes\edge_gateway_view_service;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\classes\selfserve_studio_actions;
|
||||
use modules\selfserve\helpers\selfserve_task_gate_type;
|
||||
use objects\selfserve_config_versions_o;
|
||||
|
||||
@@ -264,6 +266,51 @@ class selfserve_studio_graph
|
||||
}
|
||||
}
|
||||
|
||||
$actionsByEvent = [];
|
||||
$actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']);
|
||||
foreach ($actionRows as $index => $action) {
|
||||
$normalizedAction = selfserve_studio_actions::normalize($action);
|
||||
$id = (int)($normalizedAction['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$actionsByEvent[$normalizedAction['event']][] = $normalizedAction;
|
||||
$nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [
|
||||
'object_id' => $id,
|
||||
'raw' => $normalizedAction,
|
||||
'scope' => $this->scopeForRow($normalizedAction, $lookups),
|
||||
'subtitle' => $this->actionSubtitle($normalizedAction, $lookups),
|
||||
'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']),
|
||||
'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']),
|
||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']),
|
||||
], 1360, 160 + ($index * 130));
|
||||
|
||||
$eventSource = match ((string)$normalizedAction['event']) {
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start',
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish',
|
||||
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible',
|
||||
default => 'checkpoint:start',
|
||||
};
|
||||
$edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']);
|
||||
|
||||
$conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null);
|
||||
if ($conditionId !== null) {
|
||||
$edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows');
|
||||
}
|
||||
$this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction);
|
||||
}
|
||||
|
||||
foreach ($actionsByEvent as $event => $actions) {
|
||||
$orderedActions = $this->sortedRows($actions, ['order_priority', 'id']);
|
||||
for ($i = 1; $i < count($orderedActions); $i++) {
|
||||
$sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0);
|
||||
$targetId = (int)($orderedActions[$i]['id'] ?? 0);
|
||||
if ($sourceId > 0 && $targetId > 0) {
|
||||
$edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
|
||||
$this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
|
||||
|
||||
@@ -678,6 +725,7 @@ class selfserve_studio_graph
|
||||
'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'),
|
||||
'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'),
|
||||
'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'),
|
||||
'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'),
|
||||
'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
|
||||
'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])),
|
||||
'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
|
||||
@@ -761,7 +809,7 @@ class selfserve_studio_graph
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['conditions', 'questions', 'tasks'] as $section) {
|
||||
foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) {
|
||||
foreach ((array)($config[$section] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
@@ -1039,6 +1087,29 @@ class selfserve_studio_graph
|
||||
'buttons' => [],
|
||||
'dynamic_images_vehicle_type' => null,
|
||||
],
|
||||
'action' => [
|
||||
'id' => $id,
|
||||
'department' => $departmentId,
|
||||
'lane' => 0,
|
||||
'product' => 0,
|
||||
'machine_type_id' => null,
|
||||
'condition_id' => null,
|
||||
'name' => 'Open lane entrance port',
|
||||
'description' => '',
|
||||
'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
'wash_mode' => selfserve_studio_actions::MODE_BOTH,
|
||||
'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT,
|
||||
'relay_state' => null,
|
||||
'enabled' => true,
|
||||
'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])),
|
||||
'options' => [
|
||||
'delay_ms' => 0,
|
||||
'toggle_after_seconds' => 1,
|
||||
'retry_count' => 0,
|
||||
'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE,
|
||||
'record_event' => true,
|
||||
],
|
||||
],
|
||||
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
|
||||
};
|
||||
|
||||
@@ -1120,6 +1191,13 @@ class selfserve_studio_graph
|
||||
}
|
||||
}
|
||||
unset($task);
|
||||
$actionRows = &$this->configRows($config, 'action');
|
||||
foreach ($actionRows as &$action) {
|
||||
if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) {
|
||||
$action['condition_id'] = null;
|
||||
}
|
||||
}
|
||||
unset($action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,8 +1207,8 @@ class selfserve_studio_graph
|
||||
*/
|
||||
private function applyConfigReorder(array &$config, string $entity, array $items): void
|
||||
{
|
||||
if (!in_array($entity, ['question', 'task'], true)) {
|
||||
throw new \RuntimeException('Only questions and tasks can be reordered.');
|
||||
if (!in_array($entity, ['question', 'task', 'action'], true)) {
|
||||
throw new \RuntimeException('Only questions, tasks, and actions can be reordered.');
|
||||
}
|
||||
|
||||
$priorities = [];
|
||||
@@ -1217,6 +1295,15 @@ class selfserve_studio_graph
|
||||
$rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($sourceType === 'condition' && $targetType === 'action') {
|
||||
$rows = &$this->configRows($config, 'action');
|
||||
$index = $this->configRowIndex($rows, $targetId);
|
||||
if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
|
||||
$rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1288,6 +1375,7 @@ class selfserve_studio_graph
|
||||
'question' => 'questions',
|
||||
'condition' => 'conditions',
|
||||
'task' => 'tasks',
|
||||
'action' => 'actions',
|
||||
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
|
||||
};
|
||||
if (!isset($config[$key]) || !is_array($config[$key])) {
|
||||
@@ -1315,7 +1403,7 @@ class selfserve_studio_graph
|
||||
private function allocateConfigId(array &$config, string $entity): int
|
||||
{
|
||||
$name = match ($entity) {
|
||||
'question', 'condition', 'task' => $entity,
|
||||
'question', 'condition', 'task', 'action' => $entity,
|
||||
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
|
||||
};
|
||||
if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
|
||||
@@ -1362,6 +1450,8 @@ class selfserve_studio_graph
|
||||
$data['name'] = $data['label'];
|
||||
} elseif ($entity === 'task' && !array_key_exists('task', $data)) {
|
||||
$data['task'] = $data['label'];
|
||||
} elseif ($entity === 'action' && !array_key_exists('name', $data)) {
|
||||
$data['name'] = $data['label'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1369,6 +1459,7 @@ class selfserve_studio_graph
|
||||
'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
|
||||
'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'],
|
||||
'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
|
||||
'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'],
|
||||
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
|
||||
};
|
||||
|
||||
@@ -1389,6 +1480,9 @@ class selfserve_studio_graph
|
||||
$row['condition_id'] = null;
|
||||
}
|
||||
}
|
||||
if ($entity === 'action') {
|
||||
$row = selfserve_studio_actions::normalize($row);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
@@ -1398,18 +1492,24 @@ class selfserve_studio_graph
|
||||
if ($field === 'expression') {
|
||||
return $this->normalizeExpressionNode($value);
|
||||
}
|
||||
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) {
|
||||
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) {
|
||||
return $this->nullableInt($value);
|
||||
}
|
||||
if (in_array($field, ['department', 'lane', 'product', 'order_priority'], true)) {
|
||||
if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) {
|
||||
return (int)$value;
|
||||
}
|
||||
if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) {
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
}
|
||||
if ($field === 'services') {
|
||||
return $this->normalizeServiceList($value);
|
||||
}
|
||||
if ($field === 'buttons') {
|
||||
return $this->normalizeArrayPayload($value);
|
||||
}
|
||||
if ($field === 'options') {
|
||||
return is_array($value) ? (array)$value : [];
|
||||
}
|
||||
if ($field === 'gate_type') {
|
||||
return $this->normalizeGateType((string)$value);
|
||||
}
|
||||
@@ -2281,6 +2381,7 @@ class selfserve_studio_graph
|
||||
'question' => 'question',
|
||||
'condition', 'rule' => 'name',
|
||||
'task' => 'task',
|
||||
'action' => 'name',
|
||||
default => 'label',
|
||||
};
|
||||
$label = trim((string)($row[$field] ?? ''));
|
||||
@@ -2290,6 +2391,24 @@ class selfserve_studio_graph
|
||||
return $this->labelFor($entity . 's', $id, $lookups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $action
|
||||
* @param array<string,mixed> $lookups
|
||||
*/
|
||||
private function actionSubtitle(array $action, array $lookups): string
|
||||
{
|
||||
$parts = [
|
||||
selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')),
|
||||
ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)),
|
||||
selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null),
|
||||
];
|
||||
$scope = $this->scopeLabel($action, $lookups);
|
||||
if ($scope !== 'Shared scope') {
|
||||
$parts[] = $scope;
|
||||
}
|
||||
return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lookups
|
||||
*/
|
||||
@@ -2760,6 +2879,7 @@ class selfserve_studio_graph
|
||||
'conditions' => 'condition',
|
||||
'rules' => 'rule',
|
||||
'tasks' => 'task',
|
||||
'actions' => 'action',
|
||||
'lanes' => 'lane',
|
||||
default => $entity,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ 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_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
|
||||
@@ -35,6 +37,8 @@ use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use modules\selfserve\classes\selfserve_studio_action_runner;
|
||||
use modules\selfserve\classes\selfserve_studio_actions;
|
||||
use modules\selfserve\helpers\selfserve_task_gate_type;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
@@ -173,7 +177,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$lane->setWashStartTime(time());
|
||||
}
|
||||
$washStartedAt = (int)$lane->getWashStartTime();
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
$session->markMachineStartTriggered(
|
||||
$washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null
|
||||
@@ -183,6 +186,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
]);
|
||||
(new selfserve_studio_action_runner())->executeForLaneEvent(
|
||||
$lane,
|
||||
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
|
||||
selfserve_studio_actions::MODE_MACHINE,
|
||||
[
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
'session_id' => (int)$session->id,
|
||||
'source_payload' => $payload,
|
||||
]
|
||||
);
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
@@ -595,6 +611,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'conditions' => $conditions,
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [],
|
||||
'visible_answers' => $visibleAnswers,
|
||||
],
|
||||
];
|
||||
@@ -699,11 +716,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups);
|
||||
$conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups);
|
||||
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups);
|
||||
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
||||
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
||||
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
||||
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
||||
$annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $hardware, (array)($options['graph'] ?? []));
|
||||
$annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? []));
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
@@ -729,6 +747,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'conditions' => $conditions,
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => $actions,
|
||||
'hardware' => $hardware,
|
||||
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||
'graph_annotations' => $annotations,
|
||||
@@ -1020,14 +1039,117 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $snapshot
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<string,mixed> $lookups
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array
|
||||
{
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['condition_results']
|
||||
: [];
|
||||
$lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : [];
|
||||
$laneId = (int)($lane['id'] ?? 0);
|
||||
$departmentId = (int)($lane['department'] ?? 0);
|
||||
$vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null);
|
||||
$machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null);
|
||||
$eventModes = [
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot),
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot),
|
||||
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE,
|
||||
];
|
||||
$items = [];
|
||||
|
||||
foreach ($actions as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$action = selfserve_studio_actions::normalize((array)$row);
|
||||
$actionId = (int)($action['id'] ?? 0);
|
||||
if ($actionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH;
|
||||
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
||||
$scopeMatches = true;
|
||||
$scopeReason = null;
|
||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action department scope does not match the simulated lane.';
|
||||
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
||||
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
||||
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
||||
}
|
||||
|
||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
||||
$enabled = (bool)($action['enabled'] ?? true);
|
||||
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
||||
$reason = 'Action would run for this simulator event.';
|
||||
if (!$enabled) {
|
||||
$reason = 'Action is disabled.';
|
||||
} elseif (!$modeMatches) {
|
||||
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
||||
} elseif (!$scopeMatches) {
|
||||
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
||||
} elseif (!$conditionSatisfied) {
|
||||
$reason = 'Action condition gate did not pass.';
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'id' => $actionId,
|
||||
'node_id' => 'action:' . $actionId,
|
||||
'label' => (string)$action['name'],
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'skipped',
|
||||
'reason' => $reason,
|
||||
'event' => (string)$action['event'],
|
||||
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
||||
'wash_mode' => (string)$action['wash_mode'],
|
||||
'simulated_wash_mode' => $expectedMode,
|
||||
'operation' => (string)$action['operation'],
|
||||
'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']),
|
||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
||||
'relay_state' => $action['relay_state'],
|
||||
'condition_id' => $conditionId,
|
||||
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
|
||||
'condition_satisfied' => $conditionSatisfied,
|
||||
'scope' => [
|
||||
'department' => (int)$action['department'],
|
||||
'lane' => (int)$action['lane'],
|
||||
'product' => (int)$action['product'],
|
||||
'machine_type_id' => $action['machine_type_id'],
|
||||
],
|
||||
'options' => (array)$action['options'],
|
||||
'order_priority' => (int)$action['order_priority'],
|
||||
'raw' => $action,
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event'])
|
||||
?: ((int)$a['order_priority'] <=> (int)$b['order_priority'])
|
||||
?: ((int)$a['id'] <=> (int)$b['id']));
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $snapshot
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<string,mixed> $gatewayWorkspace
|
||||
* @param array<string,mixed> $lookups
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups): array
|
||||
protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array
|
||||
{
|
||||
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
||||
$allowedServices = (array)($snapshot['allowed_services'] ?? []);
|
||||
@@ -1051,7 +1173,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
}
|
||||
$signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots);
|
||||
$signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions);
|
||||
|
||||
$dryRunOperations = [];
|
||||
if (($snapshot['allowed'] ?? false) === true) {
|
||||
@@ -1089,9 +1211,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
* @param array<string,mixed> $gatewayWorkspace
|
||||
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
|
||||
* @param array<int,array<string,mixed>> $laneRelaySlots
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots): array
|
||||
protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array
|
||||
{
|
||||
$timeline = [];
|
||||
$sequence = 1;
|
||||
@@ -1115,6 +1238,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
null
|
||||
);
|
||||
|
||||
$this->appendDebugActionSignalRows(
|
||||
$timeline,
|
||||
$sequence,
|
||||
$actions,
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$allowed,
|
||||
$gatewayWorkspace,
|
||||
$bindingsByService,
|
||||
$laneRelaySlots,
|
||||
$snapshot
|
||||
);
|
||||
|
||||
$machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot);
|
||||
$machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE');
|
||||
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||
@@ -1147,6 +1282,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.'
|
||||
);
|
||||
|
||||
$this->appendDebugActionSignalRows(
|
||||
$timeline,
|
||||
$sequence,
|
||||
$actions,
|
||||
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
|
||||
$allowed && $machineAvailable,
|
||||
$gatewayWorkspace,
|
||||
$bindingsByService,
|
||||
$laneRelaySlots,
|
||||
$snapshot
|
||||
);
|
||||
|
||||
$cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot);
|
||||
$cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER');
|
||||
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||
@@ -1161,6 +1308,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.'
|
||||
);
|
||||
|
||||
$this->appendDebugActionSignalRows(
|
||||
$timeline,
|
||||
$sequence,
|
||||
$actions,
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||
$allowed,
|
||||
$gatewayWorkspace,
|
||||
$bindingsByService,
|
||||
$laneRelaySlots,
|
||||
$snapshot
|
||||
);
|
||||
|
||||
$exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot);
|
||||
$exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT');
|
||||
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||
@@ -1218,6 +1377,146 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
protected function debugWashModeForStart(array $snapshot): string
|
||||
{
|
||||
return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)
|
||||
? selfserve_studio_actions::MODE_MACHINE
|
||||
: selfserve_studio_actions::MODE_MANUAL;
|
||||
}
|
||||
|
||||
protected function debugWashModeForStop(array $snapshot): string
|
||||
{
|
||||
return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true)
|
||||
? selfserve_studio_actions::MODE_MACHINE
|
||||
: selfserve_studio_actions::MODE_MANUAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $timeline
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<string,mixed> $gatewayWorkspace
|
||||
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
|
||||
* @param array<int,array<string,mixed>> $laneRelaySlots
|
||||
* @param array<string,mixed> $snapshot
|
||||
*/
|
||||
protected function appendDebugActionSignalRows(
|
||||
array &$timeline,
|
||||
int &$sequence,
|
||||
array $actions,
|
||||
string $event,
|
||||
bool $eventAllowed,
|
||||
array $gatewayWorkspace,
|
||||
array $bindingsByService,
|
||||
array $laneRelaySlots,
|
||||
array $snapshot
|
||||
): void {
|
||||
$eventActions = array_values(array_filter(
|
||||
$actions,
|
||||
static fn(array $action): bool => (string)($action['event'] ?? '') === $event
|
||||
));
|
||||
usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0))
|
||||
?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
|
||||
|
||||
foreach ($eventActions as $action) {
|
||||
$raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action);
|
||||
$operation = (string)($raw['operation'] ?? '');
|
||||
$relayRole = selfserve_studio_actions::relayRoleForOperation($operation);
|
||||
$runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event);
|
||||
$isRelayOperation = selfserve_studio_actions::isRelayOperation($operation);
|
||||
$isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true);
|
||||
$relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot);
|
||||
$binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole);
|
||||
$active = (bool)($action['active'] ?? false);
|
||||
$reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.');
|
||||
$payload = [
|
||||
'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0),
|
||||
'action' => (string)($raw['name'] ?? $action['label'] ?? ''),
|
||||
'event' => $event,
|
||||
'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH),
|
||||
'operation' => $operation,
|
||||
'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null),
|
||||
'enabled' => (bool)($raw['enabled'] ?? true),
|
||||
'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0),
|
||||
'options' => (array)($raw['options'] ?? []),
|
||||
];
|
||||
|
||||
if ($isRelayOperation) {
|
||||
$payload['id'] = $relayId;
|
||||
$payload['channel'] = 0;
|
||||
$payload['on'] = (bool)($raw['relay_state'] ?? true);
|
||||
$signalType = 'studio_action_relay_switch';
|
||||
} elseif ($isPropertyGate) {
|
||||
$payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE';
|
||||
$signalType = 'studio_action_gate_open';
|
||||
} else {
|
||||
$payload['id'] = $relayId;
|
||||
$payload['on'] = true;
|
||||
$payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1;
|
||||
$signalType = 'studio_action_relay_pulse';
|
||||
}
|
||||
|
||||
if (!$active) {
|
||||
$timeline[] = $this->debugSignalTimelineRow(
|
||||
$sequence++,
|
||||
$runtimeStage,
|
||||
$signalType,
|
||||
$relayRole,
|
||||
$relayId,
|
||||
$binding,
|
||||
$payload,
|
||||
$binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
|
||||
'skipped',
|
||||
$reason
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$eventAllowed) {
|
||||
$timeline[] = $this->debugSignalTimelineRow(
|
||||
$sequence++,
|
||||
$runtimeStage,
|
||||
$signalType,
|
||||
$relayRole,
|
||||
$relayId,
|
||||
$binding,
|
||||
$payload,
|
||||
$binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
|
||||
'blocked',
|
||||
'Action event would not fire because the dry-run scenario is blocked before this stage.'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isPropertyGate) {
|
||||
$timeline[] = $this->debugSignalTimelineRow(
|
||||
$sequence++,
|
||||
$runtimeStage,
|
||||
$signalType,
|
||||
$relayRole,
|
||||
null,
|
||||
null,
|
||||
$payload,
|
||||
'real',
|
||||
'sent',
|
||||
null
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||
$sequence++,
|
||||
$runtimeStage,
|
||||
$signalType,
|
||||
$relayRole,
|
||||
$relayId,
|
||||
$binding,
|
||||
$payload,
|
||||
true,
|
||||
'Action relay is not configured for the simulated lane.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $snapshot
|
||||
* @param array<int,array<string,mixed>> $laneRelaySlots
|
||||
@@ -1545,11 +1844,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<string,mixed> $hardware
|
||||
* @param array<string,mixed> $graph
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $graph): array
|
||||
protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array
|
||||
{
|
||||
$nodes = [
|
||||
'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'],
|
||||
@@ -1601,6 +1901,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($actions as $action) {
|
||||
$nodes[(string)$action['node_id']] = [
|
||||
'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable',
|
||||
'label' => (string)($action['reason'] ?? 'Action evaluated.'),
|
||||
];
|
||||
}
|
||||
|
||||
$edges = [];
|
||||
foreach ((array)($graph['edges'] ?? []) as $edge) {
|
||||
|
||||
@@ -10,10 +10,14 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\classes\selfserve_studio_action_runner;
|
||||
use modules\selfserve\classes\selfserve_studio_actions;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\helpers\selfserve_lane_log_action;
|
||||
@@ -200,6 +204,37 @@ trait selfserve_lane_command_t
|
||||
$this->setMachineRelayStatusForWashStart();
|
||||
}
|
||||
|
||||
protected function resolveSelfServeActionWashModeForStart(): string
|
||||
{
|
||||
try {
|
||||
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
||||
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
||||
if (is_array($services)) {
|
||||
foreach ($services as $service) {
|
||||
if (strtoupper((string)$service) === 'MACHINE') {
|
||||
return selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall through to manual mode when the cached service set is unavailable.
|
||||
}
|
||||
|
||||
return selfserve_studio_actions::MODE_MANUAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute configured Studio actions from the published flow for this lane.
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array
|
||||
{
|
||||
return (new selfserve_studio_action_runner())->executeForLaneEvent($this, $event, $washMode, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable relays after STOP in deterministic order:
|
||||
* 1. Cleaner relay
|
||||
@@ -391,6 +426,14 @@ trait selfserve_lane_command_t
|
||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||
// Start the wash timer
|
||||
$this->setWashStartTime(time());
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$this->resolveSelfServeActionWashModeForStart(),
|
||||
[
|
||||
'customer_number' => (int)$customer_number,
|
||||
'reg' => $license_plate,
|
||||
]
|
||||
);
|
||||
$this->runRelaySideEffectsForWashStart($arguments);
|
||||
// Log the lane start event
|
||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||
@@ -404,6 +447,15 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
// Snapshot the physical machine ON signal before session completion/reset.
|
||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
||||
[
|
||||
'customer_number' => $arguments->customer_number,
|
||||
'reg' => $this->getLicensePlate(),
|
||||
'machine_start_triggered' => $machine_start_triggered,
|
||||
]
|
||||
);
|
||||
// Open the exit port
|
||||
$this->open(selfserve_lane_port::EXIT);
|
||||
// Turn off relays in deterministic order after STOP
|
||||
|
||||
@@ -4,6 +4,7 @@ app_require('modules/selfserve/classes/selfserve_studio_graph.php');
|
||||
app_require('modules/selfserve/classes/selfserve_virtual_hardware.php');
|
||||
|
||||
use modules\selfserve\classes\selfserve_studio_graph;
|
||||
use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use modules\selfserve\classes\selfserve_virtual_hardware;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
|
||||
@@ -115,6 +116,112 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
|
||||
expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
|
||||
});
|
||||
|
||||
it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
|
||||
$graph = $service->buildGraphFromConfig([
|
||||
'schema_version' => 2,
|
||||
'questions' => [],
|
||||
'conditions' => [
|
||||
['id' => 10, 'name' => 'Machine selected'],
|
||||
],
|
||||
'rules' => [],
|
||||
'tasks' => [],
|
||||
'actions' => [
|
||||
[
|
||||
'id' => 80,
|
||||
'name' => 'Open lane entry',
|
||||
'event' => 'wash_start_command',
|
||||
'wash_mode' => 'both',
|
||||
'operation' => 'open_lane_entrance_port',
|
||||
'condition_id' => 10,
|
||||
'lane' => 7,
|
||||
'order_priority' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 81,
|
||||
'name' => 'Cleaner off',
|
||||
'event' => 'wash_start_command',
|
||||
'wash_mode' => 'machine',
|
||||
'operation' => 'set_cleaner_relay',
|
||||
'relay_state' => false,
|
||||
'lane' => 7,
|
||||
'order_priority' => 2,
|
||||
],
|
||||
],
|
||||
], [
|
||||
'lookups' => [
|
||||
'labels' => [
|
||||
'lanes' => ['7' => 'Lane 7'],
|
||||
'conditions' => ['10' => 'Machine selected'],
|
||||
'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$nodeIds = array_column($graph['nodes'], 'id');
|
||||
$edgeIds = array_column($graph['edges'], 'id');
|
||||
$actionNode = array_values(array_filter(
|
||||
$graph['nodes'],
|
||||
static fn(array $node): bool => ($node['id'] ?? null) === 'action:81'
|
||||
))[0] ?? null;
|
||||
|
||||
expect($nodeIds)->toContain('action:80')
|
||||
->and($nodeIds)->toContain('action:81')
|
||||
->and($edgeIds)->toContain('action-event:wash_start_command:80')
|
||||
->and($edgeIds)->toContain('action-gate:10:80')
|
||||
->and($edgeIds)->toContain('action-order:wash_start_command:80:81')
|
||||
->and($edgeIds)->toContain('scope:lane:7:action:80')
|
||||
->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER')
|
||||
->and($actionNode['data']['relay_role'])->toBe('CLEANER');
|
||||
});
|
||||
|
||||
it('validates action configuration and keeps warnings non-blocking', function (): void {
|
||||
$versioning = new class extends selfserve_config_versioning {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$validation = $versioning->validateConfig([
|
||||
'schema_version' => 2,
|
||||
'questions' => [
|
||||
['id' => 1, 'question' => 'Machine selected?'],
|
||||
],
|
||||
'conditions' => [
|
||||
[
|
||||
'id' => 10,
|
||||
'name' => 'Gate',
|
||||
'expression' => [
|
||||
'type' => 'group',
|
||||
'operator' => 'ALL',
|
||||
'children' => [
|
||||
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'rules' => [],
|
||||
'tasks' => [],
|
||||
'actions' => [
|
||||
[
|
||||
'id' => 80,
|
||||
'name' => 'Manual machine start action',
|
||||
'event' => 'machine_start_triggered',
|
||||
'wash_mode' => 'manual',
|
||||
'operation' => 'set_machine_relay',
|
||||
'condition_id' => 10,
|
||||
'relay_state' => true,
|
||||
'options' => ['failure_policy' => 'block'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($validation['valid'])->toBeTrue()
|
||||
->and($validation['stats']['actions'])->toBe(1)
|
||||
->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event');
|
||||
});
|
||||
|
||||
it('serializes v2 condition expressions without standalone rule nodes', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
|
||||
@@ -586,6 +693,146 @@ it('renders virtual gateway nodes and task service edges in the studio graph', f
|
||||
->and($bindingNode['data']['raw']['virtual'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('inserts configured action signals into the simulator timeline in runtime order', function (): void {
|
||||
$service = selfserve_wash_flow_without_constructor();
|
||||
|
||||
$debug = $service->buildStudioDebugPayload(6, [
|
||||
'lane' => [
|
||||
'id' => 7,
|
||||
'department' => 6,
|
||||
'name' => 'Lane 7',
|
||||
'relay_in_id' => 'ENTRY-7',
|
||||
'relay_out_id' => 'EXIT-7',
|
||||
'relay_machine_id' => 'M-7',
|
||||
'relay_machine_program_picker_id' => 'PICKER-7',
|
||||
'relay_machine_cleaner_id' => 'CLEAN-7',
|
||||
],
|
||||
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
|
||||
'vehicle' => null,
|
||||
'reg' => 'TEST123',
|
||||
'customer_number' => null,
|
||||
'vehicle_type_id' => 2,
|
||||
'answers' => [],
|
||||
'answer_sources' => [],
|
||||
'questions' => [],
|
||||
'tasks' => [
|
||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
|
||||
],
|
||||
'allowed_services' => ['MACHINE'],
|
||||
'machine_available' => true,
|
||||
'all_visible_questions_answered' => true,
|
||||
'allowed' => true,
|
||||
'config_version_id' => 90,
|
||||
'config_source' => 'draft',
|
||||
'evaluation_trace' => [
|
||||
'visible_question_ids' => [],
|
||||
'visibility_condition_results' => [],
|
||||
'condition_results' => [21 => true],
|
||||
'task_gates' => [
|
||||
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
|
||||
],
|
||||
],
|
||||
'debug_candidates' => [
|
||||
'questions' => [],
|
||||
'conditions' => [
|
||||
['id' => 21, 'name' => 'Gate'],
|
||||
],
|
||||
'rules' => [],
|
||||
'tasks' => [
|
||||
['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1],
|
||||
],
|
||||
'actions' => [
|
||||
[
|
||||
'id' => 81,
|
||||
'name' => 'Open entry on start',
|
||||
'event' => 'wash_start_command',
|
||||
'wash_mode' => 'both',
|
||||
'operation' => 'open_lane_entrance_port',
|
||||
'condition_id' => 21,
|
||||
'order_priority' => 1,
|
||||
'options' => ['toggle_after_seconds' => 2],
|
||||
],
|
||||
[
|
||||
'id' => 82,
|
||||
'name' => 'Program picker off when machine starts',
|
||||
'event' => 'machine_start_triggered',
|
||||
'wash_mode' => 'machine',
|
||||
'operation' => 'set_program_picker_relay',
|
||||
'relay_state' => false,
|
||||
'order_priority' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 83,
|
||||
'name' => 'Cleaner off on stop',
|
||||
'event' => 'wash_stop_command',
|
||||
'wash_mode' => 'machine',
|
||||
'operation' => 'set_cleaner_relay',
|
||||
'relay_state' => false,
|
||||
'order_priority' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'gateway_workspace' => [
|
||||
'gateways' => [
|
||||
[
|
||||
'id' => 701,
|
||||
'label' => 'Roskilde Edge',
|
||||
'status' => 'ONLINE',
|
||||
'bindings' => [
|
||||
['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']],
|
||||
['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']],
|
||||
['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
|
||||
['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']],
|
||||
['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']],
|
||||
],
|
||||
],
|
||||
],
|
||||
'lanes' => [
|
||||
[
|
||||
'id' => 7,
|
||||
'relay_slots' => [
|
||||
['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'],
|
||||
['slot' => 'MACHINE', 'relay_id' => 'M-7'],
|
||||
['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'],
|
||||
['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
|
||||
['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'lookups' => [
|
||||
'labels' => [
|
||||
'lanes' => ['7' => 'Lane 7'],
|
||||
'conditions' => ['21' => 'Gate'],
|
||||
'tasks' => ['41' => 'Start machine'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11))
|
||||
->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([
|
||||
'SESSION',
|
||||
'ENTRY',
|
||||
'MACHINE',
|
||||
'MACHINE',
|
||||
'PROGRAM_PICKER',
|
||||
'CLEANER',
|
||||
'CLEANER',
|
||||
'EXIT',
|
||||
'CLEANER',
|
||||
'MACHINE',
|
||||
'SESSION',
|
||||
])
|
||||
->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse')
|
||||
->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2])
|
||||
->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch')
|
||||
->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false])
|
||||
->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false])
|
||||
->and($debug['actions'][0]['state'])->toBe('active')
|
||||
->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active');
|
||||
});
|
||||
|
||||
it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void {
|
||||
$service = selfserve_wash_flow_without_constructor();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user