"(CCTV, IoT, sensors)" * "Passenger Information Displays (Digital Signage)" -> "Passenger / Public Information Displays" * (the latter keeps the only editorial teaser/description in the table) * 3. creates missing target solutions (title + slug) * 4. sets each target's categories to exactly its group (market categories * are stripped from solutions - they stay in place for other models) * 5. regenerates empty/stale slugs from the (possibly new) title * 6. soft-DELETES every solution record whose title is not a target * (DataHandler delete - restorable via recycler) * * vendor/bin/typo3 vitec:align-solutions --dry-run * vendor/bin/typo3 vitec:align-solutions */ #[AsCommand( name: 'vitec:align-solutions', description: 'Align solution records to EXACTLY the 39 sitemap Generic Solutions in 8 groups (prunes the rest)' )] final class AlignSolutionsCommand extends Command { private const TABLE = 'tx_vitec_domain_model_solution'; /** Binding target: group => solutions, verbatim from the sheet (typo fixed). */ public const TARGET = [ 'Control Room & Command Centre Solutions' => [ 'Control Room Platforms (SOC / NOC / Command Centres)', 'Command & Control Visualisation', 'Operations Management Environments', 'Crisis & Incident Management Centres', ], 'Real-time Monitoring & Situational Awareness' => [ 'Multi-source Video Monitoring', 'Real-time Data Monitoring (CCTV, IoT, sensors)', 'Situational Awareness Platforms', 'Surveillance & Operational Visibility', 'Incident Detection & Response', ], 'Video Distribution & Streaming' => [ 'IPTV Distribution', 'AV-over-IP Distribution', 'Secure Video Streaming (low latency)', 'Multi-site Video Delivery', 'Internal & External Broadcast Distribution', ], 'Video Capture, Encoding & Processing' => [ 'Video Encoding / Decoding', 'Live Video Capture', 'Signal Processing & Conversion', 'Transcoding & Optimisation', 'Contribution & Remote Production', ], 'Video Wall & Data Visualisation' => [ 'Video Wall Platforms', 'Multi-display Visualisation', 'Real-time Dashboards', 'Data Aggregation & Display', 'Operational Intelligence Displays', ], 'Digital Signage & Content Delivery' => [ 'Digital Signage Networks', 'Passenger / Public Information Displays', 'Wayfinding & Messaging', 'Advertising & Sponsorship Displays', 'Targeted Content Delivery', ], 'Enterprise Communications & Engagement' => [ 'Internal Communications (IPTV)', 'Staff Messaging Systems', 'Corporate Broadcasting', 'Guest / Visitor Engagement', 'Multi-location Communication Networks', ], 'Security & Surveillance Solutions' => [ 'Surveillance & CCTV Integration', 'Security Operations Centres (SOC)', 'Perimeter Monitoring', 'Incident Response Systems', 'Secure Monitoring Environments', ], ]; /** Legacy record title => target title (rename instead of delete+create). */ private const RENAMES = [ 'Real-time Data Monitoring (CCTV, sensors)' => 'Real-time Data Monitoring (CCTV, IoT, sensors)', 'Passenger Information Displays (Digital Signage)' => 'Passenger / Public Information Displays', ]; protected function configure(): void { $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dryRun = (bool)$input->getOption('dry-run'); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $solutionRoot = (int)$connection->fetchOne( "SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Solution'" ); if ($solutionRoot <= 0) { $output->writeln('Root category "Solution" not found.'); return Command::FAILURE; } $categoryPid = (int)$connection->fetchOne('SELECT pid FROM sys_category WHERE uid = ' . $solutionRoot); // ---- 1. group categories ------------------------------------------ $groupCats = []; $groupDatamap = []; $index = 0; $newGroupIds = []; foreach (array_keys(self::TARGET) as $group) { $uid = (int)$connection->fetchOne( 'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ? AND title = ?', [$solutionRoot, $group] ); if ($uid > 0) { $groupCats[$group] = $uid; } else { $newId = 'NEW' . ++$index; $newGroupIds[$newId] = $group; $groupDatamap['sys_category'][$newId] = ['pid' => $categoryPid, 'parent' => $solutionRoot, 'title' => $group]; $output->writeln(($dryRun ? 'plan ' : 'create') . ' Gruppe: ' . $group); } } if (!$dryRun && $groupDatamap !== []) { $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($groupDatamap, []); $dataHandler->process_datamap(); foreach ($newGroupIds as $newId => $group) { $groupCats[$group] = (int)($dataHandler->substNEWwithIDs[$newId] ?? 0); } } // target title => group $groupByTitle = []; foreach (self::TARGET as $group => $titles) { foreach ($titles as $title) { $groupByTitle[$title] = $group; } } $solutions = []; // is_group records (the 8 group-level cards, hybrid 2026-09-24) are // invisible to this command: they are neither targets nor prunable. foreach ($connection->fetchAllAssociative( 'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0 AND is_group = 0' ) as $row) { $solutions[(string)$row['title']] = ['uid' => (int)$row['uid'], 'slug' => (string)$row['slug']]; } $pids = $connection->fetchFirstColumn('SELECT pid FROM ' . self::TABLE . ' WHERE deleted = 0'); $pidCounts = array_count_values(array_map('intval', $pids)); arsort($pidCounts); $storagePid = (int)(array_key_first($pidCounts) ?? 0); $datamap = []; $usedSlugs = []; // ---- 2. renames ---------------------------------------------------- foreach (self::RENAMES as $oldTitle => $targetTitle) { if (!isset($solutions[$oldTitle])) { continue; } if (isset($solutions[$targetTitle])) { $output->writeln('Rename uebersprungen, Ziel existiert schon: ' . $targetTitle . ''); continue; } $record = $solutions[$oldTitle]; $datamap[self::TABLE][$record['uid']]['title'] = $targetTitle; $datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($targetTitle, $usedSlugs); $solutions[$targetTitle] = $record; unset($solutions[$oldTitle]); $output->writeln(sprintf('%s uid %d: "%s" -> "%s"', $dryRun ? 'plan ' : 'rename', $record['uid'], $oldTitle, $targetTitle)); } // ---- 3.-5. targets: create / categories / slugs ------------------- $mm = []; foreach ($connection->fetchAllAssociative( "SELECT uid_local, uid_foreign FROM sys_category_record_mm WHERE tablenames = ? AND fieldname = 'categories'", [self::TABLE] ) as $row) { $mm[(int)$row['uid_foreign']][] = (int)$row['uid_local']; } $created = 0; $newIndex = 0; foreach ($groupByTitle as $title => $group) { $groupUid = (int)($groupCats[$group] ?? 0); if (isset($solutions[$title])) { $record = $solutions[$title]; $have = $mm[$record['uid']] ?? []; // categories := exactly the group (strips market categories) if ($groupUid > 0 && ($have !== [$groupUid])) { $datamap[self::TABLE][$record['uid']]['categories'] = (string)$groupUid; } if ($groupUid === 0 && $dryRun) { $output->writeln('plan Kategorie (neu anzulegende Gruppe) fuer: ' . $title); } $slug = (string)($datamap[self::TABLE][$record['uid']]['slug'] ?? $record['slug']); if ($slug === '') { $datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($title, $usedSlugs); } } else { $newId = 'NEW_S' . ++$newIndex; $datamap[self::TABLE][$newId] = [ 'pid' => $storagePid, 'title' => $title, 'slug' => $this->slugFor($title, $usedSlugs), 'categories' => $groupUid > 0 ? (string)$groupUid : '', ]; $created++; $output->writeln(($dryRun ? 'plan ' : 'create') . ' Solution: ' . $title . ' [' . $group . ']'); } } // ---- 6. prune ------------------------------------------------------ $cmdmap = []; $deleted = 0; foreach ($solutions as $title => $record) { if (isset($groupByTitle[$title])) { continue; } $cmdmap[self::TABLE][$record['uid']]['delete'] = 1; $deleted++; $output->writeln(sprintf('%s uid %d: %s', $dryRun ? 'plan-DELETE' : 'DELETE ', $record['uid'], $title)); } $output->writeln(sprintf("\nZiel: %d Solutions in %d Gruppen | anlegen: %d, loeschen: %d, aktualisieren: %d", count($groupByTitle), count(self::TARGET), $created, $deleted, count(array_filter(array_keys($datamap[self::TABLE] ?? []), 'is_int')))); if ($dryRun) { $output->writeln('DRY RUN - nichts geschrieben.'); return Command::SUCCESS; } foreach ([[$datamap, []], [[], $cmdmap]] as [$dm, $cm]) { if ($dm === [] && $cm === []) { continue; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($dm, $cm); if ($dm !== []) { $dataHandler->process_datamap(); } if ($cm !== []) { $dataHandler->process_cmdmap(); } if ($dataHandler->errorLog !== []) { foreach ($dataHandler->errorLog as $error) { $output->writeln('' . $error . ''); } return Command::FAILURE; } } $output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } /** @param string[] $used by-reference collection of slugs handed out in this run */ private function slugFor(string $title, array &$used): string { $slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('+', ' plus ', mb_strtolower($title))), '-'); $candidate = $slug; $suffix = 1; while (in_array($candidate, $used, true)) { $candidate = $slug . '-' . ++$suffix; } $used[] = $candidate; return $candidate; } }