Add unit tests for department lane dynamic image overrides and introduce classes for self-serve signal and virtual hardware management
- Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes. - Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows. - Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management. - Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings.
This commit is contained in:
@@ -3857,6 +3857,13 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
- name: dynamic_image_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Dynamic image ID to preview instead of the lane's saved image
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
- name: buttons
|
- name: buttons
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@@ -7803,6 +7810,93 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/relay/machine/on/post:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Plate Scans
|
||||||
|
summary: Record Shelly machine ON signal webhook
|
||||||
|
description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing.
|
||||||
|
operationId: recordShellyMachineOnSignal
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: {type: string}
|
||||||
|
- name: lane_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: relay_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: event
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [input.toggle_on, switch.on]
|
||||||
|
- name: reg
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Machine ON signal recorded and linked to a self-serve wash session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MachineButtonPressWebhookResponse'
|
||||||
|
'202':
|
||||||
|
description: Shelly signal was recognized but ignored
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Plate Scans
|
||||||
|
summary: Record Shelly machine ON signal webhook
|
||||||
|
description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing.
|
||||||
|
operationId: recordShellyMachineOnSignalPost
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
lane_id:
|
||||||
|
type: integer
|
||||||
|
relay_id:
|
||||||
|
type: string
|
||||||
|
event:
|
||||||
|
type: string
|
||||||
|
enum: [input.toggle_on, switch.on]
|
||||||
|
component:
|
||||||
|
type: string
|
||||||
|
example: input:0
|
||||||
|
state:
|
||||||
|
type: boolean
|
||||||
|
output:
|
||||||
|
type: boolean
|
||||||
|
reg:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Machine ON signal recorded and linked to a self-serve wash session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MachineButtonPressWebhookResponse'
|
||||||
|
'202':
|
||||||
|
description: Shelly signal was recognized but ignored
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
|
||||||
# Module - e-conomic Endpoints
|
# Module - e-conomic Endpoints
|
||||||
/economic/customers/import:
|
/economic/customers/import:
|
||||||
post:
|
post:
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ class selfserve_schema_bootstrap
|
|||||||
INDEX idx_department_selfserve_studio_layouts_department_user (department_id, user_id),
|
INDEX idx_department_selfserve_studio_layouts_department_user (department_id, user_id),
|
||||||
INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at)
|
INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
department_id INT NOT NULL,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||||
|
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($queries as $sql) {
|
foreach ($queries as $sql) {
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ function collectDynamicImageTaskGroupsForLane(array $laneRow): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int,array<string,mixed>> $taskRows
|
* @param array<int,array<string,mixed>> $taskRows
|
||||||
* @return array<int,array{dynamic_image_id:int,buttons:array<int>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
|
* @return array<int,array{dynamic_image_id:int,buttons:array<int|string>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
|
||||||
*/
|
*/
|
||||||
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
|
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
|
||||||
{
|
{
|
||||||
@@ -515,7 +515,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl
|
|||||||
$buttons = parseDynamicImageButtons($row['buttons'] ?? null);
|
$buttons = parseDynamicImageButtons($row['buttons'] ?? null);
|
||||||
if ($buttons !== []) {
|
if ($buttons !== []) {
|
||||||
$buttonSets[] = $buttons;
|
$buttonSets[] = $buttons;
|
||||||
$runningButtons = mergeUniqueIntValues($runningButtons, $buttons);
|
$runningButtons = mergeUniqueButtonValues($runningButtons, $buttons);
|
||||||
$buttonSets[] = $runningButtons;
|
$buttonSets[] = $runningButtons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,7 +554,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int>|null $buttons
|
* @param array<int|string>|null $buttons
|
||||||
*/
|
*/
|
||||||
function buildDynamicImageCacheKey(array $variant): string
|
function buildDynamicImageCacheKey(array $variant): string
|
||||||
{
|
{
|
||||||
@@ -575,7 +575,7 @@ function buildDynamicImageCacheKey(array $variant): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int>|null $buttons
|
* @param array<int|string>|null $buttons
|
||||||
*/
|
*/
|
||||||
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
|
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
|
||||||
{
|
{
|
||||||
@@ -621,7 +621,7 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @return array<int>
|
* @return array<int|string>
|
||||||
*/
|
*/
|
||||||
function parseDynamicImageButtons(mixed $value): array
|
function parseDynamicImageButtons(mixed $value): array
|
||||||
{
|
{
|
||||||
@@ -630,8 +630,7 @@ function parseDynamicImageButtons(mixed $value): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$normalized = department_selfserve_tasks_o::normalizeButtonsInput($value);
|
return department_selfserve_tasks_o::normalizeButtonsInput($value);
|
||||||
return array_values(array_map('intval', $normalized));
|
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -662,17 +661,22 @@ function normalizeDynamicImageVehicleType(mixed $value): ?int
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int> $base
|
* @param array<int|string> $base
|
||||||
* @param array<int> $append
|
* @param array<int|string> $append
|
||||||
* @return array<int>
|
* @return array<int|string>
|
||||||
*/
|
*/
|
||||||
function mergeUniqueIntValues(array $base, array $append): array
|
function mergeUniqueButtonValues(array $base, array $append): array
|
||||||
{
|
{
|
||||||
$result = $base;
|
$result = $base;
|
||||||
|
$seen = [];
|
||||||
|
foreach ($result as $value) {
|
||||||
|
$seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true;
|
||||||
|
}
|
||||||
foreach ($append as $value) {
|
foreach ($append as $value) {
|
||||||
$intValue = (int)$value;
|
$key = (is_int($value) ? 'int:' : 'string:') . (string)$value;
|
||||||
if (!in_array($intValue, $result, true)) {
|
if (!isset($seen[$key])) {
|
||||||
$result[] = $intValue;
|
$seen[$key] = true;
|
||||||
|
$result[] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return array_values($result);
|
return array_values($result);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class machine_1 extends dynamicimages_image
|
|||||||
const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png';
|
const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png';
|
||||||
const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png';
|
const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png';
|
||||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||||
|
const BUTTON_RESET = 'reset';
|
||||||
|
const BUTTON_START = 'start';
|
||||||
// Thumb
|
// Thumb
|
||||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||||
public int $thumb_size = 1550; // height and width of the thumb
|
public int $thumb_size = 1550; // height and width of the thumb
|
||||||
@@ -174,11 +176,33 @@ class machine_1 extends dynamicimages_image
|
|||||||
return self::IMAGE_BUTTON_HIGHLIGHTED_GREY;
|
return self::IMAGE_BUTTON_HIGHLIGHTED_GREY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isHighlightedButton(int|string $button): bool
|
||||||
|
{
|
||||||
|
foreach ($this->highlighted_buttons as $highlightedButton) {
|
||||||
|
if (is_int($button)) {
|
||||||
|
if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function drawHighlightedStartButton(): void
|
public function drawHighlightedStartButton(): void
|
||||||
{
|
{
|
||||||
|
if (!$this->isHighlightedButton(self::BUTTON_START)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$x = 4965; // X position for the start button
|
$x = 4965; // X position for the start button
|
||||||
$y = 1980; // Y position for the start button
|
$y = 1980; // Y position for the start button
|
||||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||||
@@ -200,6 +224,10 @@ class machine_1 extends dynamicimages_image
|
|||||||
*/
|
*/
|
||||||
public function drawHighlightedResetButton(): void
|
public function drawHighlightedResetButton(): void
|
||||||
{
|
{
|
||||||
|
if (!$this->isHighlightedButton(self::BUTTON_RESET)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$x = 1736; // X position for the reset button
|
$x = 1736; // X position for the reset button
|
||||||
$y = 599; // Y position for the reset button
|
$y = 599; // Y position for the reset button
|
||||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||||
@@ -404,7 +432,7 @@ class machine_1 extends dynamicimages_image
|
|||||||
for ($col = 0; $col < $this->button_columns; $col++) {
|
for ($col = 0; $col < $this->button_columns; $col++) {
|
||||||
// If the current button position is not in the highlighted buttons array, skip it
|
// If the current button position is not in the highlighted buttons array, skip it
|
||||||
$buttonIndex = $row * $this->button_columns + $col;
|
$buttonIndex = $row * $this->button_columns + $col;
|
||||||
if (!in_array($buttonIndex, $this->highlighted_buttons, true)) {
|
if (!$this->isHighlightedButton($buttonIndex)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Calculate the position for the current button
|
// Calculate the position for the current button
|
||||||
@@ -529,4 +557,4 @@ class machine_1 extends dynamicimages_image
|
|||||||
$tmp->clearMemoryImage();
|
$tmp->clearMemoryImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use classes\edge_gateway_registry_service;
|
|||||||
use classes\edge_gateway_view_service;
|
use classes\edge_gateway_view_service;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use modules\selfserve\classes\selfserve_machine_signal;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class edgeGatewaysRoute
|
class edgeGatewaysRoute
|
||||||
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
|
|||||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
||||||
|
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
||||||
@@ -569,6 +572,40 @@ class edgeGatewaysRoute
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleAgentSelfserveMachineSignalBindings(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->success((new selfserve_machine_signal())->listEdgeGatewayMachineSignalMonitors(
|
||||||
|
$gatewayId,
|
||||||
|
$this->requireAgentToken($payload)
|
||||||
|
));
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAgentSelfserveMachineSignal(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = (new selfserve_machine_signal())->recordEdgeGatewaySignal(
|
||||||
|
$gatewayId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
$payload
|
||||||
|
);
|
||||||
|
$response->success($result, !empty($result['recorded']) ? 201 : 202);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function handleBrokerGatewayValidate(): void
|
private function handleBrokerGatewayValidate(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ use modules\selfserve\interfaces\selfserve_condition_evaluator_i;
|
|||||||
|
|
||||||
class selfserve_condition_evaluator implements selfserve_condition_evaluator_i
|
class selfserve_condition_evaluator implements selfserve_condition_evaluator_i
|
||||||
{
|
{
|
||||||
|
private const V2_OPERATORS = [
|
||||||
|
'IS_TRUE',
|
||||||
|
'IS_FALSE',
|
||||||
|
'IS_SET',
|
||||||
|
'IS_TRUE_OR_NOT_SET',
|
||||||
|
'IS_FALSE_OR_NOT_SET',
|
||||||
|
];
|
||||||
|
|
||||||
public function evaluate(array $conditions, array $rules, array $answers): array
|
public function evaluate(array $conditions, array $rules, array $answers): array
|
||||||
{
|
{
|
||||||
$rulesByConditionId = [];
|
$rulesByConditionId = [];
|
||||||
@@ -89,6 +97,80 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function evaluateExpressions(array $conditions, array $answers): array
|
||||||
|
{
|
||||||
|
return $this->evaluateExpressionsWithTrace($conditions, $answers)['results'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evaluateExpressionsWithTrace(array $conditions, array $answers): array
|
||||||
|
{
|
||||||
|
$conditionsById = [];
|
||||||
|
foreach ($conditions as $condition) {
|
||||||
|
$conditionId = (int)($condition['id'] ?? 0);
|
||||||
|
if ($conditionId > 0) {
|
||||||
|
$conditionsById[$conditionId] = $condition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
$trace = [];
|
||||||
|
$resolving = [];
|
||||||
|
|
||||||
|
$resolver = function (int $conditionId) use (&$resolver, &$results, &$trace, &$resolving, $conditionsById, $answers): bool {
|
||||||
|
if (array_key_exists($conditionId, $results)) {
|
||||||
|
return $results[$conditionId];
|
||||||
|
}
|
||||||
|
if (isset($resolving[$conditionId])) {
|
||||||
|
$results[$conditionId] = false;
|
||||||
|
$trace[$conditionId] = [
|
||||||
|
'type' => 'cycle',
|
||||||
|
'condition_id' => $conditionId,
|
||||||
|
'result' => false,
|
||||||
|
'reason' => 'Condition dependency cycle detected.',
|
||||||
|
];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$condition = $conditionsById[$conditionId] ?? null;
|
||||||
|
if (!is_array($condition)) {
|
||||||
|
$results[$conditionId] = false;
|
||||||
|
$trace[$conditionId] = [
|
||||||
|
'type' => 'missing_condition',
|
||||||
|
'condition_id' => $conditionId,
|
||||||
|
'result' => false,
|
||||||
|
'reason' => 'Condition was not found.',
|
||||||
|
];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expression = is_array($condition['expression'] ?? null)
|
||||||
|
? (array)$condition['expression']
|
||||||
|
: $this->emptyExpression();
|
||||||
|
|
||||||
|
$resolving[$conditionId] = true;
|
||||||
|
$evaluated = $this->evaluateExpressionNode($expression, $answers, $resolver);
|
||||||
|
unset($resolving[$conditionId]);
|
||||||
|
|
||||||
|
$results[$conditionId] = (bool)($evaluated['result'] ?? false);
|
||||||
|
$trace[$conditionId] = [
|
||||||
|
'type' => 'condition',
|
||||||
|
'condition_id' => $conditionId,
|
||||||
|
'result' => $results[$conditionId],
|
||||||
|
'expression' => $evaluated,
|
||||||
|
];
|
||||||
|
return $results[$conditionId];
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (array_keys($conditionsById) as $conditionId) {
|
||||||
|
$resolver((int)$conditionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'results' => $results,
|
||||||
|
'trace' => $trace,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool
|
public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool
|
||||||
{
|
{
|
||||||
if ($gateId === null || $gateId <= 0) {
|
if ($gateId === null || $gateId <= 0) {
|
||||||
@@ -147,4 +229,122 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i
|
|||||||
selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null,
|
selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $node
|
||||||
|
* @param array<int,bool|null> $answers
|
||||||
|
* @param callable(int):bool $conditionResolver
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function evaluateExpressionNode(array $node, array $answers, callable $conditionResolver): array
|
||||||
|
{
|
||||||
|
$type = strtolower((string)($node['type'] ?? $node['kind'] ?? 'group'));
|
||||||
|
if ($type === 'predicate') {
|
||||||
|
return $this->evaluateExpressionPredicate($node, $answers, $conditionResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = strtoupper((string)($node['operator'] ?? $node['mode'] ?? 'ALL'));
|
||||||
|
if (!in_array($operator, ['ALL', 'ANY'], true)) {
|
||||||
|
$operator = 'ALL';
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = is_array($node['children'] ?? null) ? array_values((array)$node['children']) : [];
|
||||||
|
if ($children === []) {
|
||||||
|
return [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => $operator,
|
||||||
|
'result' => false,
|
||||||
|
'children' => [],
|
||||||
|
'reason' => 'Group has no predicates.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$childTraces = [];
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if (!is_array($child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$childTraces[] = $this->evaluateExpressionNode((array)$child, $answers, $conditionResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($childTraces === []) {
|
||||||
|
$result = false;
|
||||||
|
} elseif ($operator === 'ANY') {
|
||||||
|
$result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) === true)) > 0;
|
||||||
|
} else {
|
||||||
|
$result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) !== true)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => $operator,
|
||||||
|
'result' => $result,
|
||||||
|
'children' => $childTraces,
|
||||||
|
'reason' => $result ? 'Group passed.' : 'Group did not pass.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $node
|
||||||
|
* @param array<int,bool|null> $answers
|
||||||
|
* @param callable(int):bool $conditionResolver
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function evaluateExpressionPredicate(array $node, array $answers, callable $conditionResolver): array
|
||||||
|
{
|
||||||
|
$subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? ''));
|
||||||
|
$subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0);
|
||||||
|
$operator = strtoupper((string)($node['operator'] ?? $node['rule_type'] ?? ''));
|
||||||
|
|
||||||
|
if (!in_array($operator, self::V2_OPERATORS, true)) {
|
||||||
|
return [
|
||||||
|
'type' => 'predicate',
|
||||||
|
'subject_type' => $subjectType,
|
||||||
|
'subject_id' => $subjectId,
|
||||||
|
'operator' => $operator,
|
||||||
|
'actual_value' => null,
|
||||||
|
'result' => false,
|
||||||
|
'reason' => 'Unsupported predicate operator.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjectType === 'condition') {
|
||||||
|
$actual = $subjectId > 0 ? $conditionResolver($subjectId) : null;
|
||||||
|
} elseif ($subjectType === 'question') {
|
||||||
|
$actual = $answers[$subjectId] ?? null;
|
||||||
|
} else {
|
||||||
|
$actual = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = match ($operator) {
|
||||||
|
'IS_TRUE' => $actual === true,
|
||||||
|
'IS_FALSE' => $actual === false,
|
||||||
|
'IS_SET' => $actual !== null,
|
||||||
|
'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null,
|
||||||
|
'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'predicate',
|
||||||
|
'subject_type' => $subjectType,
|
||||||
|
'subject_id' => $subjectId,
|
||||||
|
'operator' => $operator,
|
||||||
|
'actual_value' => $actual,
|
||||||
|
'result' => $result,
|
||||||
|
'reason' => $result ? 'Predicate passed.' : 'Predicate did not pass.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function emptyExpression(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ class selfserve_config_versioning
|
|||||||
public const STATUS_DRAFT = 'DRAFT';
|
public const STATUS_DRAFT = 'DRAFT';
|
||||||
public const STATUS_PUBLISHED = 'PUBLISHED';
|
public const STATUS_PUBLISHED = 'PUBLISHED';
|
||||||
public const STATUS_ARCHIVED = 'ARCHIVED';
|
public const STATUS_ARCHIVED = 'ARCHIVED';
|
||||||
|
public const SCHEMA_VERSION_V2 = 2;
|
||||||
|
|
||||||
|
private const V2_PREDICATE_OPERATORS = [
|
||||||
|
'IS_TRUE',
|
||||||
|
'IS_FALSE',
|
||||||
|
'IS_SET',
|
||||||
|
'IS_TRUE_OR_NOT_SET',
|
||||||
|
'IS_FALSE_OR_NOT_SET',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -47,6 +56,24 @@ class selfserve_config_versioning
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{version_id:int,config:array<string,mixed>}|null
|
||||||
|
*/
|
||||||
|
public function getPublishedV2Config(int $departmentId): ?array
|
||||||
|
{
|
||||||
|
$published = $this->getPublishedConfig($departmentId);
|
||||||
|
if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $published;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isV2Config(array $config): bool
|
||||||
|
{
|
||||||
|
return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
@@ -57,9 +84,11 @@ class selfserve_config_versioning
|
|||||||
$validation = $this->validateConfig($config);
|
$validation = $this->validateConfig($config);
|
||||||
|
|
||||||
if ($versionObject->exists()) {
|
if ($versionObject->exists()) {
|
||||||
if ($forceRefresh) {
|
$existingConfig = (array)($versionObject->config_json->value() ?? []);
|
||||||
$versionObject->config_json->set($config);
|
if ($forceRefresh || !$this->isV2Config($existingConfig)) {
|
||||||
$versionObject->validation_result_json->set($validation);
|
$nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]);
|
||||||
|
$versionObject->config_json->set($nextConfig);
|
||||||
|
$versionObject->validation_result_json->set($this->validateConfig($nextConfig));
|
||||||
}
|
}
|
||||||
return $versionObject->asArray();
|
return $versionObject->asArray();
|
||||||
}
|
}
|
||||||
@@ -117,7 +146,13 @@ class selfserve_config_versioning
|
|||||||
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
|
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
|
$config = (array)($draft->config_json->value() ?? []);
|
||||||
|
if (!$this->isV2Config($config)) {
|
||||||
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
||||||
|
$draft->config_json->set($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation = $this->validateConfig($config);
|
||||||
$draft->validation_result_json->set($validation);
|
$draft->validation_result_json->set($validation);
|
||||||
if (($validation['valid'] ?? false) !== true) {
|
if (($validation['valid'] ?? false) !== true) {
|
||||||
throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
|
throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
|
||||||
@@ -132,7 +167,7 @@ class selfserve_config_versioning
|
|||||||
|
|
||||||
// Keep editing path open by creating a new draft cloned from newly published version.
|
// Keep editing path open by creating a new draft cloned from newly published version.
|
||||||
$publishedArray = $draft->asArray();
|
$publishedArray = $draft->asArray();
|
||||||
$this->createDraftFromConfig($departmentId, (array)$draft->config_json->value(), (int)$draft->id, $publishedBy);
|
$this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy);
|
||||||
|
|
||||||
return $publishedArray;
|
return $publishedArray;
|
||||||
}
|
}
|
||||||
@@ -148,6 +183,9 @@ class selfserve_config_versioning
|
|||||||
}
|
}
|
||||||
|
|
||||||
$config = (array)($target->config_json->value() ?? []);
|
$config = (array)($target->config_json->value() ?? []);
|
||||||
|
if (!$this->isV2Config($config)) {
|
||||||
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
||||||
|
}
|
||||||
$validation = $this->validateConfig($config);
|
$validation = $this->validateConfig($config);
|
||||||
if (($validation['valid'] ?? false) !== true) {
|
if (($validation['valid'] ?? false) !== true) {
|
||||||
throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
|
throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
|
||||||
@@ -187,6 +225,14 @@ class selfserve_config_versioning
|
|||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
public function snapshotLegacyConfig(int $departmentId): array
|
public function snapshotLegacyConfig(int $departmentId): array
|
||||||
|
{
|
||||||
|
return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function snapshotLegacyTableConfig(int $departmentId): array
|
||||||
{
|
{
|
||||||
$questionsObject = new department_selfserve_questions_o();
|
$questionsObject = new department_selfserve_questions_o();
|
||||||
$conditionsObject = new department_selfserve_conditions_o();
|
$conditionsObject = new department_selfserve_conditions_o();
|
||||||
@@ -248,12 +294,68 @@ class selfserve_config_versioning
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $legacyConfig
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function migrateLegacyConfigToV2(array $legacyConfig): array
|
||||||
|
{
|
||||||
|
if ($this->isV2Config($legacyConfig)) {
|
||||||
|
return $this->normalizeV2Config($legacyConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rulesByCondition = [];
|
||||||
|
foreach ((array)($legacyConfig['rules'] ?? []) as $rule) {
|
||||||
|
if (!is_array($rule)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrationIssues = [];
|
||||||
|
$conditions = [];
|
||||||
|
foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) {
|
||||||
|
if (!is_array($condition)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$conditionId = (int)($condition['id'] ?? 0);
|
||||||
|
$conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? []));
|
||||||
|
$condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues);
|
||||||
|
$conditions[] = $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'schema_version' => self::SCHEMA_VERSION_V2,
|
||||||
|
'department_id' => (int)($legacyConfig['department_id'] ?? 0),
|
||||||
|
'questions' => array_values((array)($legacyConfig['questions'] ?? [])),
|
||||||
|
'conditions' => array_values($conditions),
|
||||||
|
'rules' => [],
|
||||||
|
'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])),
|
||||||
|
'v2_meta' => [
|
||||||
|
'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1),
|
||||||
|
'migrated_at' => date('c'),
|
||||||
|
'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'),
|
||||||
|
'next_ids' => $this->nextIdsForConfig($legacyConfig),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($migrationIssues !== []) {
|
||||||
|
$config['migration_issues'] = $migrationIssues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeV2Config($config);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $config
|
* @param array<string,mixed> $config
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
public function validateConfig(array $config): array
|
public function validateConfig(array $config): array
|
||||||
{
|
{
|
||||||
|
if ($this->isV2Config($config)) {
|
||||||
|
return $this->validateV2Config($config);
|
||||||
|
}
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
|
||||||
@@ -351,11 +453,223 @@ class selfserve_config_versioning
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function validateV2Config(array $config): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
foreach ((array)($config['migration_issues'] ?? []) as $issue) {
|
||||||
|
if (is_array($issue)) {
|
||||||
|
$errors[] = (string)($issue['message'] ?? 'Migration issue detected.');
|
||||||
|
} else {
|
||||||
|
$errors[] = (string)$issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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']) : [];
|
||||||
|
|
||||||
|
$questionIds = [];
|
||||||
|
foreach ($questions as $question) {
|
||||||
|
$id = (int)($question['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
$errors[] = 'Question without valid id.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$questionIds[$id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conditionIds = [];
|
||||||
|
$conditionParents = [];
|
||||||
|
foreach ($conditions as $condition) {
|
||||||
|
$id = (int)($condition['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
$errors[] = 'Condition without valid id.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$conditionIds[$id] = true;
|
||||||
|
$conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usedConditionIds = [];
|
||||||
|
$conditionEdges = [];
|
||||||
|
$conditionHasPredicate = [];
|
||||||
|
foreach ($conditions as $condition) {
|
||||||
|
$conditionId = (int)($condition['id'] ?? 0);
|
||||||
|
if ($conditionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parentId = $conditionParents[$conditionId] ?? null;
|
||||||
|
if ($parentId !== null) {
|
||||||
|
if (!isset($conditionIds[$parentId])) {
|
||||||
|
$errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId;
|
||||||
|
}
|
||||||
|
if ($parentId === $conditionId) {
|
||||||
|
$errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.';
|
||||||
|
}
|
||||||
|
$conditionEdges[$conditionId][] = $parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression();
|
||||||
|
$expressionValidation = $this->validateExpressionNode(
|
||||||
|
$expression,
|
||||||
|
$conditionId,
|
||||||
|
$questionIds,
|
||||||
|
$conditionIds,
|
||||||
|
$usedConditionIds,
|
||||||
|
$conditionEdges,
|
||||||
|
);
|
||||||
|
$conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate'];
|
||||||
|
foreach ($expressionValidation['errors'] as $message) {
|
||||||
|
$errors[] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($questions as $question) {
|
||||||
|
$conditionId = $this->nullableInt($question['condition_id'] ?? null);
|
||||||
|
if ($conditionId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$usedConditionIds[$conditionId] = true;
|
||||||
|
if (!isset($conditionIds[$conditionId])) {
|
||||||
|
$errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$taskId = (int)($task['id'] ?? 0);
|
||||||
|
$gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
|
||||||
|
$gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
|
||||||
|
if ($gateType === null) {
|
||||||
|
$errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
|
||||||
|
if ($gateType === selfserve_task_gate_type::ALWAYS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($gateRefId === null) {
|
||||||
|
$errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($gateType === selfserve_task_gate_type::CONDITION) {
|
||||||
|
$usedConditionIds[$gateRefId] = true;
|
||||||
|
if (!isset($conditionIds[$gateRefId])) {
|
||||||
|
$errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
|
||||||
|
$errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) {
|
||||||
|
$errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $errors === [],
|
||||||
|
'errors' => array_values(array_unique($errors)),
|
||||||
|
'warnings' => $warnings,
|
||||||
|
'stats' => [
|
||||||
|
'schema_version' => self::SCHEMA_VERSION_V2,
|
||||||
|
'questions' => count($questions),
|
||||||
|
'conditions' => count($conditions),
|
||||||
|
'rules' => 0,
|
||||||
|
'tasks' => count($tasks),
|
||||||
|
],
|
||||||
|
'validated_at' => date('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $expression
|
||||||
|
* @param array<int,bool> $questionIds
|
||||||
|
* @param array<int,bool> $conditionIds
|
||||||
|
* @param array<int,bool> $usedConditionIds
|
||||||
|
* @param array<int,array<int,int>> $conditionEdges
|
||||||
|
* @return array{errors:array<int,string>,has_predicate:bool}
|
||||||
|
*/
|
||||||
|
protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
|
||||||
|
if ($type === 'predicate') {
|
||||||
|
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
|
||||||
|
$subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
|
||||||
|
$operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? ''));
|
||||||
|
if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.';
|
||||||
|
}
|
||||||
|
if ($subjectType === 'question') {
|
||||||
|
if ($subjectId <= 0 || !isset($questionIds[$subjectId])) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId;
|
||||||
|
}
|
||||||
|
} elseif ($subjectType === 'condition') {
|
||||||
|
$usedConditionIds[$subjectId] = true;
|
||||||
|
$conditionEdges[$ownerConditionId][] = $subjectId;
|
||||||
|
if ($subjectId === $ownerConditionId) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.';
|
||||||
|
}
|
||||||
|
if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'errors' => $errors,
|
||||||
|
'has_predicate' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
|
||||||
|
if (!in_array($operator, ['ALL', 'ANY'], true)) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
|
||||||
|
$hasPredicate = false;
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if (!is_array($child)) {
|
||||||
|
$errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
|
||||||
|
$hasPredicate = $hasPredicate || $childValidation['has_predicate'];
|
||||||
|
foreach ($childValidation['errors'] as $message) {
|
||||||
|
$errors[] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'errors' => $errors,
|
||||||
|
'has_predicate' => $hasPredicate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
|
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
|
||||||
{
|
{
|
||||||
// Remove stale drafts first.
|
// Remove stale drafts first.
|
||||||
$this->deleteAllDrafts($departmentId);
|
$this->deleteAllDrafts($departmentId);
|
||||||
|
|
||||||
|
if (!$this->isV2Config($config)) {
|
||||||
|
$config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
||||||
|
}
|
||||||
|
|
||||||
$validation = $this->validateConfig($config);
|
$validation = $this->validateConfig($config);
|
||||||
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
|
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
|
||||||
(new selfserve_config_versions_o())->add(
|
(new selfserve_config_versions_o())->add(
|
||||||
@@ -470,4 +784,177 @@ class selfserve_config_versioning
|
|||||||
|
|
||||||
return $cycles;
|
return $cycles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $rules
|
||||||
|
* @param array<int,array<string,mixed>> $migrationIssues
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array
|
||||||
|
{
|
||||||
|
$allChildren = [];
|
||||||
|
$anyChildren = [];
|
||||||
|
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
$predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues);
|
||||||
|
if ($predicate === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') {
|
||||||
|
$anyChildren[] = $predicate;
|
||||||
|
} else {
|
||||||
|
$allChildren[] = $predicate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($anyChildren !== []) {
|
||||||
|
$allChildren[] = [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ANY',
|
||||||
|
'children' => $anyChildren,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => $allChildren,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $rule
|
||||||
|
* @param array<int,array<string,mixed>> $migrationIssues
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array
|
||||||
|
{
|
||||||
|
$ruleId = (int)($rule['id'] ?? 0);
|
||||||
|
$objectType = strtolower((string)($rule['object_type'] ?? ''));
|
||||||
|
if (!in_array($objectType, ['question', 'condition'], true)) {
|
||||||
|
$migrationIssues[] = [
|
||||||
|
'severity' => 'error',
|
||||||
|
'condition_id' => $conditionId,
|
||||||
|
'rule_id' => $ruleId,
|
||||||
|
'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.',
|
||||||
|
];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyType = strtoupper((string)($rule['type'] ?? ''));
|
||||||
|
$operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType;
|
||||||
|
if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
|
||||||
|
$migrationIssues[] = [
|
||||||
|
'severity' => 'error',
|
||||||
|
'condition_id' => $conditionId,
|
||||||
|
'rule_id' => $ruleId,
|
||||||
|
'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.',
|
||||||
|
];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'predicate',
|
||||||
|
'subject_type' => $objectType,
|
||||||
|
'subject_id' => (int)($rule['object_id'] ?? 0),
|
||||||
|
'operator' => $operator,
|
||||||
|
'legacy_rule_id' => $ruleId > 0 ? $ruleId : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function normalizeV2Config(array $config): array
|
||||||
|
{
|
||||||
|
$config['schema_version'] = self::SCHEMA_VERSION_V2;
|
||||||
|
$config['questions'] = array_values((array)($config['questions'] ?? []));
|
||||||
|
$config['conditions'] = array_values(array_map(function ($condition): array {
|
||||||
|
$condition = is_array($condition) ? $condition : [];
|
||||||
|
if (!is_array($condition['expression'] ?? null)) {
|
||||||
|
$condition['expression'] = $this->emptyV2Expression();
|
||||||
|
}
|
||||||
|
return $condition;
|
||||||
|
}, (array)($config['conditions'] ?? [])));
|
||||||
|
$config['rules'] = [];
|
||||||
|
$config['tasks'] = array_values((array)($config['tasks'] ?? []));
|
||||||
|
$config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : [];
|
||||||
|
$config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config);
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,int>
|
||||||
|
*/
|
||||||
|
protected function nextIdsForConfig(array $config): array
|
||||||
|
{
|
||||||
|
$next = [];
|
||||||
|
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task'] as $key => $name) {
|
||||||
|
$max = 0;
|
||||||
|
foreach ((array)($config[$key] ?? []) as $row) {
|
||||||
|
if (is_array($row)) {
|
||||||
|
$max = max($max, (int)($row['id'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$next[$name] = $max + 1;
|
||||||
|
}
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function emptyV2Expression(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<int,int>> $edges
|
||||||
|
* @return array<int,array<int,int>>
|
||||||
|
*/
|
||||||
|
protected function detectDirectedConditionCycles(array $edges): array
|
||||||
|
{
|
||||||
|
$cycles = [];
|
||||||
|
$visiting = [];
|
||||||
|
$visited = [];
|
||||||
|
$stack = [];
|
||||||
|
|
||||||
|
$walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void {
|
||||||
|
if (isset($visited[$conditionId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isset($visiting[$conditionId])) {
|
||||||
|
$start = array_search($conditionId, $stack, true);
|
||||||
|
$cycle = array_slice($stack, $start === false ? 0 : (int)$start);
|
||||||
|
$cycle[] = $conditionId;
|
||||||
|
$cycles[] = $cycle;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visiting[$conditionId] = true;
|
||||||
|
$stack[] = $conditionId;
|
||||||
|
foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) {
|
||||||
|
if ($nextId > 0) {
|
||||||
|
$walk($nextId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array_pop($stack);
|
||||||
|
unset($visiting[$conditionId]);
|
||||||
|
$visited[$conditionId] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (array_keys($edges) as $conditionId) {
|
||||||
|
$walk((int)$conditionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cycles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace modules\selfserve\classes;
|
||||||
|
|
||||||
|
require_once WD . '/classes/selfserve.php';
|
||||||
|
require_once WD . '/modules/edgegateway/classes/edge_gateway_manager.php';
|
||||||
|
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||||
|
require_once WD . '/objects/department_lanes_o.php';
|
||||||
|
require_once WD . '/objects/edge_gateway_relay_bindings_o.php';
|
||||||
|
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
|
use classes\selfserve;
|
||||||
|
use objects\department_lanes_o;
|
||||||
|
use objects\edge_gateway_relay_bindings_o;
|
||||||
|
|
||||||
|
class selfserve_machine_signal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalize Shelly Cloud webhook payloads and local edge gateway poll payloads.
|
||||||
|
*
|
||||||
|
* Shelly Gen2 emits switch.on/input.toggle_on webhooks and exposes Switch.GetStatus.output
|
||||||
|
* / Input.GetStatus.state for state reads. Legacy or custom payloads are accepted as long
|
||||||
|
* as they carry equivalent boolean fields.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function normalizeShellyPayload(array $payload): array
|
||||||
|
{
|
||||||
|
if (isset($payload['events']) && is_array($payload['events'])) {
|
||||||
|
foreach ((array)$payload['events'] as $eventPayload) {
|
||||||
|
if (is_array($eventPayload)) {
|
||||||
|
$payload = array_replace($payload, (array)$eventPayload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $this->firstString($payload, ['event', 'event_type', 'eventType', 'name', 'type']);
|
||||||
|
$component = $this->normalizeComponent($this->firstString($payload, ['component', 'component_id', 'componentId']));
|
||||||
|
$relayId = $this->firstString($payload, ['relay_id', 'logical_relay_id', 'logicalRelayId', 'relayId']);
|
||||||
|
$deviceId = $this->firstString($payload, ['device_id', 'deviceId', 'device']);
|
||||||
|
$channel = $this->firstInt($payload, ['channel', 'id', 'input_id', 'switch_id']);
|
||||||
|
$on = $this->extractOnState($payload);
|
||||||
|
$eventName = strtolower(trim((string)$event));
|
||||||
|
|
||||||
|
if ($component === null && str_starts_with($eventName, 'input.')) {
|
||||||
|
$component = 'input';
|
||||||
|
}
|
||||||
|
if ($component === null && str_starts_with($eventName, 'switch.')) {
|
||||||
|
$component = 'switch';
|
||||||
|
}
|
||||||
|
|
||||||
|
$positiveEvents = [
|
||||||
|
'on',
|
||||||
|
'toggle_on',
|
||||||
|
'btn_down',
|
||||||
|
'single_push',
|
||||||
|
'machine.on',
|
||||||
|
'switch.on',
|
||||||
|
'switch.toggle_on',
|
||||||
|
'input.on',
|
||||||
|
'input.toggle_on',
|
||||||
|
'input.btn_down',
|
||||||
|
'input.single_push',
|
||||||
|
];
|
||||||
|
$negativeEvents = [
|
||||||
|
'off',
|
||||||
|
'toggle_off',
|
||||||
|
'btn_up',
|
||||||
|
'machine.off',
|
||||||
|
'switch.off',
|
||||||
|
'switch.toggle_off',
|
||||||
|
'input.off',
|
||||||
|
'input.toggle_off',
|
||||||
|
'input.btn_up',
|
||||||
|
];
|
||||||
|
|
||||||
|
$eventIsOn = in_array($eventName, $positiveEvents, true);
|
||||||
|
$eventIsOff = in_array($eventName, $negativeEvents, true);
|
||||||
|
$recognized = $eventIsOn || $eventIsOff || $on !== null;
|
||||||
|
$onState = $eventIsOn || ($on === true && !$eventIsOff);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recognized' => $recognized,
|
||||||
|
'on' => $onState,
|
||||||
|
'event' => $event !== null ? (string)$event : null,
|
||||||
|
'component' => $component,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'channel' => $channel,
|
||||||
|
'source' => (string)($payload['source'] ?? 'shelly'),
|
||||||
|
'raw_status' => $this->extractStatusPayload($payload),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function recordCloudShellySignal(int $departmentId, ?int $laneId, array $payload, array $context = []): array
|
||||||
|
{
|
||||||
|
$signal = $this->normalizeShellyPayload($payload + ['source' => 'shelly_cloud']);
|
||||||
|
if (!$signal['recognized']) {
|
||||||
|
return [
|
||||||
|
'recorded' => false,
|
||||||
|
'ignored' => true,
|
||||||
|
'reason' => 'Payload does not contain a recognized Shelly ON/OFF signal.',
|
||||||
|
'signal' => $signal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$signal['on']) {
|
||||||
|
return [
|
||||||
|
'recorded' => false,
|
||||||
|
'ignored' => true,
|
||||||
|
'reason' => 'Shelly signal was recognized but it was not ON.',
|
||||||
|
'signal' => $signal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedLaneId = $this->resolveLaneId($departmentId, $laneId, $signal['relay_id'] ?? null);
|
||||||
|
$summary = (new selfserve_wash_flow())->recordMachineStartWebhook(
|
||||||
|
$resolvedLaneId,
|
||||||
|
$this->extractRegistration($payload),
|
||||||
|
$payload + [
|
||||||
|
'source' => 'shelly_cloud',
|
||||||
|
'shelly_signal' => $signal,
|
||||||
|
'context' => $context,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recorded' => true,
|
||||||
|
'ignored' => false,
|
||||||
|
'lane_id' => $resolvedLaneId,
|
||||||
|
'signal' => $signal,
|
||||||
|
'selfserve' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function recordEdgeGatewaySignal(int $gatewayId, string $agentToken, array $payload): array
|
||||||
|
{
|
||||||
|
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
||||||
|
$departmentId = (int)$gateway->department_id->value();
|
||||||
|
|
||||||
|
return $this->recordCloudShellySignal(
|
||||||
|
$departmentId,
|
||||||
|
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
||||||
|
$payload + [
|
||||||
|
'source' => 'edge_gateway',
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'source' => 'edge_gateway',
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'agent_instance_id' => $payload['agent_instance_id'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listEdgeGatewayMachineSignalMonitors(int $gatewayId, string $agentToken): array
|
||||||
|
{
|
||||||
|
$manager = new edge_gateway_manager();
|
||||||
|
$gateway = $manager->authenticateGateway($gatewayId, $agentToken);
|
||||||
|
$departmentId = (int)$gateway->department_id->value();
|
||||||
|
|
||||||
|
$bindings = [];
|
||||||
|
foreach ($this->bindingRows($gatewayId, $departmentId) as $binding) {
|
||||||
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||||
|
if ($relayId !== '') {
|
||||||
|
$bindings[$relayId] = $binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$monitors = [];
|
||||||
|
foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) {
|
||||||
|
$relayId = trim((string)$lane->relay_machine_id->value());
|
||||||
|
if ($relayId === '' || !isset($bindings[$relayId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$binding = $bindings[$relayId];
|
||||||
|
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||||
|
$component = $this->normalizeMonitorComponent((string)($metadata['machine_signal_component'] ?? $metadata['signal_component'] ?? 'input'));
|
||||||
|
$channel = (int)($metadata['machine_signal_channel'] ?? $metadata['input_channel'] ?? $binding['channel'] ?? 0);
|
||||||
|
|
||||||
|
$monitors[] = [
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'lane_id' => (int)$lane->id,
|
||||||
|
'lane_label' => (string)$lane->name->value(),
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'device_id' => (string)($metadata['machine_signal_device_id'] ?? $binding['device_id'] ?? ''),
|
||||||
|
'local_ip' => $metadata['machine_signal_local_ip'] ?? $binding['local_ip'] ?? null,
|
||||||
|
'channel' => $channel,
|
||||||
|
'component' => $component,
|
||||||
|
'expected_event' => $component === 'switch' ? 'switch.on' : 'input.toggle_on',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'monitors' => $monitors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveLaneId(int $departmentId, ?int $laneId, ?string $relayId = null): int
|
||||||
|
{
|
||||||
|
if ($laneId !== null && $laneId > 0) {
|
||||||
|
$lane = (new department_lanes_o())->select($laneId);
|
||||||
|
if (!$lane->exists()) {
|
||||||
|
throw new \RuntimeException('Department lane not found.');
|
||||||
|
}
|
||||||
|
if ((int)$lane->department->value() !== $departmentId) {
|
||||||
|
throw new \RuntimeException('The lane does not belong to the Shelly signal department.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$lane->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relayId = trim((string)$relayId);
|
||||||
|
if ($relayId !== '') {
|
||||||
|
$matches = (new department_lanes_o())->getFieldsWhere(
|
||||||
|
[
|
||||||
|
'department' => $departmentId,
|
||||||
|
'relay_machine_id' => $relayId,
|
||||||
|
'deleted_at' => null,
|
||||||
|
],
|
||||||
|
['id']
|
||||||
|
);
|
||||||
|
if ($matches !== []) {
|
||||||
|
return (int)$matches[0]['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||||
|
if (count($lanes) === 1) {
|
||||||
|
return (int)$lanes[0]->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('lane_id or relay_id is required when the department has multiple self-serve lanes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*/
|
||||||
|
private function extractRegistration(array $payload): ?string
|
||||||
|
{
|
||||||
|
$reg = $this->firstString($payload, ['reg', 'registration', 'license_plate', 'licensePlate', 'plate']);
|
||||||
|
return $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<int,string> $keys
|
||||||
|
*/
|
||||||
|
private function firstString(array $payload, array $keys): ?string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!array_key_exists($key, $payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $payload[$key];
|
||||||
|
if (is_scalar($value) && trim((string)$value) !== '') {
|
||||||
|
return trim((string)$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['params', 'data', 'status'] as $container) {
|
||||||
|
if (!isset($payload[$container]) || !is_array($payload[$container])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$match = $this->firstString((array)$payload[$container], $keys);
|
||||||
|
if ($match !== null) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<int,string> $keys
|
||||||
|
*/
|
||||||
|
private function firstInt(array $payload, array $keys): ?int
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (array_key_exists($key, $payload) && is_numeric($payload[$key])) {
|
||||||
|
return (int)$payload[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['params', 'data', 'status'] as $container) {
|
||||||
|
if (!isset($payload[$container]) || !is_array($payload[$container])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$match = $this->firstInt((array)$payload[$container], $keys);
|
||||||
|
if ($match !== null) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*/
|
||||||
|
private function extractOnState(array $payload): ?bool
|
||||||
|
{
|
||||||
|
foreach (['on', 'output', 'state', 'ison'] as $key) {
|
||||||
|
if (array_key_exists($key, $payload)) {
|
||||||
|
return $this->boolValue($payload[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['input', 'switch', 'params', 'data', 'status'] as $key) {
|
||||||
|
if (!isset($payload[$key]) || !is_array($payload[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $this->extractOnState((array)$payload[$key]);
|
||||||
|
if ($value !== null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['input:0', 'switch:0'] as $componentKey) {
|
||||||
|
if (!isset($payload[$componentKey]) || !is_array($payload[$componentKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $this->extractOnState((array)$payload[$componentKey]);
|
||||||
|
if ($value !== null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function boolValue(mixed $value): ?bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (int)$value === 1;
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeComponent(?string $component): ?string
|
||||||
|
{
|
||||||
|
$component = strtolower(trim((string)$component));
|
||||||
|
if ($component === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (str_starts_with($component, 'input')) {
|
||||||
|
return 'input';
|
||||||
|
}
|
||||||
|
if (str_starts_with($component, 'switch') || str_starts_with($component, 'relay')) {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeMonitorComponent(string $component): string
|
||||||
|
{
|
||||||
|
return $this->normalizeComponent($component) === 'switch' ? 'switch' : 'input';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function extractStatusPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
if (isset($payload['status']) && is_array($payload['status'])) {
|
||||||
|
return (array)$payload['status'];
|
||||||
|
}
|
||||||
|
if (isset($payload['raw']) && is_array($payload['raw'])) {
|
||||||
|
return (array)$payload['raw'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function bindingRows(int $gatewayId, int $departmentId): array
|
||||||
|
{
|
||||||
|
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere(
|
||||||
|
[
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'deleted_at' => null,
|
||||||
|
],
|
||||||
|
['id']
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn(array $row): array => (new edge_gateway_relay_bindings_o())->select((int)$row['id'])->asArray(),
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace modules\selfserve\classes;
|
||||||
|
|
||||||
|
require_once WD . '/classes/db.php';
|
||||||
|
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||||
|
|
||||||
|
use classes\db;
|
||||||
|
use classes\selfserve_schema_bootstrap;
|
||||||
|
|
||||||
|
class selfserve_virtual_hardware
|
||||||
|
{
|
||||||
|
public const SCHEMA_VERSION = 1;
|
||||||
|
public const DEFAULT_GATEWAY_KEY = 'virtual-main';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
selfserve_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function getConfig(int $departmentId): array
|
||||||
|
{
|
||||||
|
$pdo = db::getPDO();
|
||||||
|
$statement = $pdo->prepare(
|
||||||
|
'SELECT config_json
|
||||||
|
FROM department_selfserve_studio_virtual_hardware
|
||||||
|
WHERE department_id = :department_id AND deleted_at IS NULL
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([':department_id' => $departmentId]);
|
||||||
|
$row = $statement->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return $this->emptyConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)($row['config_json'] ?? '{}'), true);
|
||||||
|
return $this->normalizeConfig(is_array($decoded) ? $decoded : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function saveConfig(int $departmentId, array $config, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeConfig($config);
|
||||||
|
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new \RuntimeException('Unable to encode virtual hardware config.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db::getPDO();
|
||||||
|
$statement = $pdo->prepare(
|
||||||
|
'INSERT INTO department_selfserve_studio_virtual_hardware
|
||||||
|
(department_id, config_json, created_by, updated_by, deleted_at)
|
||||||
|
VALUES
|
||||||
|
(:department_id, :config_json, :created_by, :updated_by, NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_json = VALUES(config_json),
|
||||||
|
updated_by = VALUES(updated_by),
|
||||||
|
deleted_at = NULL'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
':department_id' => $departmentId,
|
||||||
|
':config_json' => $json,
|
||||||
|
':created_by' => $userId,
|
||||||
|
':updated_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<string,mixed> $realWorkspace
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array
|
||||||
|
{
|
||||||
|
$config = $this->getConfig($departmentId);
|
||||||
|
$operation = strtolower(trim($operation));
|
||||||
|
|
||||||
|
if ($operation === 'generate_from_lanes') {
|
||||||
|
$config = $this->generateFromLanes($realWorkspace, $config);
|
||||||
|
} elseif ($operation === 'upsert_gateway') {
|
||||||
|
$config = $this->upsertGateway($config, $payload);
|
||||||
|
} elseif ($operation === 'upsert_binding') {
|
||||||
|
$config = $this->upsertBinding($config, $payload, $realWorkspace);
|
||||||
|
} elseif ($operation === 'delete_binding') {
|
||||||
|
$config = $this->deleteBinding($config, $payload);
|
||||||
|
} elseif ($operation === 'reset') {
|
||||||
|
$config = $this->emptyConfig();
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->saveConfig($departmentId, $config, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function mergeWorkspace(array $workspace, int $departmentId): array
|
||||||
|
{
|
||||||
|
return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure merge helper used by graph serialization and tests.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function mergeWorkspaceWithConfig(array $workspace, array $config): array
|
||||||
|
{
|
||||||
|
$config = $this->normalizeConfig($config);
|
||||||
|
$workspace += [
|
||||||
|
'gateways' => [],
|
||||||
|
'relays' => [],
|
||||||
|
'lanes' => [],
|
||||||
|
'issues' => [],
|
||||||
|
'actions' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$virtualBindings = (array)($config['bindings'] ?? []);
|
||||||
|
$enabled = (bool)($config['enabled'] ?? true);
|
||||||
|
if (!$enabled || $virtualBindings === []) {
|
||||||
|
$workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0);
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
$realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? []));
|
||||||
|
$virtualGateways = $this->virtualGatewaysForWorkspace($config);
|
||||||
|
$virtualRelays = $this->virtualRelaysForWorkspace($config);
|
||||||
|
$bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways);
|
||||||
|
|
||||||
|
$workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways));
|
||||||
|
$workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays);
|
||||||
|
$workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId);
|
||||||
|
|
||||||
|
$coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true);
|
||||||
|
$workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways);
|
||||||
|
$workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds);
|
||||||
|
$workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace);
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public function validationWarnings(array $workspace): array
|
||||||
|
{
|
||||||
|
$virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : [];
|
||||||
|
if (($virtual['has_virtual_hardware'] ?? false) !== true) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings = [
|
||||||
|
'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.',
|
||||||
|
];
|
||||||
|
$virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []);
|
||||||
|
if ($virtualOnlyRelays !== []) {
|
||||||
|
$warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @param array<string,mixed>|null $baseConfig
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function generateFromLanes(array $workspace, ?array $baseConfig = null): array
|
||||||
|
{
|
||||||
|
$config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig());
|
||||||
|
$gatewayKey = self::DEFAULT_GATEWAY_KEY;
|
||||||
|
$config = $this->upsertGateway($config, [
|
||||||
|
'key' => $gatewayKey,
|
||||||
|
'label' => 'Virtual Studio Gateway',
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
|
||||||
|
if (!is_array($lane)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($slot['relay_id'] ?? ''));
|
||||||
|
$role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? '');
|
||||||
|
if ($relayId === '' || $role === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$config = $this->upsertBinding($config, [
|
||||||
|
'gateway_key' => $gatewayKey,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'role' => $role,
|
||||||
|
'services' => [$role],
|
||||||
|
'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role,
|
||||||
|
'lane_id' => (int)($lane['id'] ?? 0),
|
||||||
|
'slot' => $role,
|
||||||
|
'generated' => true,
|
||||||
|
], $workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function emptyConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'schema_version' => self::SCHEMA_VERSION,
|
||||||
|
'enabled' => true,
|
||||||
|
'gateways' => [],
|
||||||
|
'relays' => [],
|
||||||
|
'bindings' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function normalizeConfig(array $config): array
|
||||||
|
{
|
||||||
|
$normalized = $this->emptyConfig();
|
||||||
|
$normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION);
|
||||||
|
$normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
|
||||||
|
|
||||||
|
foreach ((array)($config['gateways'] ?? []) as $gateway) {
|
||||||
|
if (!is_array($gateway)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '');
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalized['gateways'][$key] = [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))),
|
||||||
|
'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
||||||
|
'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((array)($config['relays'] ?? []) as $relay) {
|
||||||
|
if (!is_array($relay)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
|
||||||
|
if ($relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalized['relays'][$relayId] = [
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))),
|
||||||
|
'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindings = [];
|
||||||
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
||||||
|
if (!is_array($binding)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
|
||||||
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||||
|
$role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? '');
|
||||||
|
if ($gatewayKey === '' || $relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($normalized['gateways'][$gatewayKey])) {
|
||||||
|
$normalized['gateways'][$gatewayKey] = [
|
||||||
|
'key' => $gatewayKey,
|
||||||
|
'label' => 'Virtual Gateway ' . $gatewayKey,
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
'metadata' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!isset($normalized['relays'][$relayId])) {
|
||||||
|
$normalized['relays'][$relayId] = [
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))),
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : []));
|
||||||
|
if ($services === [] && $role !== '') {
|
||||||
|
$services = [$role];
|
||||||
|
}
|
||||||
|
$id = $this->bindingId($gatewayKey, $relayId, $role);
|
||||||
|
$bindings[$id] = [
|
||||||
|
'id' => $id,
|
||||||
|
'gateway_key' => $gatewayKey,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'role' => $role,
|
||||||
|
'slot' => $role,
|
||||||
|
'services' => $services,
|
||||||
|
'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
|
||||||
|
'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0,
|
||||||
|
'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null,
|
||||||
|
'generated' => (bool)($binding['generated'] ?? false),
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized['gateways'] = array_values($normalized['gateways']);
|
||||||
|
$normalized['relays'] = array_values($normalized['relays']);
|
||||||
|
$normalized['bindings'] = array_values($bindings);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function upsertGateway(array $config, array $payload): array
|
||||||
|
{
|
||||||
|
$config = $this->normalizeConfig($config);
|
||||||
|
$key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY);
|
||||||
|
if ($key === '') {
|
||||||
|
throw new \RuntimeException('Virtual gateway key is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateways = [];
|
||||||
|
foreach ((array)$config['gateways'] as $gateway) {
|
||||||
|
$gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway;
|
||||||
|
}
|
||||||
|
$gateways[$key] = [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))),
|
||||||
|
'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
||||||
|
'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []),
|
||||||
|
];
|
||||||
|
$config['gateways'] = array_values($gateways);
|
||||||
|
|
||||||
|
return $this->normalizeConfig($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function upsertBinding(array $config, array $payload, array $workspace): array
|
||||||
|
{
|
||||||
|
$config = $this->normalizeConfig($config);
|
||||||
|
$gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
|
||||||
|
$relayId = trim((string)($payload['relay_id'] ?? ''));
|
||||||
|
$role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? '');
|
||||||
|
if ($gatewayKey === '' || $relayId === '') {
|
||||||
|
throw new \RuntimeException('Virtual gateway key and relay id are required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->upsertGateway($config, ['key' => $gatewayKey]);
|
||||||
|
$services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : []));
|
||||||
|
if ($services === [] && $role !== '') {
|
||||||
|
$services = [$role];
|
||||||
|
}
|
||||||
|
$binding = [
|
||||||
|
'id' => $this->bindingId($gatewayKey, $relayId, $role),
|
||||||
|
'gateway_key' => $gatewayKey,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'role' => $role,
|
||||||
|
'slot' => $role,
|
||||||
|
'services' => $services,
|
||||||
|
'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
|
||||||
|
'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0,
|
||||||
|
'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId),
|
||||||
|
'generated' => (bool)($payload['generated'] ?? false),
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$bindings = [];
|
||||||
|
foreach ((array)$config['bindings'] as $existing) {
|
||||||
|
if (!is_array($existing)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing;
|
||||||
|
}
|
||||||
|
$bindings[$binding['id']] = $binding;
|
||||||
|
$config['bindings'] = array_values($bindings);
|
||||||
|
$config['relays'][] = [
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))),
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->normalizeConfig($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function deleteBinding(array $config, array $payload): array
|
||||||
|
{
|
||||||
|
$config = $this->normalizeConfig($config);
|
||||||
|
$id = trim((string)($payload['id'] ?? ''));
|
||||||
|
if ($id === '') {
|
||||||
|
$id = $this->bindingId(
|
||||||
|
$this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY),
|
||||||
|
trim((string)($payload['relay_id'] ?? '')),
|
||||||
|
$this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool {
|
||||||
|
return (string)($binding['id'] ?? '') !== $id;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return $this->normalizeConfig($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function virtualGatewaysForWorkspace(array $config): array
|
||||||
|
{
|
||||||
|
$bindingsByGateway = [];
|
||||||
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
||||||
|
if (!is_array($binding)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY);
|
||||||
|
$bindingsByGateway[$gatewayKey][] = [
|
||||||
|
...$binding,
|
||||||
|
'gateway_id' => $gatewayKey,
|
||||||
|
'gateway_key' => $gatewayKey,
|
||||||
|
'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []),
|
||||||
|
'virtual' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateways = [];
|
||||||
|
foreach ((array)($config['gateways'] ?? []) as $gateway) {
|
||||||
|
if (!is_array($gateway)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY);
|
||||||
|
$bindings = array_values($bindingsByGateway[$key] ?? []);
|
||||||
|
if ($bindings === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gateways[] = [
|
||||||
|
'id' => $key,
|
||||||
|
'key' => $key,
|
||||||
|
'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)),
|
||||||
|
'status' => (string)($gateway['status'] ?? 'VIRTUAL'),
|
||||||
|
'virtual' => true,
|
||||||
|
'studio_only' => true,
|
||||||
|
'bindings' => $bindings,
|
||||||
|
'metadata' => (array)($gateway['metadata'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $gateways;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function virtualRelaysForWorkspace(array $config): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(static function (array $relay): array {
|
||||||
|
return [
|
||||||
|
...$relay,
|
||||||
|
'virtual' => true,
|
||||||
|
'studio_only' => true,
|
||||||
|
];
|
||||||
|
}, (array)($config['relays'] ?? [])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $gateways
|
||||||
|
* @return array<string,array<int,array<string,mixed>>>
|
||||||
|
*/
|
||||||
|
private function indexVirtualBindingsByRelayId(array $gateways): array
|
||||||
|
{
|
||||||
|
$bindings = [];
|
||||||
|
foreach ($gateways as $gateway) {
|
||||||
|
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
|
||||||
|
if (!is_array($binding)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||||
|
if ($relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bindings[$relayId][] = [
|
||||||
|
...$binding,
|
||||||
|
'gateway_id' => (string)($gateway['id'] ?? ''),
|
||||||
|
'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'),
|
||||||
|
'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'),
|
||||||
|
'virtual' => true,
|
||||||
|
'studio_only' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $realRelays
|
||||||
|
* @param array<int,array<string,mixed>> $virtualRelays
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function mergeRelays(array $realRelays, array $virtualRelays): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach (array_merge($realRelays, $virtualRelays) as $relay) {
|
||||||
|
if (!is_array($relay)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
|
||||||
|
if ($relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay;
|
||||||
|
}
|
||||||
|
return array_values($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $lanes
|
||||||
|
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array
|
||||||
|
{
|
||||||
|
foreach ($lanes as $laneIndex => $lane) {
|
||||||
|
if (!is_array($lane)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$required = 0;
|
||||||
|
$bound = 0;
|
||||||
|
foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) {
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$required += 1;
|
||||||
|
$relayId = trim((string)($slot['relay_id'] ?? ''));
|
||||||
|
$covered = (bool)($slot['coverage']['covered'] ?? false);
|
||||||
|
if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) {
|
||||||
|
$slot['coverage'] = [
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'covered' => true,
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
'binding_count' => count($bindingsByRelayId[$relayId]),
|
||||||
|
'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null,
|
||||||
|
'bindings' => $bindingsByRelayId[$relayId],
|
||||||
|
'virtual' => true,
|
||||||
|
'studio_only' => true,
|
||||||
|
];
|
||||||
|
$slot['virtual'] = true;
|
||||||
|
$covered = true;
|
||||||
|
}
|
||||||
|
if ($covered) {
|
||||||
|
$bound += 1;
|
||||||
|
}
|
||||||
|
$lane['relay_slots'][$slotIndex] = $slot;
|
||||||
|
}
|
||||||
|
$lane['binding_coverage'] = [
|
||||||
|
'required' => $required,
|
||||||
|
'bound' => $bound,
|
||||||
|
'missing' => max(0, $required - $bound),
|
||||||
|
'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'),
|
||||||
|
'virtual' => $this->laneHasVirtualCoverage($lane),
|
||||||
|
];
|
||||||
|
$lanes[$laneIndex] = $lane;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lanes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $lane
|
||||||
|
*/
|
||||||
|
private function laneHasVirtualCoverage(array $lane): bool
|
||||||
|
{
|
||||||
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
||||||
|
if (is_array($slot) && !empty($slot['coverage']['virtual'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $issues
|
||||||
|
* @param array<string,bool> $coveredRelayIds
|
||||||
|
* @param array<int,array<string,mixed>> $virtualGateways
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array
|
||||||
|
{
|
||||||
|
$filtered = [];
|
||||||
|
foreach ($issues as $issue) {
|
||||||
|
if (!is_array($issue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$code = strtoupper((string)($issue['code'] ?? ''));
|
||||||
|
if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) {
|
||||||
|
$filtered[] = [
|
||||||
|
...$issue,
|
||||||
|
'severity' => 'info',
|
||||||
|
'virtual' => true,
|
||||||
|
'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) {
|
||||||
|
$filtered[] = [
|
||||||
|
...$issue,
|
||||||
|
'severity' => 'warning',
|
||||||
|
'virtual' => true,
|
||||||
|
'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filtered[] = $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered[] = [
|
||||||
|
'severity' => 'warning',
|
||||||
|
'code' => 'VIRTUAL_HARDWARE_ACTIVE',
|
||||||
|
'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.',
|
||||||
|
'virtual' => true,
|
||||||
|
'relay_ids' => array_keys($coveredRelayIds),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $summary
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function mergeSummary(array $summary, array $workspace): array
|
||||||
|
{
|
||||||
|
$virtual = (array)($workspace['virtual'] ?? []);
|
||||||
|
$summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0);
|
||||||
|
$summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0);
|
||||||
|
$summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false);
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @param array<string,bool> $realBindingRelayIds
|
||||||
|
*/
|
||||||
|
private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array
|
||||||
|
{
|
||||||
|
$relayIds = [];
|
||||||
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
||||||
|
if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') {
|
||||||
|
$relayId = trim((string)$binding['relay_id']);
|
||||||
|
if (!isset($realBindingRelayIds[$relayId])) {
|
||||||
|
$relayIds[] = $relayId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION),
|
||||||
|
'enabled' => (bool)($config['enabled'] ?? true),
|
||||||
|
'has_virtual_hardware' => $bindingCount > 0,
|
||||||
|
'gateway_count' => $gatewayCount,
|
||||||
|
'binding_count' => $bindingCount,
|
||||||
|
'virtual_only_relay_ids' => array_values(array_unique($relayIds)),
|
||||||
|
'config' => $config,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $gateways
|
||||||
|
* @return array<string,bool>
|
||||||
|
*/
|
||||||
|
private function realBindingRelayIds(array $gateways): array
|
||||||
|
{
|
||||||
|
$relayIds = [];
|
||||||
|
foreach ($gateways as $gateway) {
|
||||||
|
if (!is_array($gateway) || !empty($gateway['virtual'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
|
||||||
|
if (!is_array($binding)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||||
|
if ($relayId !== '') {
|
||||||
|
$relayIds[$relayId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $relayIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
*/
|
||||||
|
private function laneIdForRelay(array $workspace, string $relayId): ?int
|
||||||
|
{
|
||||||
|
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
|
||||||
|
if (!is_array($lane)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
||||||
|
if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) {
|
||||||
|
return (int)($lane['id'] ?? 0) ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeGatewayKey(mixed $value): string
|
||||||
|
{
|
||||||
|
return trim((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRole(mixed $value): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim((string)$value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private function normalizeServiceList(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
$value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value);
|
||||||
|
}
|
||||||
|
if (!is_array($value)) {
|
||||||
|
$value = [$value];
|
||||||
|
}
|
||||||
|
|
||||||
|
$services = [];
|
||||||
|
foreach ($value as $entry) {
|
||||||
|
if (is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$service = $this->normalizeRole($entry);
|
||||||
|
if ($service !== '') {
|
||||||
|
$services[$service] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_keys($services);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bindingId(string $gatewayKey, string $relayId, string $role): string
|
||||||
|
{
|
||||||
|
return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
|
||||||
|
{
|
||||||
|
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||||
|
$session = $normalizedReg !== null
|
||||||
|
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
|
||||||
|
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
||||||
|
|
||||||
|
return $session->exists() && (bool)$session->machine_start_triggered->value();
|
||||||
|
}
|
||||||
|
|
||||||
protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void
|
protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -404,12 +414,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$configSource = (string)($options['config_source'] ?? 'published');
|
$configSource = (string)($options['config_source'] ?? 'published');
|
||||||
$publishedConfigVersionId = $options['config_version_id'] ?? null;
|
$publishedConfigVersionId = $options['config_version_id'] ?? null;
|
||||||
$publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null;
|
$publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null;
|
||||||
|
$versioning = new selfserve_config_versioning();
|
||||||
if ($publishedConfigPayload === null) {
|
if ($publishedConfigPayload === null) {
|
||||||
$publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId);
|
$publishedConfig = $versioning->getPublishedV2Config($departmentId);
|
||||||
$publishedConfigVersionId = $publishedConfig['version_id'] ?? null;
|
$publishedConfigVersionId = $publishedConfig['version_id'] ?? null;
|
||||||
$publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null;
|
$publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null;
|
||||||
$configSource = 'published';
|
$configSource = $publishedConfigPayload === null ? 'legacy' : 'published';
|
||||||
}
|
}
|
||||||
|
$isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload);
|
||||||
$vehicle = $this->findVehicleByRegistration($normalizedReg);
|
$vehicle = $this->findVehicleByRegistration($normalizedReg);
|
||||||
$vehicleData = $vehicle?->asArray();
|
$vehicleData = $vehicle?->asArray();
|
||||||
$vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
|
$vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
|
||||||
@@ -423,7 +435,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []);
|
$answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []);
|
||||||
$answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides);
|
$answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides);
|
||||||
$answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides);
|
$answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides);
|
||||||
$visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers);
|
if ($isV2Config) {
|
||||||
|
$visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers);
|
||||||
|
$visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []);
|
||||||
|
$visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []);
|
||||||
|
} else {
|
||||||
|
$visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers);
|
||||||
|
$visibilityExpressionTrace = [];
|
||||||
|
}
|
||||||
|
|
||||||
$visibleQuestions = [];
|
$visibleQuestions = [];
|
||||||
$visibleQuestionIds = [];
|
$visibleQuestionIds = [];
|
||||||
@@ -447,7 +466,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
|
|
||||||
usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);
|
usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);
|
||||||
$visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds);
|
$visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds);
|
||||||
$serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers);
|
if ($isV2Config) {
|
||||||
|
$serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers);
|
||||||
|
$serviceConditionResults = (array)($serviceEvaluation['results'] ?? []);
|
||||||
|
$serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []);
|
||||||
|
} else {
|
||||||
|
$serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers);
|
||||||
|
$serviceExpressionTrace = [];
|
||||||
|
}
|
||||||
|
|
||||||
$tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload);
|
$tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload);
|
||||||
$activeTasks = [];
|
$activeTasks = [];
|
||||||
@@ -499,8 +525,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'gate_ref_id' => $typedGateRefId,
|
'gate_ref_id' => $typedGateRefId,
|
||||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||||
'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
||||||
'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)),
|
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||||
'dynamic_images_vehicle_type' => (int)($task['dynamic_images_vehicle_type'] ?? 0),
|
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);
|
usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);
|
||||||
@@ -559,6 +585,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'evaluation_trace' => [
|
'evaluation_trace' => [
|
||||||
'visibility_condition_results' => $visibilityConditionResults,
|
'visibility_condition_results' => $visibilityConditionResults,
|
||||||
'condition_results' => $serviceConditionResults,
|
'condition_results' => $serviceConditionResults,
|
||||||
|
'visibility_expression_traces' => $visibilityExpressionTrace,
|
||||||
|
'condition_expression_traces' => $serviceExpressionTrace,
|
||||||
'task_gates' => $taskGateTrace,
|
'task_gates' => $taskGateTrace,
|
||||||
'visible_question_ids' => $visibleQuestionIds,
|
'visible_question_ids' => $visibleQuestionIds,
|
||||||
],
|
],
|
||||||
@@ -692,6 +720,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'customer_number' => $snapshot['customer_number'],
|
'customer_number' => $snapshot['customer_number'],
|
||||||
'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'),
|
'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'),
|
||||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||||
|
'hardware_mode' => $options['hardware_mode'] ?? 'studio',
|
||||||
'mode' => 'full_dry_run',
|
'mode' => 'full_dry_run',
|
||||||
'dry_run' => true,
|
'dry_run' => true,
|
||||||
],
|
],
|
||||||
@@ -701,6 +730,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'rules' => $rules,
|
'rules' => $rules,
|
||||||
'tasks' => $tasks,
|
'tasks' => $tasks,
|
||||||
'hardware' => $hardware,
|
'hardware' => $hardware,
|
||||||
|
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||||
'graph_annotations' => $annotations,
|
'graph_annotations' => $annotations,
|
||||||
'recommendations' => $recommendations,
|
'recommendations' => $recommendations,
|
||||||
];
|
];
|
||||||
@@ -893,6 +923,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array
|
protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array
|
||||||
{
|
{
|
||||||
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : [];
|
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : [];
|
||||||
|
$expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : [];
|
||||||
$cycleIds = $this->detectConditionCycles($conditions, $rules);
|
$cycleIds = $this->detectConditionCycles($conditions, $rules);
|
||||||
$rulesByCondition = [];
|
$rulesByCondition = [];
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
@@ -906,6 +937,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$result = ($conditionResults[$conditionId] ?? false) === true;
|
$result = ($conditionResults[$conditionId] ?? false) === true;
|
||||||
|
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
||||||
|
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
||||||
|
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'id' => $conditionId,
|
'id' => $conditionId,
|
||||||
'node_id' => 'condition:' . $conditionId,
|
'node_id' => 'condition:' . $conditionId,
|
||||||
@@ -914,10 +948,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'state' => $result ? 'passed' : 'failed',
|
'state' => $result ? 'passed' : 'failed',
|
||||||
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
||||||
'rules' => array_values($rulesByCondition[$conditionId] ?? []),
|
'rules' => array_values($rulesByCondition[$conditionId] ?? []),
|
||||||
|
'expression' => $expression,
|
||||||
|
'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups),
|
||||||
|
'expression_trace' => $expressionTrace,
|
||||||
|
'next_fix' => $nextFix,
|
||||||
'has_cycle' => in_array($conditionId, $cycleIds, true),
|
'has_cycle' => in_array($conditionId, $cycleIds, true),
|
||||||
'reason' => in_array($conditionId, $cycleIds, true)
|
'reason' => in_array($conditionId, $cycleIds, true)
|
||||||
? 'Condition dependency cycle detected.'
|
? 'Condition dependency cycle detected.'
|
||||||
: ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.'),
|
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,7 +1008,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
||||||
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
||||||
'services' => $services,
|
'services' => $services,
|
||||||
'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)),
|
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||||
|
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||||
'relay_bindings' => $bindings,
|
'relay_bindings' => $bindings,
|
||||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||||
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
||||||
@@ -1012,6 +1051,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots);
|
||||||
|
|
||||||
$dryRunOperations = [];
|
$dryRunOperations = [];
|
||||||
if (($snapshot['allowed'] ?? false) === true) {
|
if (($snapshot['allowed'] ?? false) === true) {
|
||||||
@@ -1037,11 +1077,276 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])),
|
'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])),
|
||||||
'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])),
|
'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])),
|
||||||
'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false),
|
'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false),
|
||||||
|
'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [],
|
||||||
|
'signal_timeline' => $signalTimeline,
|
||||||
'dry_run_operations' => $dryRunOperations,
|
'dry_run_operations' => $dryRunOperations,
|
||||||
'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups),
|
'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $snapshot
|
||||||
|
* @param array<string,mixed> $gatewayWorkspace
|
||||||
|
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
|
||||||
|
* @param array<int,array<string,mixed>> $laneRelaySlots
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots): array
|
||||||
|
{
|
||||||
|
$timeline = [];
|
||||||
|
$sequence = 1;
|
||||||
|
$allowed = (bool)($snapshot['allowed'] ?? false);
|
||||||
|
$machineAvailable = (bool)($snapshot['machine_available'] ?? false);
|
||||||
|
|
||||||
|
$timeline[] = $this->debugSignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'eligibility_sync',
|
||||||
|
'session_event',
|
||||||
|
'SESSION',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[
|
||||||
|
'event' => 'SESSION_SYNCED',
|
||||||
|
'allowed' => $allowed,
|
||||||
|
'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])),
|
||||||
|
],
|
||||||
|
'none',
|
||||||
|
'sent',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
$machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot);
|
||||||
|
$machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE');
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'eligibility_sync',
|
||||||
|
'relay_switch',
|
||||||
|
'MACHINE',
|
||||||
|
$machineRelayId,
|
||||||
|
$machineBinding,
|
||||||
|
['id' => $machineRelayId, 'channel' => 0, 'on' => true],
|
||||||
|
$allowed && $machineAvailable,
|
||||||
|
$allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'machine_start_signal',
|
||||||
|
'shelly_event',
|
||||||
|
'MACHINE',
|
||||||
|
$machineRelayId,
|
||||||
|
$machineBinding,
|
||||||
|
[
|
||||||
|
'event' => 'input.toggle_on',
|
||||||
|
'alternate_event' => 'switch.on',
|
||||||
|
'input' => ['component' => 'input:0', 'state' => true],
|
||||||
|
'switch' => ['component' => 'switch:0', 'output' => true],
|
||||||
|
'bill_machine_wash' => true,
|
||||||
|
],
|
||||||
|
$allowed && $machineAvailable,
|
||||||
|
$allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot);
|
||||||
|
$cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER');
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'machine_start',
|
||||||
|
'relay_switch',
|
||||||
|
'CLEANER',
|
||||||
|
$cleanerRelayId,
|
||||||
|
$cleanerBinding,
|
||||||
|
['id' => $cleanerRelayId, 'channel' => 0, 'on' => true],
|
||||||
|
$allowed && $cleanerRelayId !== null,
|
||||||
|
$allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot);
|
||||||
|
$exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT');
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'stop',
|
||||||
|
'relay_pulse',
|
||||||
|
'EXIT',
|
||||||
|
$exitRelayId,
|
||||||
|
$exitBinding,
|
||||||
|
['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1],
|
||||||
|
$allowed && $exitRelayId !== null,
|
||||||
|
$allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'stop',
|
||||||
|
'relay_switch',
|
||||||
|
'CLEANER',
|
||||||
|
$cleanerRelayId,
|
||||||
|
$cleanerBinding,
|
||||||
|
['id' => $cleanerRelayId, 'channel' => 0, 'on' => false],
|
||||||
|
$allowed && $cleanerRelayId !== null,
|
||||||
|
$allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$timeline[] = $this->debugRelaySignalTimelineRow(
|
||||||
|
$sequence++,
|
||||||
|
'stop',
|
||||||
|
'relay_switch',
|
||||||
|
'MACHINE',
|
||||||
|
$machineRelayId,
|
||||||
|
$machineBinding,
|
||||||
|
['id' => $machineRelayId, 'channel' => 0, 'on' => false],
|
||||||
|
$machineRelayId !== null,
|
||||||
|
$machineRelayId === null ? 'Lane machine relay is not configured.' : null
|
||||||
|
);
|
||||||
|
|
||||||
|
$timeline[] = $this->debugSignalTimelineRow(
|
||||||
|
$sequence,
|
||||||
|
'session_completion',
|
||||||
|
'session_event',
|
||||||
|
'SESSION',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[
|
||||||
|
'event' => 'SESSION_COMPLETED',
|
||||||
|
'reset_lane_state' => true,
|
||||||
|
],
|
||||||
|
'none',
|
||||||
|
$allowed ? 'sent' : 'skipped',
|
||||||
|
$allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $snapshot
|
||||||
|
* @param array<int,array<string,mixed>> $laneRelaySlots
|
||||||
|
*/
|
||||||
|
protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string
|
||||||
|
{
|
||||||
|
$role = strtoupper(trim($role));
|
||||||
|
foreach ($laneRelaySlots as $slot) {
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '')));
|
||||||
|
$relayId = trim((string)($slot['relay_id'] ?? ''));
|
||||||
|
if ($slotRole === $role && $relayId !== '') {
|
||||||
|
return $relayId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : [];
|
||||||
|
$field = match ($role) {
|
||||||
|
'ENTRY' => 'relay_in_id',
|
||||||
|
'EXIT' => 'relay_out_id',
|
||||||
|
'MACHINE' => 'relay_machine_id',
|
||||||
|
'PROGRAM_PICKER' => 'relay_machine_program_picker_id',
|
||||||
|
'CLEANER' => 'relay_machine_cleaner_id',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
$relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : '';
|
||||||
|
return $relayId !== '' ? $relayId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $gatewayWorkspace
|
||||||
|
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array
|
||||||
|
{
|
||||||
|
if ($relayId === null || trim($relayId) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$role = strtoupper(trim($role));
|
||||||
|
|
||||||
|
foreach ((array)($bindingsByService[$role] ?? []) as $binding) {
|
||||||
|
if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) {
|
||||||
|
return $binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) {
|
||||||
|
if ((string)($binding['relay_id'] ?? '') === $relayId) {
|
||||||
|
return $binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $binding|null
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function debugRelaySignalTimelineRow(
|
||||||
|
int $sequence,
|
||||||
|
string $runtimeStage,
|
||||||
|
string $signalType,
|
||||||
|
string $relayRole,
|
||||||
|
?string $relayId,
|
||||||
|
?array $binding,
|
||||||
|
array $payload,
|
||||||
|
bool $eligible,
|
||||||
|
?string $reason
|
||||||
|
): array {
|
||||||
|
if ($relayId === null || trim($relayId) === '') {
|
||||||
|
return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason);
|
||||||
|
}
|
||||||
|
if (!$eligible) {
|
||||||
|
return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason);
|
||||||
|
}
|
||||||
|
if ($binding === null) {
|
||||||
|
return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real';
|
||||||
|
return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|null $binding
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function debugSignalTimelineRow(
|
||||||
|
int $sequence,
|
||||||
|
string $runtimeStage,
|
||||||
|
string $signalType,
|
||||||
|
string $relayRole,
|
||||||
|
?string $relayId,
|
||||||
|
?array $binding,
|
||||||
|
array $payload,
|
||||||
|
string $source,
|
||||||
|
string $predictedStatus,
|
||||||
|
?string $reason
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'sequence' => $sequence,
|
||||||
|
'runtime_stage' => $runtimeStage,
|
||||||
|
'signal_type' => $signalType,
|
||||||
|
'relay_role' => $relayRole,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'target_gateway' => $binding['gateway_id'] ?? null,
|
||||||
|
'target_gateway_label' => $binding['gateway_label'] ?? null,
|
||||||
|
'target_binding' => $binding['node_id'] ?? null,
|
||||||
|
'target_binding_label' => $binding['relay_label'] ?? null,
|
||||||
|
'transport' => match ($signalType) {
|
||||||
|
'session_event' => 'selfserve_wash_session_events',
|
||||||
|
'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event',
|
||||||
|
default => '/v2/devices/api/set/switch',
|
||||||
|
},
|
||||||
|
'payload' => $payload,
|
||||||
|
'source' => $source,
|
||||||
|
'virtual' => $source === 'virtual',
|
||||||
|
'predicted_status' => $predictedStatus,
|
||||||
|
'skip_block_reason' => $reason,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $snapshot
|
* @param array<string,mixed> $snapshot
|
||||||
* @param array<int,array<string,mixed>> $questions
|
* @param array<int,array<string,mixed>> $questions
|
||||||
@@ -1393,6 +1698,82 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return array_values($cycles);
|
return array_values($cycles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $expression
|
||||||
|
* @param array<string,mixed> $lookups
|
||||||
|
*/
|
||||||
|
protected function debugExpressionSummary(array $expression, array $lookups): string
|
||||||
|
{
|
||||||
|
$type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
|
||||||
|
if ($type === 'predicate') {
|
||||||
|
$subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
|
||||||
|
$subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
|
||||||
|
$operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE'));
|
||||||
|
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||||
|
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||||
|
return $label . ' ' . strtolower(str_replace('_', ' ', $operator));
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
|
||||||
|
$children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
|
||||||
|
if ($children === []) {
|
||||||
|
return 'No predicates';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
foreach (array_slice($children, 0, 3) as $child) {
|
||||||
|
if (is_array($child)) {
|
||||||
|
$parts[] = $this->debugExpressionSummary((array)$child, $lookups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($children) > 3) {
|
||||||
|
$parts[] = '+' . (count($children) - 3) . ' more';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $trace
|
||||||
|
* @param array<string,mixed> $lookups
|
||||||
|
*/
|
||||||
|
protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string
|
||||||
|
{
|
||||||
|
$failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace));
|
||||||
|
if ($failed === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
|
||||||
|
$subjectId = (int)($failed['subject_id'] ?? 0);
|
||||||
|
$operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE'));
|
||||||
|
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||||
|
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||||
|
|
||||||
|
return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $trace
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected function firstFailedPredicateTrace(array $trace): ?array
|
||||||
|
{
|
||||||
|
if (($trace['type'] ?? '') === 'predicate') {
|
||||||
|
return (($trace['result'] ?? false) === true) ? null : $trace;
|
||||||
|
}
|
||||||
|
foreach ((array)($trace['children'] ?? []) as $child) {
|
||||||
|
if (!is_array($child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$failed = $this->firstFailedPredicateTrace((array)$child);
|
||||||
|
if ($failed !== null) {
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $workspace
|
* @param array<string,mixed> $workspace
|
||||||
* @return array<string,array<int,string>>
|
* @return array<string,array<int,string>>
|
||||||
@@ -1519,40 +1900,71 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
protected function debugGatewayBindingsByService(array $workspace): array
|
protected function debugGatewayBindingsByService(array $workspace): array
|
||||||
{
|
{
|
||||||
$bindings = [];
|
$bindings = [];
|
||||||
|
foreach ($this->debugGatewayBindingReferences($workspace) as $binding) {
|
||||||
|
foreach ((array)($binding['services'] ?? []) as $service) {
|
||||||
|
$row = $binding;
|
||||||
|
$row['service'] = $service;
|
||||||
|
$bindings[$service][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $workspace
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
protected function debugGatewayBindingReferences(array $workspace): array
|
||||||
|
{
|
||||||
|
$references = [];
|
||||||
$relayServices = $this->debugRelayServicesFromWorkspace($workspace);
|
$relayServices = $this->debugRelayServicesFromWorkspace($workspace);
|
||||||
foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
|
foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
|
||||||
if (!is_array($gateway)) {
|
if (!is_array($gateway)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$gatewayId = (int)($gateway['id'] ?? 0);
|
$gatewayId = $this->debugGatewayIdentifier($gateway);
|
||||||
|
if ($gatewayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
|
foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
|
||||||
if (!is_array($binding)) {
|
if (!is_array($binding)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||||
if ($gatewayId <= 0 || $relayId === '') {
|
if ($relayId === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$services = $this->debugBindingServices($binding, $relayId, $relayServices);
|
$services = $this->debugBindingServices($binding, $relayId, $relayServices);
|
||||||
foreach ($services as $service) {
|
if ($services === []) {
|
||||||
$bindings[$service][] = [
|
continue;
|
||||||
'gateway_id' => $gatewayId,
|
|
||||||
'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)),
|
|
||||||
'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'),
|
|
||||||
'gateway_node_id' => 'gateway:' . $gatewayId,
|
|
||||||
'relay_id' => $relayId,
|
|
||||||
'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)),
|
|
||||||
'relay_node_id' => 'relay:' . $relayId,
|
|
||||||
'binding_index' => (int)$index,
|
|
||||||
'node_id' => 'binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index,
|
|
||||||
'service' => $service,
|
|
||||||
'channel' => $binding['channel'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
$references[] = [
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)),
|
||||||
|
'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'),
|
||||||
|
'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)),
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)),
|
||||||
|
'relay_node_id' => 'relay:' . $relayId,
|
||||||
|
'binding_index' => (int)$index,
|
||||||
|
'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)),
|
||||||
|
'services' => $services,
|
||||||
|
'channel' => $binding['channel'] ?? null,
|
||||||
|
'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $bindings;
|
return $references;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $gateway
|
||||||
|
*/
|
||||||
|
protected function debugGatewayIdentifier(array $gateway): string
|
||||||
|
{
|
||||||
|
return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1759,6 +2171,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
|
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
|
||||||
|
|
||||||
if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) {
|
if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) {
|
||||||
@@ -2088,6 +2504,15 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return is_array($decoded) ? $decoded : [];
|
return is_array($decoded) ? $decoded : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function normalizeButtonList(mixed $value): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return department_selfserve_tasks_o::normalizeButtonsInput($value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function normalizeServiceNames(array $services): array
|
protected function normalizeServiceNames(array $services): array
|
||||||
{
|
{
|
||||||
$normalized = [];
|
$normalized = [];
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ interface selfserve_condition_evaluator_i
|
|||||||
*/
|
*/
|
||||||
public function evaluate(array $conditions, array $rules, array $answers): array;
|
public function evaluate(array $conditions, array $rules, array $answers): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $conditions
|
||||||
|
* @param array<int,bool|null> $answers
|
||||||
|
* @return array<int,bool>
|
||||||
|
*/
|
||||||
|
public function evaluateExpressions(array $conditions, array $answers): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $conditions
|
||||||
|
* @param array<int,bool|null> $answers
|
||||||
|
* @return array{results:array<int,bool>,trace:array<int,array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
public function evaluateExpressionsWithTrace(array $conditions, array $answers): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int|null $gateId
|
* @param int|null $gateId
|
||||||
* @param array<int,bool> $conditionResults
|
* @param array<int,bool> $conditionResults
|
||||||
|
|||||||
@@ -63,6 +63,23 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
|
||||||
|
*/
|
||||||
|
protected function hasMachineStartSignalForStop(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
||||||
|
return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
|
||||||
|
(int)$this->id,
|
||||||
|
method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
|
||||||
|
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append the lane vehicle-type product to the current invoice order when requested.
|
* Append the lane vehicle-type product to the current invoice order when requested.
|
||||||
*/
|
*/
|
||||||
@@ -385,8 +402,8 @@ trait selfserve_lane_command_t
|
|||||||
if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
|
if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
|
||||||
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
|
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
|
||||||
}
|
}
|
||||||
// Snapshot selector relay online state before relay shutdown.
|
// Snapshot the physical machine ON signal before session completion/reset.
|
||||||
$program_selector_online = $this->isProgramSelectorRelayOnlineForStop();
|
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||||
// Open the exit port
|
// Open the exit port
|
||||||
$this->open(selfserve_lane_port::EXIT);
|
$this->open(selfserve_lane_port::EXIT);
|
||||||
// Turn off relays in deterministic order after STOP
|
// Turn off relays in deterministic order after STOP
|
||||||
@@ -395,8 +412,8 @@ trait selfserve_lane_command_t
|
|||||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||||
// Invoice the customer
|
// Invoice the customer
|
||||||
$this->invoice();
|
$this->invoice();
|
||||||
// If program selector relay is online at stop time, bill the primary product.
|
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||||
$this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online);
|
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||||
// Finalize any active self-serve wash session for this lane
|
// Finalize any active self-serve wash session for this lane
|
||||||
$this->completeLatestSessionForStop();
|
$this->completeLatestSessionForStop();
|
||||||
// Reset the lane
|
// Reset the lane
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class department_selfserve_tasks_o extends db
|
|||||||
public object_property $description; // The task description
|
public object_property $description; // The task description
|
||||||
public object_property $order_priority; // The order priority of the task (lower numbers are shown first)
|
public object_property $order_priority; // The order priority of the task (lower numbers are shown first)
|
||||||
public object_property $services; // The services that the task enables (json), this is used to enable machine wash.
|
public object_property $services; // The services that the task enables (json), this is used to enable machine wash.
|
||||||
public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids)
|
public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of mapped button ids)
|
||||||
public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable.
|
public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable.
|
||||||
public object_property $created_at;
|
public object_property $created_at;
|
||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
@@ -72,7 +72,7 @@ class department_selfserve_tasks_o extends db
|
|||||||
* @param string $description The task description
|
* @param string $description The task description
|
||||||
* @param int $order_priority The order priority of the task (lower numbers are shown first)
|
* @param int $order_priority The order priority of the task (lower numbers are shown first)
|
||||||
* @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names.
|
* @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names.
|
||||||
* @param array<int>|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON.
|
* @param array<int|string>|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts program integers plus "reset" and "start".
|
||||||
* @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null.
|
* @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null.
|
||||||
* @return department_selfserve_tasks_o
|
* @return department_selfserve_tasks_o
|
||||||
* @throws Exception If the object was not created successfully
|
* @throws Exception If the object was not created successfully
|
||||||
@@ -358,13 +358,13 @@ class department_selfserve_tasks_o extends db
|
|||||||
return $val;
|
return $val;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Normalize mixed input for buttons into an array of integer IDs (>= 0).
|
* Normalize mixed input for buttons into an array of mapped button IDs.
|
||||||
* Accepts:
|
* Accepts:
|
||||||
* - array of ints/strings
|
* - array of ints/strings
|
||||||
* - JSON array string
|
* - JSON array string
|
||||||
* - comma-separated string
|
* - comma-separated string
|
||||||
* @param mixed $input
|
* @param mixed $input
|
||||||
* @return array<int>
|
* @return array<int|string>
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public static function normalizeButtonsInput(mixed $input): array
|
public static function normalizeButtonsInput(mixed $input): array
|
||||||
@@ -379,14 +379,22 @@ class department_selfserve_tasks_o extends db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!is_array($raw)) {
|
if (!is_array($raw)) {
|
||||||
throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.');
|
throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of mapped button ids.');
|
||||||
}
|
}
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($raw as $btn) {
|
foreach ($raw as $btn) {
|
||||||
|
if (is_string($btn)) {
|
||||||
|
$trimmed = trim($btn);
|
||||||
|
$specialButton = strtolower($trimmed);
|
||||||
|
if ($specialButton === 'reset' || $specialButton === 'start') {
|
||||||
|
$ids[] = $specialButton;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (is_int($btn)) {
|
if (is_int($btn)) {
|
||||||
$val = $btn;
|
$val = $btn;
|
||||||
} elseif (is_string($btn) && ctype_digit($btn)) {
|
} elseif (is_string($btn) && ctype_digit(trim($btn))) {
|
||||||
$val = (int)$btn;
|
$val = (int)trim($btn);
|
||||||
} elseif (is_numeric($btn) && (int)$btn == $btn) {
|
} elseif (is_numeric($btn) && (int)$btn == $btn) {
|
||||||
$val = (int)$btn;
|
$val = (int)$btn;
|
||||||
} else {
|
} else {
|
||||||
@@ -398,7 +406,16 @@ class department_selfserve_tasks_o extends db
|
|||||||
$ids[] = $val;
|
$ids[] = $val;
|
||||||
}
|
}
|
||||||
// de-duplicate while preserving order
|
// de-duplicate while preserving order
|
||||||
$ids = array_values(array_unique($ids));
|
$deduped = [];
|
||||||
return $ids;
|
$seen = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$key = (is_int($id) ? 'int:' : 'string:') . (string)$id;
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$deduped[] = $id;
|
||||||
|
}
|
||||||
|
return $deduped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class selfserve_wash_session_tasks_o extends db
|
|||||||
'description' => $description,
|
'description' => $description,
|
||||||
'services' => $services,
|
'services' => $services,
|
||||||
'buttons' => $buttons,
|
'buttons' => $buttons,
|
||||||
'dynamic_images_vehicle_type' => (int)$thumb_position, // The rotations to do on the image.
|
'dynamic_images_vehicle_type' => $thumb_position === null ? null : (int)$thumb_position, // The rotations to do on the image.
|
||||||
]);
|
]);
|
||||||
$this->getObjectProperties();
|
$this->getObjectProperties();
|
||||||
$this->objectChanged();
|
$this->objectChanged();
|
||||||
|
|||||||
@@ -5236,7 +5236,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Get all-in-one self-serve studio graph
|
summary: Get all-in-one self-serve studio graph
|
||||||
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products.
|
description: Returns the replacement studio workspace graph backed by the schema_version 2 draft config. Conditions own grouped expression trees directly; standalone rule nodes are omitted from v2 graphs.
|
||||||
operationId: getSelfserveStudioGraph
|
operationId: getSelfserveStudioGraph
|
||||||
parameters:
|
parameters:
|
||||||
- name: department
|
- name: department
|
||||||
@@ -5255,7 +5255,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Bulk save all-in-one self-serve studio graph changes
|
summary: Bulk save all-in-one self-serve studio graph changes
|
||||||
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior.
|
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
|
||||||
operationId: saveSelfserveStudioGraph
|
operationId: saveSelfserveStudioGraph
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -8068,6 +8068,93 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/relay/machine/on/post:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Plate Scans
|
||||||
|
summary: Record Shelly machine ON signal webhook
|
||||||
|
description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing.
|
||||||
|
operationId: recordShellyMachineOnSignal
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: {type: string}
|
||||||
|
- name: lane_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: relay_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: event
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [input.toggle_on, switch.on]
|
||||||
|
- name: reg
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Machine ON signal recorded and linked to a self-serve wash session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MachineButtonPressWebhookResponse'
|
||||||
|
'202':
|
||||||
|
description: Shelly signal was recognized but ignored
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Plate Scans
|
||||||
|
summary: Record Shelly machine ON signal webhook
|
||||||
|
description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing.
|
||||||
|
operationId: recordShellyMachineOnSignalPost
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
lane_id:
|
||||||
|
type: integer
|
||||||
|
relay_id:
|
||||||
|
type: string
|
||||||
|
event:
|
||||||
|
type: string
|
||||||
|
enum: [input.toggle_on, switch.on]
|
||||||
|
component:
|
||||||
|
type: string
|
||||||
|
example: input:0
|
||||||
|
state:
|
||||||
|
type: boolean
|
||||||
|
output:
|
||||||
|
type: boolean
|
||||||
|
reg:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Machine ON signal recorded and linked to a self-serve wash session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MachineButtonPressWebhookResponse'
|
||||||
|
'202':
|
||||||
|
description: Shelly signal was recognized but ignored
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
|
||||||
# Module - e-conomic Endpoints
|
# Module - e-conomic Endpoints
|
||||||
/economic/customers/import:
|
/economic/customers/import:
|
||||||
post:
|
post:
|
||||||
@@ -15065,8 +15152,7 @@ components:
|
|||||||
enum: [DRAFT, PUBLISHED, ARCHIVED]
|
enum: [DRAFT, PUBLISHED, ARCHIVED]
|
||||||
version_number: { type: integer }
|
version_number: { type: integer }
|
||||||
config:
|
config:
|
||||||
type: object
|
$ref: '#/components/schemas/SelfserveStudioV2Config'
|
||||||
additionalProperties: true
|
|
||||||
validation_result:
|
validation_result:
|
||||||
$ref: '#/components/schemas/SelfserveStudioValidation'
|
$ref: '#/components/schemas/SelfserveStudioValidation'
|
||||||
source_version_id: { type: integer, nullable: true }
|
source_version_id: { type: integer, nullable: true }
|
||||||
@@ -15074,6 +15160,90 @@ components:
|
|||||||
published_at: { type: string, nullable: true }
|
published_at: { type: string, nullable: true }
|
||||||
created_at: { type: string, nullable: true }
|
created_at: { type: string, nullable: true }
|
||||||
updated_at: { type: string, nullable: true }
|
updated_at: { type: string, nullable: true }
|
||||||
|
SelfserveStudioV2Config:
|
||||||
|
type: object
|
||||||
|
required: [schema_version, questions, conditions, rules, tasks]
|
||||||
|
properties:
|
||||||
|
schema_version:
|
||||||
|
type: integer
|
||||||
|
enum: [2]
|
||||||
|
department_id:
|
||||||
|
type: integer
|
||||||
|
questions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
conditions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioV2Condition'
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
description: Empty in schema_version 2; retained only for backward-compatible payload shape.
|
||||||
|
maxItems: 0
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
tasks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
v2_meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
migration_issues:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
SelfserveStudioV2Condition:
|
||||||
|
type: object
|
||||||
|
required: [id, expression]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
expression:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioV2Expression'
|
||||||
|
additionalProperties: true
|
||||||
|
SelfserveStudioV2Expression:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/SelfserveStudioV2ExpressionGroup'
|
||||||
|
- $ref: '#/components/schemas/SelfserveStudioV2ExpressionPredicate'
|
||||||
|
SelfserveStudioV2ExpressionGroup:
|
||||||
|
type: object
|
||||||
|
required: [type, operator, children]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [group]
|
||||||
|
operator:
|
||||||
|
type: string
|
||||||
|
enum: [ALL, ANY]
|
||||||
|
children:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioV2Expression'
|
||||||
|
SelfserveStudioV2ExpressionPredicate:
|
||||||
|
type: object
|
||||||
|
required: [type, subject_type, subject_id, operator]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [predicate]
|
||||||
|
subject_type:
|
||||||
|
type: string
|
||||||
|
enum: [question, condition]
|
||||||
|
subject_id:
|
||||||
|
type: integer
|
||||||
|
operator:
|
||||||
|
type: string
|
||||||
|
enum: [IS_TRUE, IS_FALSE, IS_SET, IS_TRUE_OR_NOT_SET, IS_FALSE_OR_NOT_SET]
|
||||||
SelfserveStudioGraph:
|
SelfserveStudioGraph:
|
||||||
type: object
|
type: object
|
||||||
required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions]
|
required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions]
|
||||||
@@ -15098,9 +15268,9 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SelfserveConfigVersion'
|
$ref: '#/components/schemas/SelfserveConfigVersion'
|
||||||
active_config:
|
active_config:
|
||||||
type: object
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SelfserveStudioV2Config'
|
||||||
nullable: true
|
nullable: true
|
||||||
additionalProperties: true
|
|
||||||
draft:
|
draft:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -15125,7 +15295,8 @@ components:
|
|||||||
enum: [create, update, delete, connect, disconnect, reorder]
|
enum: [create, update, delete, connect, disconnect, reorder]
|
||||||
entity:
|
entity:
|
||||||
type: string
|
type: string
|
||||||
enum: [question, condition, rule, task]
|
enum: [question, condition, task]
|
||||||
|
description: Standalone rule operations are not accepted for schema_version 2 drafts.
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
|
|||||||
@@ -1016,6 +1016,8 @@ final class TruckwashEdgeAgent
|
|||||||
private string $controlPlaneStatusPath;
|
private string $controlPlaneStatusPath;
|
||||||
private string $stagedUpdatePath;
|
private string $stagedUpdatePath;
|
||||||
private int $lastHeartbeatAt = 0;
|
private int $lastHeartbeatAt = 0;
|
||||||
|
private int $lastMachineSignalPollAt = 0;
|
||||||
|
private int $lastMachineSignalMonitorRefreshAt = 0;
|
||||||
private string $agentInstanceId;
|
private string $agentInstanceId;
|
||||||
|
|
||||||
public function __construct(string $configPath)
|
public function __construct(string $configPath)
|
||||||
@@ -1058,6 +1060,7 @@ final class TruckwashEdgeAgent
|
|||||||
$this->flushOutbox();
|
$this->flushOutbox();
|
||||||
$this->pumpBrokerTransport();
|
$this->pumpBrokerTransport();
|
||||||
$this->heartbeat();
|
$this->heartbeat();
|
||||||
|
$this->pollMachineStartSignals();
|
||||||
if ($this->resumePendingOperationCompletion()) {
|
if ($this->resumePendingOperationCompletion()) {
|
||||||
$this->pumpBrokerTransport();
|
$this->pumpBrokerTransport();
|
||||||
continue;
|
continue;
|
||||||
@@ -1291,6 +1294,171 @@ final class TruckwashEdgeAgent
|
|||||||
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function pollMachineStartSignals(): void
|
||||||
|
{
|
||||||
|
$interval = max(1, (int)$this->config->get('machineSignalPollIntervalSeconds', 2));
|
||||||
|
if ((time() - $this->lastMachineSignalPollAt) < $interval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->lastMachineSignalPollAt = time();
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
|
$agentToken = (string)$this->config->get('agentToken');
|
||||||
|
if ($gatewayId <= 0 || trim($agentToken) === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->machineSignalMonitors($gatewayId, $agentToken) as $monitor) {
|
||||||
|
$localIp = trim((string)($monitor['local_ip'] ?? ''));
|
||||||
|
if ($localIp === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$component = strtolower(trim((string)($monitor['component'] ?? 'input'))) === 'switch' ? 'switch' : 'input';
|
||||||
|
$channel = (int)($monitor['channel'] ?? 0);
|
||||||
|
$status = $this->machineSignalMonitorStatus($localIp, $channel, $component);
|
||||||
|
$on = $this->machineSignalOnState($status, $component);
|
||||||
|
if ($on === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stateKey = sprintf(
|
||||||
|
'selfserve_machine_signal:%s:%s:%d',
|
||||||
|
preg_replace('/[^A-Za-z0-9_\-:.]+/', '_', (string)($monitor['relay_id'] ?? 'relay')),
|
||||||
|
$component,
|
||||||
|
$channel
|
||||||
|
);
|
||||||
|
$previous = $this->stateStore->getJson($stateKey, null);
|
||||||
|
$previousOn = is_array($previous) && array_key_exists('on', $previous) ? (bool)$previous['on'] : false;
|
||||||
|
|
||||||
|
if ($on && !$previousOn) {
|
||||||
|
$event = $component === 'switch' ? 'switch.on' : 'input.toggle_on';
|
||||||
|
$payload = [
|
||||||
|
'agent_token' => $agentToken,
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
'lane_id' => (int)($monitor['lane_id'] ?? 0),
|
||||||
|
'relay_id' => (string)($monitor['relay_id'] ?? ''),
|
||||||
|
'device_id' => (string)($monitor['device_id'] ?? ''),
|
||||||
|
'component' => $component,
|
||||||
|
'channel' => $channel,
|
||||||
|
'event' => $event,
|
||||||
|
'source' => 'edge_gateway_poll',
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
$payload[$component === 'switch' ? 'output' : 'state'] = true;
|
||||||
|
|
||||||
|
$this->sendControlPlaneEvent(
|
||||||
|
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
|
||||||
|
$payload,
|
||||||
|
'machine_signal'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stateStore->setJson($stateKey, [
|
||||||
|
'on' => $on,
|
||||||
|
'component' => $component,
|
||||||
|
'channel' => $channel,
|
||||||
|
'relay_id' => (string)($monitor['relay_id'] ?? ''),
|
||||||
|
'updated_at' => date('c'),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->logger->warning('Machine start signal poll failed: ' . $throwable->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function machineSignalMonitors(int $gatewayId, string $agentToken): array
|
||||||
|
{
|
||||||
|
$cache = $this->stateStore->getJson('selfserve_machine_signal_monitors', []);
|
||||||
|
if (
|
||||||
|
is_array($cache)
|
||||||
|
&& isset($cache['monitors'], $cache['refreshed_at'])
|
||||||
|
&& is_array($cache['monitors'])
|
||||||
|
&& (time() - (int)$cache['refreshed_at']) < 60
|
||||||
|
) {
|
||||||
|
return array_values(array_filter($cache['monitors'], 'is_array'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal-bindings', [
|
||||||
|
'agent_token' => $agentToken,
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
], 10);
|
||||||
|
$payload = is_array($response['data'] ?? null) ? (array)$response['data'] : (is_array($response) ? $response : []);
|
||||||
|
$monitors = is_array($payload['monitors'] ?? null) ? array_values(array_filter((array)$payload['monitors'], 'is_array')) : [];
|
||||||
|
$this->lastMachineSignalMonitorRefreshAt = time();
|
||||||
|
$this->stateStore->setJson('selfserve_machine_signal_monitors', [
|
||||||
|
'refreshed_at' => $this->lastMachineSignalMonitorRefreshAt,
|
||||||
|
'monitors' => $monitors,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $monitors;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if (is_array($cache) && isset($cache['monitors']) && is_array($cache['monitors'])) {
|
||||||
|
return array_values(array_filter($cache['monitors'], 'is_array'));
|
||||||
|
}
|
||||||
|
$this->logger->warning('Machine start signal monitor refresh failed: ' . $throwable->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function machineSignalMonitorStatus(string $localIp, int $channel, string $component): array
|
||||||
|
{
|
||||||
|
if ($component === 'input') {
|
||||||
|
try {
|
||||||
|
$input = $this->workerHttp->post('/relay/input-status', [
|
||||||
|
'local_ip' => $localIp,
|
||||||
|
'channel' => $channel,
|
||||||
|
], 5) ?? [];
|
||||||
|
return [
|
||||||
|
'input_state' => $input['state'] ?? null,
|
||||||
|
'input' => $input,
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Fall back to the combined status endpoint below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workerHttp->post('/relay/status', [
|
||||||
|
'local_ip' => $localIp,
|
||||||
|
'channel' => $channel,
|
||||||
|
'include_input' => $component === 'input',
|
||||||
|
], 8) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function machineSignalOnState(array $status, string $component): ?bool
|
||||||
|
{
|
||||||
|
if ($component === 'switch') {
|
||||||
|
if (array_key_exists('output', $status)) {
|
||||||
|
return (bool)$status['output'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('on', $status)) {
|
||||||
|
return (bool)$status['on'];
|
||||||
|
}
|
||||||
|
if (isset($status['raw']) && is_array($status['raw']) && array_key_exists('output', $status['raw'])) {
|
||||||
|
return (bool)$status['raw']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('input_state', $status)) {
|
||||||
|
return $status['input_state'] === null ? null : (bool)$status['input_state'];
|
||||||
|
}
|
||||||
|
if (isset($status['input']) && is_array($status['input']) && array_key_exists('state', $status['input'])) {
|
||||||
|
return (bool)$status['input']['state'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('state', $status)) {
|
||||||
|
return (bool)$status['state'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function processCommandQueue(): void
|
private function processCommandQueue(): void
|
||||||
{
|
{
|
||||||
$gatewayId = (int)$this->config->get('gatewayId');
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
|
|||||||
@@ -48,18 +48,22 @@ function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
|
|||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
function worker_fetch_shelly_state(string $localIp, int $channel): array
|
function worker_fetch_shelly_state(string $localIp, int $channel, bool $includeInput = false): array
|
||||||
{
|
{
|
||||||
if ($localIp === '') {
|
if ($localIp === '') {
|
||||||
throw new RuntimeException('Missing Shelly IP address');
|
throw new RuntimeException('Missing Shelly IP address');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$input = $includeInput ? worker_fetch_shelly_input_state($localIp, $channel) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel));
|
$payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel));
|
||||||
return [
|
return [
|
||||||
'online' => true,
|
'online' => true,
|
||||||
'on' => (bool)($payload['output'] ?? false),
|
'on' => (bool)($payload['output'] ?? false),
|
||||||
'output' => (bool)($payload['output'] ?? false),
|
'output' => (bool)($payload['output'] ?? false),
|
||||||
|
'input_state' => $input['state'] ?? null,
|
||||||
|
'input' => $input,
|
||||||
'raw' => $payload,
|
'raw' => $payload,
|
||||||
];
|
];
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@@ -68,11 +72,31 @@ function worker_fetch_shelly_state(string $localIp, int $channel): array
|
|||||||
'online' => true,
|
'online' => true,
|
||||||
'on' => (bool)($payload['ison'] ?? false),
|
'on' => (bool)($payload['ison'] ?? false),
|
||||||
'output' => (bool)($payload['ison'] ?? false),
|
'output' => (bool)($payload['ison'] ?? false),
|
||||||
|
'input_state' => $input['state'] ?? null,
|
||||||
|
'input' => $input,
|
||||||
'raw' => $payload,
|
'raw' => $payload,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array
|
||||||
|
{
|
||||||
|
if ($localIp === '') {
|
||||||
|
throw new RuntimeException('Missing Shelly IP address');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = worker_http_get_json(sprintf('http://%s/rpc/Input.GetStatus?id=%d', $localIp, $channel), 2);
|
||||||
|
return [
|
||||||
|
'online' => true,
|
||||||
|
'state' => (bool)($payload['state'] ?? false),
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
|
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +149,19 @@ try {
|
|||||||
if ($method === 'POST' && $path === '/relay/status') {
|
if ($method === 'POST' && $path === '/relay/status') {
|
||||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
$channel = (int)($body['channel'] ?? 0);
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
worker_json_response(200, worker_fetch_shelly_state($localIp, $channel));
|
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
|
||||||
|
worker_json_response(200, worker_fetch_shelly_state($localIp, $channel, $includeInput));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $path === '/relay/input-status') {
|
||||||
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
|
$input = worker_fetch_shelly_input_state($localIp, $channel);
|
||||||
|
if ($input === null) {
|
||||||
|
throw new RuntimeException('Shelly input status is not available');
|
||||||
|
}
|
||||||
|
worker_json_response(200, $input);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ class departmentLanesRoute
|
|||||||
|
|
||||||
// Resolve dynamic image id → class (support id=1 for now)
|
// Resolve dynamic image id → class (support id=1 for now)
|
||||||
$dynamic_image_id = $lane->dynamic_image_id->value();
|
$dynamic_image_id = $lane->dynamic_image_id->value();
|
||||||
|
if ($response->isRequestParameterSet('dynamic_image_id')) {
|
||||||
|
$dynamic_image_override = $response->getRequestParameter('dynamic_image_id');
|
||||||
|
if ($dynamic_image_override === null || $dynamic_image_override === '' || strtolower((string)$dynamic_image_override) === 'null') {
|
||||||
|
$dynamic_image_id = null;
|
||||||
|
} else {
|
||||||
|
$dynamic_image_id = (int)$dynamic_image_override;
|
||||||
|
self::requireMinValue($dynamic_image_id, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($dynamic_image_id === null) {
|
if ($dynamic_image_id === null) {
|
||||||
$response->error('No dynamic image configured for this lane', 404);
|
$response->error('No dynamic image configured for this lane', 404);
|
||||||
}
|
}
|
||||||
@@ -242,11 +251,9 @@ class departmentLanesRoute
|
|||||||
switch ($dynamic_image_id) {
|
switch ($dynamic_image_id) {
|
||||||
case 1:
|
case 1:
|
||||||
$image = new machine_1();
|
$image = new machine_1();
|
||||||
// Require thumb_position for machine_1
|
if ($thumb_position !== null) {
|
||||||
if ($thumb_position === null) {
|
$image->thumb_position = $thumb_position;
|
||||||
$response->error('thumb_position parameter is required for this dynamic image', 400);
|
|
||||||
}
|
}
|
||||||
$image->thumb_position = $thumb_position;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400);
|
$response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400);
|
||||||
|
|||||||
@@ -69,6 +69,30 @@ class departmentSelfserveStudioRoute
|
|||||||
'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout',
|
'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->put('/department/selfserve/studio/virtual-hardware', function (): void {
|
||||||
|
global $response;
|
||||||
|
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||||
|
self::requireParameters(['department', 'operation']);
|
||||||
|
$departmentId = (int)self::getParameter('department');
|
||||||
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
$graph = (new selfserve_studio_graph())->applyVirtualHardwareOperation(
|
||||||
|
$departmentId,
|
||||||
|
$payload,
|
||||||
|
(int)$user->id,
|
||||||
|
$this->studioPermissions()
|
||||||
|
);
|
||||||
|
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_VIRTUAL_HARDWARE', 'Saved self-serve studio virtual hardware');
|
||||||
|
$response->success($graph);
|
||||||
|
} catch (\RuntimeException $exception) {
|
||||||
|
$response->error($exception->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'edit_department_selfserve_config_versions' => 'Generate and edit studio-only virtual hardware',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->post('/department/selfserve/studio/validate', function (): void {
|
$this->post('/department/selfserve/studio/validate', function (): void {
|
||||||
global $response;
|
global $response;
|
||||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||||
@@ -117,6 +141,9 @@ class departmentSelfserveStudioRoute
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id);
|
$published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id);
|
||||||
|
$validation = (new selfserve_studio_graph())->validatePayload($departmentId);
|
||||||
|
$published['warnings'] = (array)($validation['warnings'] ?? []);
|
||||||
|
$published['validation'] = $validation;
|
||||||
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph');
|
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph');
|
||||||
$response->success($published);
|
$response->success($published);
|
||||||
} catch (\RuntimeException $exception) {
|
} catch (\RuntimeException $exception) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace routes;
|
|||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
|
use modules\selfserve\classes\selfserve_machine_signal;
|
||||||
use modules\selfserve\classes\selfserve_wash_flow;
|
use modules\selfserve\classes\selfserve_wash_flow;
|
||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
@@ -52,6 +53,60 @@ class machineButtonPressRoute
|
|||||||
$this->post('/relay/button/press/post', $handler, [
|
$this->post('/relay/button/press/post', $handler, [
|
||||||
'add_button_press' => 'Add a button press'
|
'add_button_press' => 'Add a button press'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$shellyHandler = function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
self::requirePlateScannerAuth();
|
||||||
|
$plate_scanner = (new authentication())->get_plate_scanner();
|
||||||
|
if (!$plate_scanner) {
|
||||||
|
$response->error('Invalid plate scanner session', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->getParametersAsArray();
|
||||||
|
unset($payload['token']);
|
||||||
|
$lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = (new selfserve_machine_signal())->recordCloudShellySignal(
|
||||||
|
(int)$plate_scanner->department_id->value(),
|
||||||
|
$lane_id,
|
||||||
|
$payload,
|
||||||
|
[
|
||||||
|
'scanner_id' => (int)$plate_scanner->id,
|
||||||
|
'scanner_name' => (string)$plate_scanner->name->value(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add(
|
||||||
|
'relay',
|
||||||
|
$plate_scanner->department_id->value(),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
'TRIGGER_MACHINE_ON_SIGNAL',
|
||||||
|
'Shelly machine ON signal received from plate scanner: ' . $plate_scanner->id
|
||||||
|
);
|
||||||
|
$response->success([
|
||||||
|
'message' => !empty($result['recorded']) ? 'Machine ON signal recorded.' : 'Shelly signal ignored.',
|
||||||
|
'scanner' => $plate_scanner->name->value(),
|
||||||
|
'lane_id' => $result['lane_id'] ?? $lane_id,
|
||||||
|
'signal' => $result['signal'] ?? null,
|
||||||
|
'selfserve' => $result['selfserve'] ?? null,
|
||||||
|
'ignored' => $result['ignored'] ?? false,
|
||||||
|
'reason' => $result['reason'] ?? null,
|
||||||
|
], !empty($result['recorded']) ? 201 : 202);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->get('/relay/machine/on/post', $shellyHandler, [
|
||||||
|
'add_button_press' => 'Record a Shelly machine ON signal'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/relay/machine/on/post', $shellyHandler, [
|
||||||
|
'add_button_press' => 'Record a Shelly machine ON signal'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveLaneId(plate_scanners_o $plateScanner): int
|
private function resolveLaneId(plate_scanners_o $plateScanner): int
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('allows studio lane dynamic image previews to override the saved image id', function (): void {
|
||||||
|
$content = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain("isRequestParameterSet('dynamic_image_id')");
|
||||||
|
expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')");
|
||||||
|
expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)");
|
||||||
|
expect($content)->toContain("switch (\$dynamic_image_id)");
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ it('registers dynamic image pre-render cron task and related helpers', function
|
|||||||
expect($content)->toContain('collectDynamicImageTaskGroupsForLane');
|
expect($content)->toContain('collectDynamicImageTaskGroupsForLane');
|
||||||
expect($content)->toContain('buildDynamicImageVariantsForTaskGroup');
|
expect($content)->toContain('buildDynamicImageVariantsForTaskGroup');
|
||||||
expect($content)->toContain('renderDynamicImageVariant');
|
expect($content)->toContain('renderDynamicImageVariant');
|
||||||
|
expect($content)->toContain('mergeUniqueButtonValues');
|
||||||
|
expect($content)->toContain('normalizeButtonsInput');
|
||||||
expect($content)->toContain('dynamic_image:');
|
expect($content)->toContain('dynamic_image:');
|
||||||
expect($content)->toContain('machine_1');
|
expect($content)->toContain('machine_1');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,3 +78,83 @@ it('evaluates typed task gates with strict semantics', function (): void {
|
|||||||
expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue();
|
expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue();
|
||||||
expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse();
|
expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('evaluates nested v2 ALL and ANY expression trees with trace output', function (): void {
|
||||||
|
$evaluator = new selfserve_condition_evaluator();
|
||||||
|
|
||||||
|
$conditions = [
|
||||||
|
[
|
||||||
|
'id' => 10,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
|
||||||
|
[
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ANY',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'],
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 20,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'],
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 4, 'operator' => 'IS_SET'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$evaluation = $evaluator->evaluateExpressionsWithTrace($conditions, [
|
||||||
|
1 => true,
|
||||||
|
2 => false,
|
||||||
|
4 => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($evaluation['results'])->toBe([
|
||||||
|
10 => true,
|
||||||
|
20 => true,
|
||||||
|
]);
|
||||||
|
expect($evaluation['trace'][10]['expression']['children'][1]['operator'])->toBe('ANY');
|
||||||
|
expect($evaluation['trace'][20]['expression']['children'][0]['subject_type'])->toBe('condition');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and traces v2 condition expression cycles', function (): void {
|
||||||
|
$evaluator = new selfserve_condition_evaluator();
|
||||||
|
|
||||||
|
$evaluation = $evaluator->evaluateExpressionsWithTrace([
|
||||||
|
[
|
||||||
|
'id' => 10,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'IS_TRUE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 20,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], []);
|
||||||
|
|
||||||
|
expect($evaluation['results'][10])->toBeFalse();
|
||||||
|
expect($evaluation['results'][20])->toBeFalse();
|
||||||
|
expect(json_encode($evaluation['trace']))->toContain('Condition dependency cycle detected');
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,6 +100,106 @@ it('fails validation when nested conditions form cycles', function (): void {
|
|||||||
expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected');
|
expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates legacy AND and OR rules into grouped v2 condition expressions', function (): void {
|
||||||
|
$service = selfserve_config_versioning_without_constructor();
|
||||||
|
|
||||||
|
$config = $service->migrateLegacyConfigToV2([
|
||||||
|
'department_id' => 6,
|
||||||
|
'questions' => [
|
||||||
|
['id' => 1],
|
||||||
|
['id' => 2],
|
||||||
|
['id' => 3],
|
||||||
|
],
|
||||||
|
'conditions' => [
|
||||||
|
['id' => 10, 'name' => 'Ready'],
|
||||||
|
],
|
||||||
|
'rules' => [
|
||||||
|
['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1],
|
||||||
|
['id' => 101, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 2],
|
||||||
|
['id' => 102, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 3],
|
||||||
|
],
|
||||||
|
'tasks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expression = $config['conditions'][0]['expression'];
|
||||||
|
|
||||||
|
expect($config['schema_version'])->toBe(2);
|
||||||
|
expect($config['rules'])->toBe([]);
|
||||||
|
expect($expression['operator'])->toBe('ALL');
|
||||||
|
expect($expression['children'][0])->toMatchArray([
|
||||||
|
'type' => 'predicate',
|
||||||
|
'subject_type' => 'question',
|
||||||
|
'subject_id' => 1,
|
||||||
|
'operator' => 'IS_TRUE',
|
||||||
|
]);
|
||||||
|
expect($expression['children'][1]['operator'])->toBe('ANY');
|
||||||
|
expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported legacy task-target rules after migration', function (): void {
|
||||||
|
$service = selfserve_config_versioning_without_constructor();
|
||||||
|
|
||||||
|
$config = $service->migrateLegacyConfigToV2([
|
||||||
|
'department_id' => 6,
|
||||||
|
'questions' => [['id' => 1]],
|
||||||
|
'conditions' => [['id' => 10, 'name' => 'Ready']],
|
||||||
|
'rules' => [
|
||||||
|
['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'task', 'object_id' => 50],
|
||||||
|
],
|
||||||
|
'tasks' => [['id' => 50]],
|
||||||
|
]);
|
||||||
|
$validation = $service->validateConfig($config);
|
||||||
|
|
||||||
|
expect($validation['valid'])->toBeFalse();
|
||||||
|
expect(implode("\n", $validation['errors']))->toContain('unsupported object_type `task`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates v2 expressions for empty used conditions, missing refs, invalid operators, and cycles', function (): void {
|
||||||
|
$service = selfserve_config_versioning_without_constructor();
|
||||||
|
|
||||||
|
$validation = $service->validateConfig([
|
||||||
|
'schema_version' => 2,
|
||||||
|
'questions' => [
|
||||||
|
['id' => 1, 'condition_id' => 10],
|
||||||
|
],
|
||||||
|
'conditions' => [
|
||||||
|
['id' => 10, 'expression' => ['type' => 'group', 'operator' => 'ALL', 'children' => []]],
|
||||||
|
[
|
||||||
|
'id' => 20,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 999, 'operator' => 'IS_TRUE'],
|
||||||
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 30, 'operator' => 'IS_TRUE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 30,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'NOPE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'tasks' => [
|
||||||
|
['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 10],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$errors = implode("\n", $validation['errors']);
|
||||||
|
|
||||||
|
expect($validation['valid'])->toBeFalse();
|
||||||
|
expect($errors)->toContain('Condition 10 is used but has an empty expression.');
|
||||||
|
expect($errors)->toContain('unknown question predicate subject_id 999');
|
||||||
|
expect($errors)->toContain('invalid predicate operator `NOPE`');
|
||||||
|
expect($errors)->toContain('Condition cycle detected');
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes config json payloads with apostrophes before persistence', function (): void {
|
it('encodes config json payloads with apostrophes before persistence', function (): void {
|
||||||
$version = new class extends selfserve_config_versions_o {
|
$version = new class extends selfserve_config_versions_o {
|
||||||
/** @var array<string,mixed> */
|
/** @var array<string,mixed> */
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class SelfserveLaneStopFlowHarness
|
|||||||
public int $invoiceCalls = 0;
|
public int $invoiceCalls = 0;
|
||||||
public int $ensureInvoiceOrderContextCalls = 0;
|
public int $ensureInvoiceOrderContextCalls = 0;
|
||||||
public int $vehicleTypeProductAddCalls = 0;
|
public int $vehicleTypeProductAddCalls = 0;
|
||||||
|
public int $programSelectorStatusReads = 0;
|
||||||
|
public ?bool $lastVehicleTypeProductDecision = null;
|
||||||
/** @var selfserve_lane_port[] */
|
/** @var selfserve_lane_port[] */
|
||||||
public array $openedPorts = [];
|
public array $openedPorts = [];
|
||||||
/** @var selfserve_lane_relay[] */
|
/** @var selfserve_lane_relay[] */
|
||||||
@@ -70,14 +72,17 @@ class SelfserveLaneStopFlowHarness
|
|||||||
private bool $bypassCustomerValidation = false;
|
private bool $bypassCustomerValidation = false;
|
||||||
private bool $programSelectorOnline;
|
private bool $programSelectorOnline;
|
||||||
private bool $programSelectorOn;
|
private bool $programSelectorOn;
|
||||||
|
private bool $machineStartTriggered;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
bool $programSelectorOnline,
|
bool $machineStartTriggered,
|
||||||
|
bool $programSelectorOnline = false,
|
||||||
bool $programSelectorOn = true,
|
bool $programSelectorOn = true,
|
||||||
string $machineRelayId = 'relay-machine',
|
string $machineRelayId = 'relay-machine',
|
||||||
string $programRelayId = 'relay-program',
|
string $programRelayId = 'relay-program',
|
||||||
string $cleanerRelayId = 'relay-cleaner'
|
string $cleanerRelayId = 'relay-cleaner'
|
||||||
) {
|
) {
|
||||||
|
$this->machineStartTriggered = $machineStartTriggered;
|
||||||
$this->programSelectorOnline = $programSelectorOnline;
|
$this->programSelectorOnline = $programSelectorOnline;
|
||||||
$this->programSelectorOn = $programSelectorOn;
|
$this->programSelectorOn = $programSelectorOn;
|
||||||
$this->department_lane = new SelfserveLaneCommandDepartmentLaneFake(
|
$this->department_lane = new SelfserveLaneCommandDepartmentLaneFake(
|
||||||
@@ -165,6 +170,7 @@ class SelfserveLaneStopFlowHarness
|
|||||||
|
|
||||||
public function getMachineProgramPickerRelayStatus(): array
|
public function getMachineProgramPickerRelayStatus(): array
|
||||||
{
|
{
|
||||||
|
$this->programSelectorStatusReads++;
|
||||||
return [
|
return [
|
||||||
'online' => $this->programSelectorOnline,
|
'online' => $this->programSelectorOnline,
|
||||||
'on' => $this->programSelectorOn,
|
'on' => $this->programSelectorOn,
|
||||||
@@ -197,17 +203,32 @@ class SelfserveLaneStopFlowHarness
|
|||||||
{
|
{
|
||||||
// No-op in unit tests.
|
// No-op in unit tests.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function hasMachineStartSignalForStop(): bool
|
||||||
|
{
|
||||||
|
return $this->machineStartTriggered;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void
|
||||||
|
{
|
||||||
|
$this->lastVehicleTypeProductDecision = $should_add;
|
||||||
|
if ($should_add) {
|
||||||
|
$this->vehicleTypeProductAddCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void {
|
it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void {
|
||||||
$lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true);
|
$lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true);
|
||||||
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
||||||
|
|
||||||
$lane->execute(selfserve_lane_command::STOP, $args);
|
$lane->execute(selfserve_lane_command::STOP, $args);
|
||||||
|
|
||||||
expect($lane->invoiceCalls)->toBe(1);
|
expect($lane->invoiceCalls)->toBe(1);
|
||||||
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
||||||
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
|
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
|
||||||
|
expect($lane->lastVehicleTypeProductDecision)->toBeTrue();
|
||||||
|
expect($lane->programSelectorStatusReads)->toBe(0);
|
||||||
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
|
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
|
||||||
expect($lane->turnedOffRelays)->toBe([
|
expect($lane->turnedOffRelays)->toBe([
|
||||||
selfserve_lane_relay::MACHINE_CLEANER,
|
selfserve_lane_relay::MACHINE_CLEANER,
|
||||||
@@ -216,8 +237,9 @@ it('adds vehicle type product on STOP when program selector relay is online, the
|
|||||||
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void {
|
it('skips vehicle type product add when no physical machine ON signal was recorded and only disables configured relays', function (): void {
|
||||||
$lane = new SelfserveLaneStopFlowHarness(
|
$lane = new SelfserveLaneStopFlowHarness(
|
||||||
|
machineStartTriggered: false,
|
||||||
programSelectorOnline: false,
|
programSelectorOnline: false,
|
||||||
machineRelayId: 'relay-machine',
|
machineRelayId: 'relay-machine',
|
||||||
programRelayId: 'relay-program',
|
programRelayId: 'relay-program',
|
||||||
@@ -230,15 +252,18 @@ it('skips vehicle type product add when program selector relay is offline and on
|
|||||||
expect($lane->invoiceCalls)->toBe(1);
|
expect($lane->invoiceCalls)->toBe(1);
|
||||||
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
||||||
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
|
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
|
||||||
|
expect($lane->lastVehicleTypeProductDecision)->toBeFalse();
|
||||||
|
expect($lane->programSelectorStatusReads)->toBe(0);
|
||||||
expect($lane->turnedOffRelays)->toBe([
|
expect($lane->turnedOffRelays)->toBe([
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bills primary product when selector relay is online even if relay output is off', function (): void {
|
it('does not use selector relay online status as machine-wash billing evidence', function (): void {
|
||||||
$lane = new SelfserveLaneStopFlowHarness(
|
$lane = new SelfserveLaneStopFlowHarness(
|
||||||
|
machineStartTriggered: false,
|
||||||
programSelectorOnline: true,
|
programSelectorOnline: true,
|
||||||
programSelectorOn: false
|
programSelectorOn: true
|
||||||
);
|
);
|
||||||
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
||||||
|
|
||||||
@@ -247,4 +272,6 @@ it('bills primary product when selector relay is online even if relay output is
|
|||||||
expect($lane->invoiceCalls)->toBe(1);
|
expect($lane->invoiceCalls)->toBe(1);
|
||||||
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
|
||||||
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
|
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
|
||||||
|
expect($lane->lastVehicleTypeProductDecision)->toBeFalse();
|
||||||
|
expect($lane->programSelectorStatusReads)->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('modules/selfserve/classes/selfserve_machine_signal.php');
|
||||||
|
|
||||||
|
use modules\selfserve\classes\selfserve_machine_signal;
|
||||||
|
|
||||||
|
it('normalizes Shelly input toggle ON events as machine start signals', function (): void {
|
||||||
|
$signal = (new selfserve_machine_signal())->normalizeShellyPayload([
|
||||||
|
'event' => 'input.toggle_on',
|
||||||
|
'component' => 'input:0',
|
||||||
|
'state' => true,
|
||||||
|
'relay_id' => 'M-1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($signal['recognized'])->toBeTrue()
|
||||||
|
->and($signal['on'])->toBeTrue()
|
||||||
|
->and($signal['component'])->toBe('input')
|
||||||
|
->and($signal['relay_id'])->toBe('M-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes Shelly switch ON events and nested status payloads', function (): void {
|
||||||
|
$service = new selfserve_machine_signal();
|
||||||
|
|
||||||
|
$switchSignal = $service->normalizeShellyPayload([
|
||||||
|
'event' => 'switch.on',
|
||||||
|
'component' => 'switch:0',
|
||||||
|
'output' => true,
|
||||||
|
]);
|
||||||
|
$statusSignal = $service->normalizeShellyPayload([
|
||||||
|
'status' => [
|
||||||
|
'input:0' => ['state' => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($switchSignal['recognized'])->toBeTrue()
|
||||||
|
->and($switchSignal['on'])->toBeTrue()
|
||||||
|
->and($switchSignal['component'])->toBe('switch')
|
||||||
|
->and($statusSignal['recognized'])->toBeTrue()
|
||||||
|
->and($statusSignal['on'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes Shelly OFF events but does not treat them as billable machine starts', function (): void {
|
||||||
|
$signal = (new selfserve_machine_signal())->normalizeShellyPayload([
|
||||||
|
'event' => 'input.toggle_off',
|
||||||
|
'component' => 'input:0',
|
||||||
|
'state' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($signal['recognized'])->toBeTrue()
|
||||||
|
->and($signal['on'])->toBeFalse();
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
|
|||||||
expect($content)->toContain('/department/selfserve/vehicle/allowed:');
|
expect($content)->toContain('/department/selfserve/vehicle/allowed:');
|
||||||
expect($content)->toContain('/department/selfserve/washes/summary:');
|
expect($content)->toContain('/department/selfserve/washes/summary:');
|
||||||
expect($content)->toContain('/relay/button/press/post:');
|
expect($content)->toContain('/relay/button/press/post:');
|
||||||
|
expect($content)->toContain('/relay/machine/on/post:');
|
||||||
expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:');
|
expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:');
|
||||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:');
|
expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:');
|
||||||
expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:');
|
expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:');
|
||||||
|
|||||||
@@ -15,7 +15,24 @@ it('wires self-serve machine types, eligibility, summaries, and machine-start we
|
|||||||
|
|
||||||
expect($webhookRoute)->not->toBeFalse();
|
expect($webhookRoute)->not->toBeFalse();
|
||||||
expect($webhookRoute)->toContain('/relay/button/press/post');
|
expect($webhookRoute)->toContain('/relay/button/press/post');
|
||||||
|
expect($webhookRoute)->toContain('/relay/machine/on/post');
|
||||||
expect($webhookRoute)->toContain('recordMachineStartWebhook');
|
expect($webhookRoute)->toContain('recordMachineStartWebhook');
|
||||||
|
expect($webhookRoute)->toContain('recordCloudShellySignal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires local edge gateway machine ON signal monitor endpoints', function (): void {
|
||||||
|
$edgeGatewayRoute = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewaysRoute.php'));
|
||||||
|
$agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
$worker = file_get_contents(app_path('resources/edge-gateway-agent/lan-worker.php'));
|
||||||
|
|
||||||
|
expect($edgeGatewayRoute)->not->toBeFalse()
|
||||||
|
->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings')
|
||||||
|
->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal')
|
||||||
|
->and($edgeGatewayRoute)->toContain('recordEdgeGatewaySignal')
|
||||||
|
->and($agent)->toContain('pollMachineStartSignals')
|
||||||
|
->and($agent)->toContain('/selfserve/machine-signal')
|
||||||
|
->and($worker)->toContain('Input.GetStatus')
|
||||||
|
->and($worker)->toContain('/relay/input-status');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void {
|
it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void {
|
||||||
|
|||||||
@@ -26,3 +26,18 @@ it('creates canvas-only self-serve studio layout storage', function (): void {
|
|||||||
expect($bootstrapContent)->toContain('layout_json JSON NOT NULL');
|
expect($bootstrapContent)->toContain('layout_json JSON NOT NULL');
|
||||||
expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user');
|
expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses mysql-safe identifiers for self-serve studio virtual hardware storage', function (): void {
|
||||||
|
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||||
|
|
||||||
|
expect($bootstrapContent)->not->toBeFalse();
|
||||||
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware');
|
||||||
|
expect($bootstrapContent)->toContain('UNIQUE KEY uniq_selfserve_vhw_department');
|
||||||
|
expect($bootstrapContent)->toContain('INDEX idx_selfserve_vhw_dept_updated');
|
||||||
|
expect($bootstrapContent)->not->toContain('idx_department_selfserve_studio_virtual_hardware_department_updated');
|
||||||
|
|
||||||
|
preg_match_all('/\b(?:UNIQUE\s+KEY|INDEX)\s+([a-zA-Z0-9_]+)/', $bootstrapContent, $matches);
|
||||||
|
foreach ($matches[1] as $identifier) {
|
||||||
|
expect(strlen($identifier))->toBeLessThanOrEqual(64);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
app_require('modules/selfserve/classes/selfserve_studio_graph.php');
|
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_studio_graph;
|
||||||
|
use modules\selfserve\classes\selfserve_virtual_hardware;
|
||||||
use modules\selfserve\classes\selfserve_wash_flow;
|
use modules\selfserve\classes\selfserve_wash_flow;
|
||||||
|
|
||||||
function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
|
function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
|
||||||
@@ -17,6 +19,12 @@ function selfserve_wash_flow_without_constructor(): selfserve_wash_flow
|
|||||||
return $reflection->newInstanceWithoutConstructor();
|
return $reflection->newInstanceWithoutConstructor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass(selfserve_virtual_hardware::class);
|
||||||
|
return $reflection->newInstanceWithoutConstructor();
|
||||||
|
}
|
||||||
|
|
||||||
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
@@ -107,6 +115,66 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
|
|||||||
expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
|
expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes v2 condition expressions without standalone rule nodes', function (): void {
|
||||||
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
|
$graph = $service->buildGraphFromConfig([
|
||||||
|
'schema_version' => 2,
|
||||||
|
'questions' => [
|
||||||
|
['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1],
|
||||||
|
],
|
||||||
|
'conditions' => [
|
||||||
|
[
|
||||||
|
'id' => 10,
|
||||||
|
'name' => 'Ready',
|
||||||
|
'lane' => 7,
|
||||||
|
'product' => 3,
|
||||||
|
'department' => 2,
|
||||||
|
'expression' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'operator' => 'ALL',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'rules' => [
|
||||||
|
['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1],
|
||||||
|
],
|
||||||
|
'tasks' => [],
|
||||||
|
], [
|
||||||
|
'lookups' => [
|
||||||
|
'labels' => [
|
||||||
|
'questions' => ['1' => 'Are mirrors folded?'],
|
||||||
|
'conditions' => ['10' => 'Ready'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nodeIds = array_column($graph['nodes'], 'id');
|
||||||
|
$edgeIds = array_column($graph['edges'], 'id');
|
||||||
|
$conditionNode = array_values(array_filter(
|
||||||
|
$graph['nodes'],
|
||||||
|
static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10'
|
||||||
|
))[0] ?? null;
|
||||||
|
|
||||||
|
expect($nodeIds)->toContain('condition:10');
|
||||||
|
expect($nodeIds)->not->toContain('rule:99');
|
||||||
|
expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8));
|
||||||
|
expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void {
|
||||||
|
$washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName());
|
||||||
|
$studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName());
|
||||||
|
|
||||||
|
expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)');
|
||||||
|
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
|
||||||
|
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
|
||||||
|
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
|
||||||
|
});
|
||||||
|
|
||||||
it('derives studio vehicle type lookup rows from selectable wash products', function (): void {
|
it('derives studio vehicle type lookup rows from selectable wash products', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
$method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts');
|
$method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts');
|
||||||
@@ -125,6 +193,95 @@ it('derives studio vehicle type lookup rows from selectable wash products', func
|
|||||||
expect($vehicleTypes[0]['source'])->toBe('products');
|
expect($vehicleTypes[0]['source'])->toBe('products');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void {
|
||||||
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
$dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes');
|
||||||
|
$machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows');
|
||||||
|
|
||||||
|
$dynamicImageRows = $dynamicImages->invoke($service, [
|
||||||
|
['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1],
|
||||||
|
['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9],
|
||||||
|
]);
|
||||||
|
$machineTypeRows = $machineTypes->invoke(
|
||||||
|
$service,
|
||||||
|
[
|
||||||
|
['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['id' => 7, 'machine_type_id' => 2002],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'conditions' => [
|
||||||
|
['id' => 21, 'machine_type_id' => 3003],
|
||||||
|
],
|
||||||
|
'tasks' => [
|
||||||
|
['id' => 41, 'machine_type_id' => 1001],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]);
|
||||||
|
expect($dynamicImageRows[0]['label'])->toBe('Machine 1');
|
||||||
|
expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9');
|
||||||
|
expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]);
|
||||||
|
expect($machineTypeRows[0]['label'])->toBe('Portal');
|
||||||
|
expect($machineTypeRows[1]['label'])->toBe('Machine type 2002');
|
||||||
|
expect($machineTypeRows[2]['label'])->toBe('Machine type 3003');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps lane management fields on lane scope nodes', function (): void {
|
||||||
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
|
$graph = $service->buildGraphFromConfig([
|
||||||
|
'questions' => [],
|
||||||
|
'conditions' => [],
|
||||||
|
'rules' => [],
|
||||||
|
'tasks' => [],
|
||||||
|
], [
|
||||||
|
'lookups' => [
|
||||||
|
'lanes' => [
|
||||||
|
[
|
||||||
|
'id' => 7,
|
||||||
|
'department' => 6,
|
||||||
|
'name' => 'Lane 7',
|
||||||
|
'label' => 'Lane 7',
|
||||||
|
'relay_machine_id' => 'M-7',
|
||||||
|
'machine_type_id' => 1001,
|
||||||
|
'dynamic_image_id' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'machine_types' => [['id' => 1001, 'label' => 'Portal']],
|
||||||
|
'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']],
|
||||||
|
'labels' => [
|
||||||
|
'lanes' => ['7' => 'Lane 7'],
|
||||||
|
'machine_types' => ['1001' => 'Portal'],
|
||||||
|
'dynamic_images' => ['1' => 'Machine 1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$laneNode = array_values(array_filter(
|
||||||
|
$graph['nodes'],
|
||||||
|
static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7'
|
||||||
|
))[0] ?? null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes studio lane graph operations through department_lanes', function (): void {
|
||||||
|
$source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName());
|
||||||
|
|
||||||
|
expect($source)->toContain("if (\$entity === 'lane')")
|
||||||
|
->and($source)->toContain('private function applyLaneOperation')
|
||||||
|
->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'");
|
||||||
|
});
|
||||||
|
|
||||||
it('applies saved layout without changing graph semantics', function (): void {
|
it('applies saved layout without changing graph semantics', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
@@ -337,3 +494,173 @@ it('resolves simulator gateway service bindings from lane relay slots', function
|
|||||||
->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE')
|
->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE')
|
||||||
->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']);
|
->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates and merges virtual hardware as studio-only relay coverage', function (): void {
|
||||||
|
$service = selfserve_virtual_hardware_without_constructor();
|
||||||
|
$workspace = [
|
||||||
|
'gateways' => [],
|
||||||
|
'relays' => [],
|
||||||
|
'lanes' => [
|
||||||
|
[
|
||||||
|
'id' => 7,
|
||||||
|
'name' => 'Lane 7',
|
||||||
|
'relay_slots' => [
|
||||||
|
['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
|
||||||
|
['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
|
||||||
|
],
|
||||||
|
'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'issues' => [
|
||||||
|
['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'],
|
||||||
|
['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7],
|
||||||
|
],
|
||||||
|
'actions' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config = $service->generateFromLanes($workspace);
|
||||||
|
$merged = $service->mergeWorkspaceWithConfig($workspace, $config);
|
||||||
|
|
||||||
|
expect($config['bindings'])->toHaveCount(2)
|
||||||
|
->and($merged['virtual']['has_virtual_hardware'])->toBeTrue()
|
||||||
|
->and($merged['gateways'][0]['virtual'])->toBeTrue()
|
||||||
|
->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7')
|
||||||
|
->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY')
|
||||||
|
->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue()
|
||||||
|
->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE')
|
||||||
|
->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders virtual gateway nodes and task service edges in the studio graph', function (): void {
|
||||||
|
$virtual = selfserve_virtual_hardware_without_constructor();
|
||||||
|
$graphService = selfserve_studio_graph_without_constructor();
|
||||||
|
$workspace = $virtual->mergeWorkspaceWithConfig([
|
||||||
|
'gateways' => [],
|
||||||
|
'relays' => [],
|
||||||
|
'lanes' => [
|
||||||
|
[
|
||||||
|
'id' => 7,
|
||||||
|
'name' => 'Lane 7',
|
||||||
|
'relay_slots' => [
|
||||||
|
['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
|
||||||
|
],
|
||||||
|
'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'issues' => [],
|
||||||
|
'actions' => [],
|
||||||
|
], [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'enabled' => true,
|
||||||
|
'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']],
|
||||||
|
'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']],
|
||||||
|
'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$graph = $graphService->buildGraphFromConfig([
|
||||||
|
'questions' => [],
|
||||||
|
'conditions' => [],
|
||||||
|
'rules' => [],
|
||||||
|
'tasks' => [
|
||||||
|
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1],
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'lookups' => [
|
||||||
|
'labels' => [
|
||||||
|
'tasks' => ['41' => 'Start machine'],
|
||||||
|
'lanes' => ['7' => 'Lane 7'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'gateway_workspace' => $workspace,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nodeIds = array_column($graph['nodes'], 'id');
|
||||||
|
$edgeIds = array_column($graph['edges'], 'id');
|
||||||
|
$gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? [];
|
||||||
|
$bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? [];
|
||||||
|
|
||||||
|
expect($nodeIds)->toContain('gateway:virtual-main')
|
||||||
|
->and($nodeIds)->toContain('binding:virtual-main:M-7:0')
|
||||||
|
->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0')
|
||||||
|
->and($gatewayNode['data']['raw']['virtual'])->toBeTrue()
|
||||||
|
->and($bindingNode['data']['raw']['virtual'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void {
|
||||||
|
$service = selfserve_wash_flow_without_constructor();
|
||||||
|
|
||||||
|
$debug = $service->buildStudioDebugPayload(6, [
|
||||||
|
'lane' => ['id' => 7, 'name' => 'Lane 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' => [],
|
||||||
|
'task_gates' => [
|
||||||
|
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'debug_candidates' => [
|
||||||
|
'questions' => [],
|
||||||
|
'conditions' => [],
|
||||||
|
'rules' => [],
|
||||||
|
'tasks' => [
|
||||||
|
['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'gateway_workspace' => [
|
||||||
|
'gateways' => [
|
||||||
|
[
|
||||||
|
'id' => 'virtual-main',
|
||||||
|
'label' => 'Virtual Studio Gateway',
|
||||||
|
'status' => 'VIRTUAL',
|
||||||
|
'virtual' => true,
|
||||||
|
'bindings' => [
|
||||||
|
['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true],
|
||||||
|
['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true],
|
||||||
|
['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'lanes' => [
|
||||||
|
[
|
||||||
|
'id' => 7,
|
||||||
|
'relay_slots' => [
|
||||||
|
['slot' => 'MACHINE', 'relay_id' => 'M-7'],
|
||||||
|
['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
|
||||||
|
['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'virtual' => ['has_virtual_hardware' => true],
|
||||||
|
],
|
||||||
|
'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8])
|
||||||
|
->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION'])
|
||||||
|
->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only')
|
||||||
|
->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event')
|
||||||
|
->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal')
|
||||||
|
->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event')
|
||||||
|
->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true])
|
||||||
|
->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1])
|
||||||
|
->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ namespace {
|
|||||||
|
|
||||||
// 1) Buttons normalization examples
|
// 1) Buttons normalization examples
|
||||||
try {
|
try {
|
||||||
$arr = department_selfserve_tasks_o::normalizeButtonsInput('0, 2,3 , 5');
|
$arr = department_selfserve_tasks_o::normalizeButtonsInput('reset, 0, 2,3 , 5, start');
|
||||||
if ($arr === [0,2,3,5]) { ok('CSV buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); }
|
if ($arr === ['reset',0,2,3,5,'start']) { ok('CSV mapped buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); }
|
||||||
} catch (\Exception $e) { fail('CSV normalization threw: '.$e->getMessage()); }
|
} catch (\Exception $e) { fail('CSV normalization threw: '.$e->getMessage()); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4]');
|
$arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4, "START"]');
|
||||||
if ($arr === [1,2,4]) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); }
|
if ($arr === [1,2,4,'start']) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); }
|
||||||
} catch (\Exception $e) { fail('JSON normalization threw: '.$e->getMessage()); }
|
} catch (\Exception $e) { fail('JSON normalization threw: '.$e->getMessage()); }
|
||||||
|
|
||||||
// 2) Vehicle type normalization examples
|
// 2) Vehicle type normalization examples
|
||||||
@@ -47,7 +47,7 @@ namespace {
|
|||||||
try {
|
try {
|
||||||
$img = new machine_1();
|
$img = new machine_1();
|
||||||
// Set some sample parameters (not used until setup(), which we skip to avoid Imagick requirement)
|
// Set some sample parameters (not used until setup(), which we skip to avoid Imagick requirement)
|
||||||
$img->highlighted_buttons = [0,2,5];
|
$img->highlighted_buttons = ['reset',0,2,5,'start'];
|
||||||
$img->current_step = 1;
|
$img->current_step = 1;
|
||||||
$dataUri = $img->exportAsBase64();
|
$dataUri = $img->exportAsBase64();
|
||||||
if (is_string($dataUri) && str_starts_with($dataUri, 'data:image/')) {
|
if (is_string($dataUri) && str_starts_with($dataUri, 'data:image/')) {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ namespace {
|
|||||||
fail('JSON input threw unexpectedly: ' . $e->getMessage());
|
fail('JSON input threw unexpectedly: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) CSV string input
|
// 3) CSV string input with mapped reset/start buttons
|
||||||
try {
|
try {
|
||||||
$result = department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8');
|
$result = department_selfserve_tasks_o::normalizeButtonsInput('reset, 6, 7 ,8, start');
|
||||||
if ($result === [6,7,8]) {
|
if ($result === ['reset',6,7,8,'start']) {
|
||||||
ok('CSV input normalized correctly');
|
ok('CSV input normalized mapped buttons correctly');
|
||||||
} else {
|
} else {
|
||||||
fail('CSV input normalization mismatch: ' . json_encode($result));
|
fail('CSV input normalization mismatch: ' . json_encode($result));
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,19 @@ namespace {
|
|||||||
fail('CSV input threw unexpectedly: ' . $e->getMessage());
|
fail('CSV input threw unexpectedly: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Invalid input should throw
|
// 4) JSON string input preserves reset/start and removes duplicates
|
||||||
|
try {
|
||||||
|
$result = department_selfserve_tasks_o::normalizeButtonsInput('["RESET", 1, "start", "reset"]');
|
||||||
|
if ($result === ['reset',1,'start']) {
|
||||||
|
ok('JSON mapped buttons normalized with duplicates removed');
|
||||||
|
} else {
|
||||||
|
fail('JSON mapped button normalization mismatch: ' . json_encode($result));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
fail('JSON mapped button input threw unexpectedly: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Invalid input should throw
|
||||||
$thrown = false;
|
$thrown = false;
|
||||||
try {
|
try {
|
||||||
department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]');
|
department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]');
|
||||||
|
|||||||
Reference in New Issue
Block a user