diff --git a/services/nginx/app/classes/department_gate_config.php b/services/nginx/app/classes/department_gate_config.php index ec7c1f77..6fcbe199 100644 --- a/services/nginx/app/classes/department_gate_config.php +++ b/services/nginx/app/classes/department_gate_config.php @@ -9,15 +9,19 @@ class department_gate_config public string $type; public ?string $phone_number = null; public ?int $call_duration_threshold = null; + public ?string $relay_id = null; + public ?int $pulse_seconds = null; /** * @param array $config */ public function __construct(array $config = []) { - $this->type = (string)($config['type'] ?? ''); + $this->type = strtoupper(trim((string)($config['type'] ?? ''))); $this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null; $this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null; + $this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null; + $this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null; } /** @@ -37,6 +41,14 @@ class department_gate_config $array['call_duration_threshold'] = $this->call_duration_threshold; } + if ($this->relay_id !== null) { + $array['relay_id'] = $this->relay_id; + } + + if ($this->pulse_seconds !== null) { + $array['pulse_seconds'] = $this->pulse_seconds; + } + return $array; } @@ -58,6 +70,19 @@ class department_gate_config if ($this->call_duration_threshold === null) { throw new Exception('Call duration threshold is required for PHONE_CALL gate type'); } + return; } + + if ($this->type === 'RELAY') { + if ($this->relay_id === null || $this->relay_id === '') { + throw new Exception('relay_id is required for RELAY gate type'); + } + if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) { + throw new Exception('pulse_seconds must be a positive integer for RELAY gate type'); + } + return; + } + + throw new Exception('Unsupported gate config type: ' . $this->type); } } diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php new file mode 100644 index 00000000..57722a29 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php @@ -0,0 +1,865 @@ +> + * @throws Exception + */ + public function listDepartmentSummaries(): array + { + $departments = (new departments_o())->list(true); + usort($departments, static function (array $left, array $right): int { + $leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX); + $rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX); + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + return (int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0); + }); + + $summaries = []; + foreach ($departments as $departmentRow) { + $departmentId = (int)($departmentRow['id'] ?? 0); + if ($departmentId <= 0) { + continue; + } + + $workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow); + $summaries[] = $workspace['summary']; + } + + return $summaries; + } + + /** + * @return array + * @throws Exception + */ + public function getDepartmentWorkspace(int $departmentId): array + { + return $this->buildDepartmentWorkspace($departmentId, true); + } + + /** + * @param array|null $departmentRow + * @return array + * @throws Exception + */ + private function buildDepartmentWorkspace(int $departmentId, bool $includeGateways, ?array $departmentRow = null): array + { + $department = (new departments_o())->select($departmentId); + if (!$department->exists()) { + throw new Exception('Department not found'); + } + + $departmentPayload = [ + 'id' => $departmentId, + 'name' => (string)$department->name->value(), + 'description' => (string)$department->description->value(), + 'order_priority' => (int)$department->order_priority->value(), + ]; + + $transportMode = $this->manager()->getDepartmentTransportMode($departmentId); + $gateways = $this->manager()->listGateways($departmentId, true); + $bindingsByRelayId = $this->indexBindingsByRelayId($gateways); + $relayCatalog = $this->indexRelayCatalog($departmentId); + $consumersByRelayId = []; + + $lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); + $gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); + $gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId); + $selfServe = $this->buildSelfServePayload($department, $lanes); + $scanners = $this->buildScannerPayloads($departmentId, $lanes); + $issues = $this->buildIssues($transportMode, $gateways, $lanes, $gates, $scanners, $selfServe); + $actions = $this->buildActions($departmentId, $gateways, $lanes, $gates, $scanners, $selfServe); + $summary = $this->buildSummary( + $departmentPayload, + $departmentRow, + $transportMode, + $gateways, + $lanes, + $gates, + $scanners, + $selfServe, + $issues + ); + + return [ + 'department' => $departmentPayload, + 'summary' => $summary, + 'gateways' => $includeGateways ? $gateways : [], + 'lanes' => $lanes, + 'self_serve' => $selfServe, + 'gates' => $gates, + 'scanners' => $scanners, + 'issues' => $issues, + 'actions' => $actions, + ]; + } + + /** + * @param array> $gateways + * @return array>> + */ + private function indexBindingsByRelayId(array $gateways): array + { + $bindingsByRelayId = []; + + foreach ($gateways as $gateway) { + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) + ? (array)$gateway['bindings'] + : []; + + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $binding['gateway_label'] = (string)($gateway['label'] ?? ('Gateway ' . ($gateway['id'] ?? ''))); + $binding['gateway_status'] = (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE); + $binding['is_primary_gateway'] = (bool)($gateway['is_primary'] ?? false); + $bindingsByRelayId[$relayId][] = $binding; + } + } + + foreach ($bindingsByRelayId as $relayId => $bindings) { + usort($bindings, static function (array $left, array $right): int { + return ((int)($right['is_primary_gateway'] ?? 0) <=> (int)($left['is_primary_gateway'] ?? 0)) + ?: ((int)($left['gateway_id'] ?? 0) <=> (int)($right['gateway_id'] ?? 0)); + }); + $bindingsByRelayId[$relayId] = $bindings; + } + + return $bindingsByRelayId; + } + + /** + * @return array> + * @throws Exception + */ + private function indexRelayCatalog(int $departmentId): array + { + $catalog = []; + foreach ((new department_relays_o())->getDepartmentRelays($departmentId) as $relay) { + if (!$relay->exists()) { + continue; + } + + $relayId = trim((string)$relay->relay_id->value()); + if ($relayId === '') { + continue; + } + + $catalog[$relayId] = $relay->asArray(); + } + + return $catalog; + } + + /** + * @param array>> $bindingsByRelayId + * @param array> $relayCatalog + * @param array>> $consumersByRelayId + * @return array> + * @throws Exception + */ + private function buildLanePayloads( + int $departmentId, + array $bindingsByRelayId, + array $relayCatalog, + array &$consumersByRelayId + ): array { + $lanes = []; + $slotMap = [ + 'relay_in_id' => 'ENTRY', + 'relay_out_id' => 'EXIT', + 'relay_machine_id' => 'MACHINE', + 'relay_machine_program_picker_id' => 'PROGRAM_PICKER', + 'relay_machine_cleaner_id' => 'CLEANER', + ]; + + foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) { + if (!$lane->exists()) { + continue; + } + + $relaySlots = []; + $boundRelayCount = 0; + foreach ($slotMap as $property => $slotName) { + $relayId = trim((string)$lane->{$property}->value()); + if ($relayId === '') { + continue; + } + + $consumersByRelayId[$relayId][] = [ + 'type' => 'lane', + 'id' => (int)$lane->id, + 'slot' => $slotName, + 'label' => (string)$lane->name->value(), + ]; + + $coverage = $this->buildRelayCoverage($relayId, $bindingsByRelayId); + if ((bool)($coverage['covered'] ?? false)) { + $boundRelayCount += 1; + } + + $relaySlots[] = [ + 'slot' => $slotName, + 'relay_id' => $relayId, + 'catalog' => $relayCatalog[$relayId] ?? null, + 'coverage' => $coverage, + ]; + } + + $requiredRelayCount = count($relaySlots); + $laneStatus = 'UNKNOWN'; + try { + $laneStatus = (string)$lane->getLaneStatus()->name; + } catch (\Throwable) { + } + + $lanes[] = [ + 'id' => (int)$lane->id, + 'department' => (int)$lane->department->value(), + 'name' => (string)$lane->name->value(), + 'relay_in_id' => $lane->relay_in_id->value() === null ? null : (string)$lane->relay_in_id->value(), + 'relay_out_id' => $lane->relay_out_id->value() === null ? null : (string)$lane->relay_out_id->value(), + 'relay_machine_id' => $lane->relay_machine_id->value() === null ? null : (string)$lane->relay_machine_id->value(), + 'relay_machine_program_picker_id' => $lane->relay_machine_program_picker_id->value() === null ? null : (string)$lane->relay_machine_program_picker_id->value(), + 'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(), + 'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(), + 'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(), + 'status' => $laneStatus, + 'self_serve_products' => $lane->getSelfServeLaneProducts(), + 'relay_slots' => $relaySlots, + 'binding_coverage' => [ + 'required' => $requiredRelayCount, + 'bound' => $boundRelayCount, + 'missing' => max(0, $requiredRelayCount - $boundRelayCount), + 'state' => $requiredRelayCount === 0 + ? 'NOT_REQUIRED' + : ($boundRelayCount === $requiredRelayCount ? 'READY' : 'MISSING'), + ], + 'links' => [ + 'legacy' => '/superuser/department/lanes/' . (int)$lane->id, + 'self_serve_studio' => '/admin/' . $departmentId . '/modules/self-serve/studio', + ], + ]; + } + + return $lanes; + } + + /** + * @param array>> $bindingsByRelayId + * @param array> $relayCatalog + * @param array>> $consumersByRelayId + * @return array> + * @throws Exception + */ + private function buildGatePayloads( + int $departmentId, + array $bindingsByRelayId, + array $relayCatalog, + array &$consumersByRelayId + ): array { + $gates = []; + + foreach ((new department_gates_o())->getDepartmentGates($departmentId) as $gate) { + if (!$gate->exists()) { + continue; + } + + $config = (array)$gate->config->value(); + $gateType = strtoupper(trim((string)($config['type'] ?? 'UNKNOWN'))); + $relayId = trim((string)($config['relay_id'] ?? '')); + + if ($gateType === 'RELAY' && $relayId !== '') { + $consumersByRelayId[$relayId][] = [ + 'type' => 'gate', + 'id' => (int)$gate->id, + 'slot' => ((bool)$gate->is_entrance->value() ? 'ENTRANCE' : ((bool)$gate->is_exit->value() ? 'EXIT' : 'GENERAL')), + 'label' => (string)$gate->name->value(), + ]; + } + + $coverage = $gateType === 'RELAY' && $relayId !== '' + ? $this->buildRelayCoverage($relayId, $bindingsByRelayId) + : null; + + $gates[] = [ + 'id' => (int)$gate->id, + 'department' => (int)$gate->department->value(), + 'name' => (string)$gate->name->value(), + 'is_entrance' => (bool)$gate->is_entrance->value(), + 'is_exit' => (bool)$gate->is_exit->value(), + 'config' => $config, + 'transport_type' => $gateType, + 'config_complete' => $this->isGateConfigComplete($config), + 'relay' => $relayId !== '' ? ($relayCatalog[$relayId] ?? ['relay_id' => $relayId]) : null, + 'coverage' => $coverage, + ]; + } + + return $gates; + } + + /** + * @param array> $lanes + * @return array + * @throws Exception + */ + private function buildSelfServePayload(departments_o $department, array $lanes): array + { + $enabled = false; + try { + $enabled = $department->getSelfServeEnabled(); + } catch (\Throwable) { + } + + $readyLanes = array_values(array_filter($lanes, static function (array $lane): bool { + return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY'; + })); + + $taskRows = (new \objects\department_selfserve_tasks_o())->getFieldsWhere([ + 'department' => (int)$department->id, + 'deleted_at' => null, + ], ['id', 'lane', 'product']); + + $productIds = []; + foreach ($taskRows as $taskRow) { + if (isset($taskRow['product'])) { + $productIds[(int)$taskRow['product']] = true; + } + } + + return [ + 'enabled' => $enabled, + 'lane_count' => count($lanes), + 'ready_lanes' => count($readyLanes), + 'configured_task_count' => count($taskRows), + 'configured_product_count' => count($productIds), + 'readiness_state' => !$enabled + ? 'DISABLED' + : (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')), + 'links' => [ + 'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio', + 'legacy' => '/superuser/selfserve', + ], + ]; + } + + /** + * @param array> $lanes + * @return array> + * @throws Exception + */ + private function buildScannerPayloads(int $departmentId, array $lanes): array + { + $laneIndex = []; + foreach ($lanes as $lane) { + $laneIndex[(int)$lane['id']] = $lane; + } + + $recentScansByScannerId = $this->groupRecentScansByScannerId($departmentId); + $scanners = []; + foreach ((new plate_scanners_o())->getDepartmentScanners($departmentId) as $scanner) { + if (!$scanner->exists()) { + continue; + } + + $scannerPayload = $scanner->asArray(); + $laneId = isset($scannerPayload['lane_id']) ? (int)($scannerPayload['lane_id'] ?? 0) : 0; + $assignedLane = $laneId > 0 ? ($laneIndex[$laneId] ?? null) : null; + $recentScans = $recentScansByScannerId[(int)$scanner->id] ?? []; + $recentScanAt = $recentScans !== [] ? ($recentScans[0]['created_at'] ?? null) : null; + + $assignmentState = $laneId <= 0 + ? 'UNASSIGNED' + : ($assignedLane === null + ? 'INVALID' + : (((int)($assignedLane['binding_coverage']['missing'] ?? 0) === 0) ? 'READY' : 'PARTIAL')); + + $scanners[] = [ + ...$scannerPayload, + 'assigned_lane' => $assignedLane, + 'assignment_state' => $assignmentState, + 'recent_scan_at' => $recentScanAt, + 'recent_scans' => $recentScans, + 'recent_scan_count' => count($recentScans), + ]; + } + + return $scanners; + } + + /** + * @return array>> + */ + private function groupRecentScansByScannerId(int $departmentId): array + { + $rows = (new plate_scans_o())->getFieldsWhere([ + 'department_id' => $departmentId, + ], ['id', 'plate_scanner_id', 'plate', 'bay_id', 'created_at']); + + usort($rows, static function (array $left, array $right): int { + $rightTimestamp = strtotime((string)($right['created_at'] ?? '')) ?: 0; + $leftTimestamp = strtotime((string)($left['created_at'] ?? '')) ?: 0; + return $rightTimestamp <=> $leftTimestamp ?: ((int)($right['id'] ?? 0) <=> (int)($left['id'] ?? 0)); + }); + + $grouped = []; + foreach ($rows as $row) { + $scannerId = (int)($row['plate_scanner_id'] ?? 0); + if ($scannerId <= 0) { + continue; + } + + if (!isset($grouped[$scannerId])) { + $grouped[$scannerId] = []; + } + + if (count($grouped[$scannerId]) >= 5) { + continue; + } + + $grouped[$scannerId][] = [ + 'id' => (int)($row['id'] ?? 0), + 'plate' => (string)($row['plate'] ?? ''), + 'bay_id' => isset($row['bay_id']) ? (string)$row['bay_id'] : null, + 'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null, + ]; + } + + return $grouped; + } + + /** + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @return array> + */ + private function buildIssues( + string $transportMode, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe + ): array { + $issues = []; + + if ($gateways === []) { + $issues[] = [ + 'severity' => 'danger', + 'code' => 'NO_GATEWAY', + 'message' => 'No edge gateway has been claimed for this department.', + ]; + } + + $onlineGateways = array_values(array_filter($gateways, static function (array $gateway): bool { + return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE; + })); + + if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY && $onlineGateways === []) { + $issues[] = [ + 'severity' => 'danger', + 'code' => 'NO_ONLINE_GATEWAY', + 'message' => 'Gateway transport mode is enabled, but no department gateway is currently online.', + ]; + } + + foreach ($lanes as $lane) { + if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'LANE_BINDING_GAP', + 'message' => 'Lane ' . (string)$lane['name'] . ' is missing relay bindings.', + 'target_type' => 'lane', + 'target_id' => (int)$lane['id'], + ]; + } + } + + foreach ($gates as $gate) { + if (!($gate['config_complete'] ?? false)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'GATE_CONFIG_INCOMPLETE', + 'message' => 'Gate ' . (string)$gate['name'] . ' has incomplete transport configuration.', + 'target_type' => 'gate', + 'target_id' => (int)$gate['id'], + ]; + continue; + } + + if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'GATE_BINDING_MISSING', + 'message' => 'Gate ' . (string)$gate['name'] . ' is assigned to an unbound relay.', + 'target_type' => 'gate', + 'target_id' => (int)$gate['id'], + ]; + } + } + + foreach ($scanners as $scanner) { + if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'SCANNER_UNASSIGNED', + 'message' => 'Scanner ' . (string)$scanner['name'] . ' is not assigned to a default lane.', + 'target_type' => 'scanner', + 'target_id' => (int)$scanner['id'], + ]; + } elseif (($scanner['assignment_state'] ?? '') === 'PARTIAL') { + $issues[] = [ + 'severity' => 'info', + 'code' => 'SCANNER_LANE_PARTIAL', + 'message' => 'Scanner ' . (string)$scanner['name'] . ' is assigned to a lane with missing relay coverage.', + 'target_type' => 'scanner', + 'target_id' => (int)$scanner['id'], + ]; + } + } + + if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'SELFSERVE_PARTIAL_READY', + 'message' => 'Self-serve is enabled, but one or more lanes are missing required relay coverage.', + ]; + } + + return $issues; + } + + /** + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @return array> + */ + private function buildActions( + int $departmentId, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe + ): array { + $actions = [ + [ + 'code' => 'OPEN_GATEWAY_TAB', + 'label' => 'Open gateway controls', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gateways', + ], + ]; + + if ($gateways === []) { + $actions[] = [ + 'code' => 'INSTALL_GATEWAY', + 'label' => 'Install first edge gateway', + 'path' => '/superuser/configuration/edgegateway', + ]; + } + + foreach ($lanes as $lane) { + if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) { + $actions[] = [ + 'code' => 'REVIEW_LANE_BINDINGS', + 'label' => 'Resolve lane bindings', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=lanes', + ]; + break; + } + } + + foreach ($gates as $gate) { + if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) { + $actions[] = [ + 'code' => 'REVIEW_GATE_BINDINGS', + 'label' => 'Resolve gate relay bindings', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gates', + ]; + break; + } + } + + foreach ($scanners as $scanner) { + if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') { + $actions[] = [ + 'code' => 'ASSIGN_SCANNERS', + 'label' => 'Assign scanners to lanes', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=scanners', + ]; + break; + } + } + + if (($selfServe['enabled'] ?? false) && (int)($selfServe['lane_count'] ?? 0) > 0) { + $actions[] = [ + 'code' => 'OPEN_SELFSERVE_STUDIO', + 'label' => 'Open self-serve studio', + 'path' => '/admin/' . $departmentId . '/modules/self-serve/studio', + ]; + } + + return $actions; + } + + /** + * @param array $departmentPayload + * @param array|null $departmentRow + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @param array> $issues + * @return array + */ + private function buildSummary( + array $departmentPayload, + ?array $departmentRow, + string $transportMode, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe, + array $issues + ): array { + $onlineGatewayCount = count(array_filter($gateways, static function (array $gateway): bool { + return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE; + })); + $primaryGateway = null; + foreach ($gateways as $gateway) { + if (!empty($gateway['is_primary'])) { + $primaryGateway = [ + 'id' => (int)$gateway['id'], + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE), + ]; + break; + } + } + + if ($primaryGateway === null && $gateways !== []) { + $gateway = $gateways[0]; + $primaryGateway = [ + 'id' => (int)$gateway['id'], + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE), + ]; + } + + $requiredRelayIds = []; + $coveredRelayIds = []; + foreach ($lanes as $lane) { + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $requiredRelayIds[$relayId] = true; + if (!empty($slot['coverage']['covered'])) { + $coveredRelayIds[$relayId] = true; + } + } + } + foreach ($gates as $gate) { + if (($gate['transport_type'] ?? '') !== 'RELAY') { + continue; + } + + $relayId = trim((string)($gate['relay']['relay_id'] ?? $gate['config']['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $requiredRelayIds[$relayId] = true; + if (!empty($gate['coverage']['covered'])) { + $coveredRelayIds[$relayId] = true; + } + } + + $assignedScannerCount = count(array_filter($scanners, static function (array $scanner): bool { + return (int)($scanner['lane_id'] ?? 0) > 0; + })); + + $recentScanAt = null; + foreach ($scanners as $scanner) { + $candidate = isset($scanner['recent_scan_at']) ? (string)$scanner['recent_scan_at'] : null; + if ($candidate === null || trim($candidate) === '') { + continue; + } + if ($recentScanAt === null || strtotime($candidate) > strtotime($recentScanAt)) { + $recentScanAt = $candidate; + } + } + + $relayGateCount = count(array_filter($gates, static function (array $gate): bool { + return ($gate['transport_type'] ?? '') === 'RELAY'; + })); + $phoneGateCount = count(array_filter($gates, static function (array $gate): bool { + return ($gate['transport_type'] ?? '') === 'PHONE_CALL'; + })); + + return [ + 'department_id' => (int)$departmentPayload['id'], + 'department_name' => (string)$departmentPayload['name'], + 'order_priority' => (int)($departmentRow['order_priority'] ?? $departmentPayload['order_priority'] ?? PHP_INT_MAX), + 'transport_mode' => $transportMode, + 'gateway_count' => count($gateways), + 'online_gateway_count' => $onlineGatewayCount, + 'primary_gateway' => $primaryGateway, + 'lane_count' => count($lanes), + 'self_serve_enabled' => (bool)($selfServe['enabled'] ?? false), + 'self_serve_ready_lanes' => (int)($selfServe['ready_lanes'] ?? 0), + 'required_relay_count' => count($requiredRelayIds), + 'bound_relay_count' => count($coveredRelayIds), + 'missing_binding_count' => max(0, count($requiredRelayIds) - count($coveredRelayIds)), + 'gate_count' => count($gates), + 'gate_transport_mix' => [ + 'relay' => $relayGateCount, + 'phone_call' => $phoneGateCount, + ], + 'scanner_count' => count($scanners), + 'assigned_scanner_count' => $assignedScannerCount, + 'recent_scan_at' => $recentScanAt, + 'issue_count' => count($issues), + 'health' => $this->deriveHealthState($transportMode, $gateways, $issues), + ]; + } + + /** + * @param array> $gateways + * @param array> $issues + */ + private function deriveHealthState(string $transportMode, array $gateways, array $issues): string + { + foreach ($issues as $issue) { + if (($issue['severity'] ?? '') === 'danger') { + return 'AT_RISK'; + } + } + + if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) { + foreach ($gateways as $gateway) { + if (strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE) { + return $issues === [] ? 'READY' : 'PARTIAL'; + } + } + return 'AT_RISK'; + } + + return $issues === [] ? 'READY' : 'PARTIAL'; + } + + /** + * @param array>> $bindingsByRelayId + * @return array + */ + private function buildRelayCoverage(string $relayId, array $bindingsByRelayId): array + { + $bindings = $bindingsByRelayId[$relayId] ?? []; + $primaryBinding = $bindings[0] ?? null; + + return [ + 'relay_id' => $relayId, + 'covered' => $bindings !== [], + 'status' => $bindings !== [] ? 'BOUND' : 'MISSING', + 'binding_count' => count($bindings), + 'primary_binding' => $primaryBinding, + 'bindings' => $bindings, + ]; + } + + /** + * @param array> $gateways + * @param array>> $consumersByRelayId + * @return array> + */ + private function applyBindingConsumerContexts(array $gateways, array $consumersByRelayId): array + { + foreach ($gateways as $gatewayIndex => $gateway) { + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) + ? (array)$gateway['bindings'] + : []; + + foreach ($bindings as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) + ? (array)$binding['metadata'] + : []; + $consumerContexts = $consumersByRelayId[$relayId] ?? []; + $metadata['consumer_contexts'] = $consumerContexts; + $metadata['consumers'] = $consumerContexts; + $bindings[$bindingIndex]['metadata'] = $metadata; + $bindings[$bindingIndex]['consumer_contexts'] = $consumerContexts; + } + + $gateways[$gatewayIndex]['bindings'] = $bindings; + } + + return $gateways; + } + + /** + * @param array $config + */ + private function isGateConfigComplete(array $config): bool + { + $type = strtoupper(trim((string)($config['type'] ?? ''))); + if ($type === 'PHONE_CALL') { + return trim((string)($config['phone_number'] ?? '')) !== '' + && isset($config['call_duration_threshold']); + } + + if ($type === 'RELAY') { + return trim((string)($config['relay_id'] ?? '')) !== ''; + } + + return false; + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php index f0529e29..85967b2e 100644 --- a/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php @@ -2999,24 +2999,11 @@ BASH; } $apiBaseUrl = $this->getApiBaseUrl(); - $parsed = parse_url($apiBaseUrl); - $host = $parsed['host'] ?? null; - if (!is_string($host) || trim($host) === '') { + if (trim($apiBaseUrl) === '') { return null; } - $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http'; - $port = (int)(getenv('EDGE_PUBLIC_BROKER_PORT') ?: 4300); - if ($port <= 0) { - $port = 4300; - } - - $hostWithPort = $host; - if (!(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { - $hostWithPort .= ':' . $port; - } - - return $scheme . '://' . $hostWithPort; + return rtrim($apiBaseUrl, '/') . '/edge-broker'; } private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string @@ -3034,9 +3021,11 @@ BASH; } $port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : ''; + $basePath = rtrim((string)($parsed['path'] ?? ''), '/'); $normalizedPath = '/' . ltrim($path, '/'); + $fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath); - return $scheme . '://' . $host . $port . ($normalizedPath === '/' ? '' : $normalizedPath); + return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath); } private function buildBrokerInternalUrl(): ?string @@ -3133,6 +3122,29 @@ BASH; } $metadata['fallback_mode'] = $fallbackMode; + $consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? []; + if (!is_array($consumerContexts)) { + $consumerContexts = []; + } + $consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array { + if (!is_array($consumer)) { + return null; + } + + $consumerType = trim((string)($consumer['type'] ?? '')); + if ($consumerType === '') { + return null; + } + + return [ + 'type' => $consumerType, + 'id' => isset($consumer['id']) ? (int)$consumer['id'] : null, + 'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null, + 'label' => isset($consumer['label']) ? (string)$consumer['label'] : null, + ]; + }, $consumerContexts))); + $metadata['consumer_contexts'] = $consumerContexts; + $metadata['consumers'] = $consumerContexts; if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) { $metadata['last_resolution'] = (array)$binding['last_resolution']; diff --git a/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php b/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php index dc808eb6..edc9b27d 100644 --- a/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php +++ b/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\edge_gateway_manager; +use classes\edge_gateway_department_workspace_service; use classes\edge_gateway_operation_exception; use classes\edge_gateway_operation_service; use classes\edge_gateway_registry_service; @@ -23,6 +24,12 @@ class moduleEdgeGatewayRoute $this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [ 'modules_shelly_config' => 'List edge gateway module fleet', ]); + $this->get('/modules/edge-gateways/workspace/departments', fn() => $this->handleDepartmentWorkspaceList(), [ + 'modules_shelly_config' => 'List department hardware workspaces', + ]); + $this->get('/modules/edge-gateways/workspace/departments/{id}', fn() => $this->handleDepartmentWorkspaceDetail(), [ + 'modules_shelly_config' => 'View department hardware workspace detail', + ]); $this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [ 'modules_shelly_config' => 'View edge gateway module detail', ]); @@ -102,6 +109,26 @@ class moduleEdgeGatewayRoute $response->success($gateway); } + private function handleDepartmentWorkspaceList(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $summaries = $this->workspaces()->listDepartmentSummaries(); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_LIST', 'Listed department hardware workspace summaries'); + $response->success($summaries); + } + + private function handleDepartmentWorkspaceDetail(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $departmentId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($departmentId, 'id'); + $this->requireDepartmentAccess((string)$departmentId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_GET', 'Fetched department hardware workspace detail'); + $response->success($this->workspaces()->getDepartmentWorkspace($departmentId)); + } + private function handleGatewayTasksPage(): void { global /** @var response $response */ $response; @@ -395,4 +422,9 @@ class moduleEdgeGatewayRoute { return new edge_gateway_manager(); } + + private function workspaces(): edge_gateway_department_workspace_service + { + return new edge_gateway_department_workspace_service(); + } } diff --git a/services/nginx/app/objects/department_gates_o.php b/services/nginx/app/objects/department_gates_o.php index 68aabf9e..3a0f21dd 100644 --- a/services/nginx/app/objects/department_gates_o.php +++ b/services/nginx/app/objects/department_gates_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\department_gate_config; +use classes\edge_gateway_manager; use classes\bird; use classes\db; use classes\object_property; @@ -262,6 +263,11 @@ class department_gates_o extends db return new slack(); } + protected function resolveEdgeGatewayManager(): edge_gateway_manager + { + return new edge_gateway_manager(); + } + /** * @return array> */ @@ -367,9 +373,25 @@ class department_gates_o extends db $this->requireSelected(); $config = (array)$this->config->value(); - if (!$this->matchesPhoneCallGateConfig($config)) { - throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? '')); + if ($this->matchesPhoneCallGateConfig($config)) { + $this->openPhoneCallGate($config); + return; } + + if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') { + $this->openRelayBackedGate($config); + return; + } + + throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? '')); + } + + /** + * @param array $config + * @throws Exception + */ + protected function openPhoneCallGate(array $config): void + { if (!isset($config['phone_number'])) { throw new Exception('Phone number is required for PHONE_CALL gate type'); } @@ -391,4 +413,30 @@ class department_gates_o extends db throw new Exception('Failed to open gate relay via phone call', 0, $e); } } + + /** + * @param array $config + * @throws Exception + */ + protected function openRelayBackedGate(array $config): void + { + $relayId = trim((string)($config['relay_id'] ?? '')); + if ($relayId === '') { + throw new Exception('relay_id is required for RELAY gate type'); + } + + $departmentId = (int)$this->department->value(); + if ($departmentId <= 0) { + throw new Exception('Gate department is invalid'); + } + + $pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1; + $manager = $this->resolveEdgeGatewayManager(); + $manager->dispatchRelaySwitch($departmentId, $relayId, true); + + if ($pulseSeconds > 0) { + usleep($pulseSeconds * 1000000); + $manager->dispatchRelaySwitch($departmentId, $relayId, false); + } + } } diff --git a/services/nginx/app/objects/plate_scanners_o.php b/services/nginx/app/objects/plate_scanners_o.php index 0d23570b..dba11b92 100644 --- a/services/nginx/app/objects/plate_scanners_o.php +++ b/services/nginx/app/objects/plate_scanners_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use Exception; use traits\db_object_t; class plate_scanners_o extends db @@ -11,12 +12,16 @@ class plate_scanners_o extends db use db_object_t; public object_property $department_id; + public object_property $lane_id; public object_property $name; public object_property $notes; public object_property $api_key; + private static bool $schemaInitialized = false; + public function structure(): void { + self::ensureSchema(); $this->setTable('plate_scanners'); } @@ -41,22 +46,25 @@ class plate_scanners_o extends db public function getObjectProperties(): void { $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int'); + $this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int'); $this->name = new object_property($this->table, $this->id, 'name', 'string'); $this->notes = new object_property($this->table, $this->id, 'notes', 'string'); $this->api_key = new object_property($this->table, $this->id, 'api_key', 'string'); } - public function add(int $department_id, string $name, string $notes): void + public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void { global $db, $response; try { // Generate an API key $api_key = bin2hex(random_bytes(32)); + $lane_id = $this->normalizeLaneId($department_id, $lane_id); // Avoid SQL injection $name = $db->escape_string($name); $notes = $db->escape_string($notes); + $laneValue = $lane_id === null ? 'NULL' : (string)$lane_id; // Create a new record in the database - $sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')"; + $sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')"; $db->query($sql); // Get the id of the new record @@ -69,7 +77,14 @@ class plate_scanners_o extends db } } - public function edit(int $id, int $department_id, string $name, string $notes): void + public function edit( + int $id, + int $department_id, + string $name, + string $notes, + ?int $lane_id = null, + bool $laneIdProvided = false + ): void { global $db, $response; $this->id = $id; @@ -77,8 +92,17 @@ class plate_scanners_o extends db // Avoid SQL injection $name = $db->escape_string($name); $notes = $db->escape_string($notes); + $setParts = [ + "department_id = $department_id", + "name = '$name'", + "notes = '$notes'", + ]; + if ($laneIdProvided) { + $lane_id = $this->normalizeLaneId($department_id, $lane_id); + $setParts[] = 'lane_id = ' . ($lane_id === null ? 'NULL' : (string)$lane_id); + } // Update the record in the database - $sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id"; + $sql = "UPDATE $this->table SET " . implode(', ', $setParts) . " WHERE id = $id"; $db->query($sql); // Set the values of the object properties @@ -102,4 +126,116 @@ class plate_scanners_o extends db } return $this; } -} \ No newline at end of file + + /** + * @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string} + * @throws Exception + */ + public function asArray(): array + { + $this->requireSelected(); + + return [ + 'id' => (int)$this->id, + 'department_id' => (int)$this->department_id->value(), + 'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(), + 'name' => (string)$this->name->value(), + 'notes' => (string)$this->notes->value(), + 'api_key' => (string)$this->api_key->value(), + ]; + } + + /** + * @return array + * @throws Exception + */ + public function getDepartmentScanners(int $departmentId): array + { + $scanners = []; + $rows = self::getFieldsWhere([ + 'department_id' => $departmentId, + ], ['id']); + + foreach ($rows as $row) { + $scanner = (new plate_scanners_o())->select((int)$row['id']); + if ($scanner->exists()) { + $scanners[] = $scanner; + } + } + + return $scanners; + } + + /** + * @throws Exception + */ + public function rotateApiKey(int $id): array + { + $scanner = $this->select($id); + if (!$scanner->exists()) { + throw new Exception('Number plate scanner not found'); + } + + $newApiKey = bin2hex(random_bytes(32)); + $scanner->api_key->set($newApiKey); + + return $scanner->asArray(); + } + + private function normalizeLaneId(int $departmentId, ?int $laneId): ?int + { + if ($laneId === null || $laneId <= 0) { + return null; + } + + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new Exception('Department lane not found'); + } + + if ((int)$lane->department->value() !== $departmentId) { + throw new Exception('The lane does not belong to the number plate scanner department'); + } + + return (int)$lane->id; + } + + private static function ensureSchema(): void + { + if (self::$schemaInitialized) { + return; + } + + global $db; + + if (!self::tableHasColumn('plate_scanners', 'lane_id')) { + $db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`"); + } + + self::$schemaInitialized = true; + } + + private static function tableHasColumn(string $table, string $column): bool + { + global $db; + + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $result = $db->query( + "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'" + ); + + if (!$result) { + return false; + } + + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } +} diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index 260ddaa8..79693b66 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -69,6 +69,17 @@ class machineButtonPressRoute return (int)$lane->id; } + if ($plateScanner->lane_id->value() !== null) { + $lane = (new department_lanes_o())->select((int)$plateScanner->lane_id->value()); + if (!$lane->exists()) { + $response->error('The default lane configured for the plate scanner no longer exists', 404); + } + if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) { + $response->error('The default lane does not belong to the plate scanner department', 403); + } + return (int)$lane->id; + } + $lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value()); if (count($lanes) === 1) { return (int)$lanes[0]->id; diff --git a/services/nginx/app/routes/plateScannersRoute.php b/services/nginx/app/routes/plateScannersRoute.php index 7ce0d3dd..ab9c9f97 100644 --- a/services/nginx/app/routes/plateScannersRoute.php +++ b/services/nginx/app/routes/plateScannersRoute.php @@ -34,7 +34,11 @@ class plateScannersRoute 'name', 'notes' ]) - ->listObjectsWithPaginationIfSet() + ->listObjectsWithPaginationIfSet( + static function (array $scanner): array { + return (new plate_scanners_o())->select((int)$scanner['id'])->asArray(); + } + ) ); } else { // Log the incident @@ -68,12 +72,19 @@ class plateScannersRoute if (!isset($data['notes'])) { $response->error('Notes is required', 400); } + $laneId = array_key_exists('lane_id', $data) && $data['lane_id'] !== null + ? (int)$data['lane_id'] + : null; // Add the number plate scanner - (new plate_scanners_o())->add($data['department_id'], $data['name'], $data['notes']); + $scanner = new plate_scanners_o(); + $scanner->add((int)$data['department_id'], (string)$data['name'], (string)$data['notes'], $laneId); // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner'); // Return a success message - $response->success(['message' => 'Number plate scanner added']); + $response->success([ + 'message' => 'Number plate scanner added', + 'scanner' => $scanner->asArray(), + ]); } else { // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); @@ -109,12 +120,25 @@ class plateScannersRoute if (!isset($data['notes'])) { $response->error('Notes is required', 400); } + $laneIdProvided = array_key_exists('lane_id', $data); + $laneId = $laneIdProvided && $data['lane_id'] !== null ? (int)$data['lane_id'] : null; // Edit the number plate scanner - (new plate_scanners_o())->edit($data['id'], $data['department_id'], $data['name'], $data['notes']); + $scanner = new plate_scanners_o(); + $scanner->edit( + (int)$data['id'], + (int)$data['department_id'], + (string)$data['name'], + (string)$data['notes'], + $laneId, + $laneIdProvided + ); // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner'); // Return a success message - $response->success(['message' => 'Number plate scanner edited']); + $response->success([ + 'message' => 'Number plate scanner edited', + 'scanner' => $scanner->asArray(), + ]); } else { // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); @@ -127,6 +151,35 @@ class plateScannersRoute ] ); + $this->post('/numberplatescanners/{id}/rotate-key', function () { + global $response; + $this->requirePermission('edit_number_plate_scanner'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $scannerId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($scannerId, 'id'); + $scanner = (new plate_scanners_o())->select($scannerId); + if (!$scanner->exists()) { + $response->error('Number plate scanner not found', 404); + } + + self::requireDepartmentAccess((int)$scanner->department_id->value()); + $rotatedScanner = (new plate_scanners_o())->rotateApiKey($scannerId); + (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'Successfully rotated a number plate scanner API key'); + $response->success([ + 'message' => 'Number plate scanner API key rotated', + 'scanner' => $rotatedScanner, + 'api_key' => (string)$rotatedScanner['api_key'], + ]); + }, [ + 'edit_number_plate_scanner' => 'Rotate a number plate scanner API key', + ]); + self::get('/department/numberplatescanners', function () { // Require the user to be logged in global $response; @@ -152,12 +205,14 @@ class plateScannersRoute 'department_id' => (int)self::getParameter('id') ], [ 'id', + 'lane_id', 'name', 'notes' ]); // Parse the result foreach ( $result as $key => $value ) { $result[$key]['id'] = (int)$value['id']; + $result[$key]['lane_id'] = $value['lane_id'] === null ? null : (int)$value['lane_id']; } // Return the list of plate scanners $response->success( @@ -176,4 +231,4 @@ class plateScannersRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php new file mode 100644 index 00000000..086b9df2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php @@ -0,0 +1,79 @@ +switchCalls[] = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + 'on' => $on, + ]; + + return [ + 'relay_id' => $logicalRelayId, + 'online' => true, + 'on' => $on, + ]; + } +} + +final class DepartmentGatesRelayOpenHarness extends department_gates_o +{ + public function __construct( + array $config, + int $departmentId, + private readonly DepartmentGatesRelayManagerFake $manager, + ) { + $this->id = 1001; + $departmentProperty = new object_property('department_gates', -1, 'department', 'int'); + $departmentProperty->set($departmentId); + $this->department = $departmentProperty; + + $configProperty = new object_property('department_gates', -1, 'config', 'json'); + $configProperty->set($config); + $this->config = $configProperty; + } + + public function requireSelected(): void + { + } + + protected function resolveEdgeGatewayManager(): edge_gateway_manager + { + return $this->manager; + } +} + +it('dispatches relay-backed gates through the edge gateway relay manager', function (): void { + $manager = new DepartmentGatesRelayManagerFake(); + $gate = new DepartmentGatesRelayOpenHarness([ + 'type' => 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ], 17, $manager); + + $gate->openGate(); + + expect($manager->switchCalls)->toBe([ + [ + 'department_id' => 17, + 'relay_id' => 'ENTRY-GATE-1', + 'on' => true, + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php new file mode 100644 index 00000000..a469081c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php @@ -0,0 +1,30 @@ + 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ]); + + $config->validate(); + + expect($config->toArray())->toMatchArray([ + 'type' => 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ]); +}); + +it('rejects relay gate configs without a logical relay id', function (): void { + $config = new department_gate_config([ + 'type' => 'RELAY', + ]); + + expect(fn() => $config->validate()) + ->toThrow(Exception::class, 'relay_id is required for RELAY gate type'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php new file mode 100644 index 00000000..b9a67382 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php @@ -0,0 +1,17 @@ +not->toBeFalse(); + expect($service)->toContain('class edge_gateway_department_workspace_service'); + expect($service)->toContain("'summary' => \$summary"); + expect($service)->toContain("'gateways' => \$includeGateways ? \$gateways : []"); + expect($service)->toContain("'lanes' => \$lanes"); + expect($service)->toContain("'self_serve' => \$selfServe"); + expect($service)->toContain("'gates' => \$gates"); + expect($service)->toContain("'scanners' => \$scanners"); + expect($service)->toContain("'issues' => \$issues"); + expect($service)->toContain("'actions' => \$actions"); + expect($service)->toContain("'consumer_contexts'"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php index 2285358d..2db2765b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php @@ -5,6 +5,8 @@ it('registers module-scoped edge gateway operator routes', function (): void { expect($route)->not->toBeFalse(); expect($route)->toContain("'/modules/edge-gateways'"); + expect($route)->toContain("'/modules/edge-gateways/workspace/departments'"); + expect($route)->toContain("'/modules/edge-gateways/workspace/departments/{id}'"); expect($route)->toContain("'/modules/edge-gateways/{id}'"); expect($route)->toContain("'/modules/edge-gateways/install-token'"); expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'"); diff --git a/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php b/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php new file mode 100644 index 00000000..c8ff0759 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($route)->toContain("'/numberplatescanners/{id}/rotate-key'"); + expect($route)->toContain("'lane_id'"); + + expect($scannerObject)->not->toBeFalse(); + expect($scannerObject)->toContain('public object_property $lane_id;'); + expect($scannerObject)->toContain('public function rotateApiKey(int $id): array'); + expect($scannerObject)->toContain('ADD COLUMN `lane_id` INT NULL AFTER `department_id`'); +}); + +it('uses the scanner default lane before requiring an explicit lane_id in machine button webhooks', function (): void { + $route = file_get_contents(app_path('routes/machineButtonPressRoute.php')); + + expect($route)->not->toBeFalse(); + expect($route)->toContain('$plateScanner->lane_id->value()'); + expect($route)->toContain('default lane configured for the plate scanner'); +});