diff --git a/migrations/check_dup_solution_pages.php b/migrations/check_dup_solution_pages.php
new file mode 100644
index 0000000..9ea74b9
--- /dev/null
+++ b/migrations/check_dup_solution_pages.php
@@ -0,0 +1,23 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+$uids = [481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500];
+$rows = $pdo->query('SELECT uid, pid, title, slug, hidden, crdate, sorting FROM pages WHERE uid IN (' . implode(',', $uids) . ') ORDER BY pid, sorting')->fetchAll();
+$parents = [];
+foreach ($rows as $r) { $parents[(int)$r['pid']] = true; }
+foreach (array_keys($parents) as $pid) {
+ $p = $pdo->query("SELECT uid, pid, title, slug, hidden, crdate FROM pages WHERE uid = $pid")->fetch();
+ printf("ELTERN %d | pid %d | %s | /%s | %s| angelegt %s\n", $p['uid'], $p['pid'], $p['title'], trim((string)$p['slug'],'/'), $p['hidden'] ? 'HIDDEN ' : '', date('Y-m-d H:i', (int)$p['crdate']));
+ // Kinderzahl + Inhalt vorhanden?
+ foreach ($rows as $r) {
+ if ((int)$r['pid'] !== (int)$p['uid']) { continue; }
+ $ce = (int)$pdo->query('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ' . (int)$r['uid'])->fetchColumn();
+ printf(" %d | %-42s | /%s | %s| %d CE | angelegt %s\n", $r['uid'], $r['title'], trim((string)$r['slug'],'/'), $r['hidden'] ? 'HIDDEN ' : '', $ce, date('Y-m-d H:i', (int)$r['crdate']));
+ }
+}
diff --git a/migrations/check_solutions.php b/migrations/check_solutions.php
new file mode 100644
index 0000000..93ea6b5
--- /dev/null
+++ b/migrations/check_solutions.php
@@ -0,0 +1,50 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+);
+
+$rows = $pdo->query(
+ "SELECT uid, title, slug, hidden, detail_page, teaser <> '' AS has_teaser, description <> '' AS has_description
+ FROM tx_vitec_domain_model_solution WHERE deleted = 0 ORDER BY title"
+)->fetchAll();
+
+echo "=== solutions (uid | title | slug | flags) ===\n";
+foreach ($rows as $row) {
+ printf("%3d | %-55s | %-45s | %s%s%s%s\n",
+ $row['uid'], $row['title'], $row['slug'],
+ $row['hidden'] ? 'HIDDEN ' : '',
+ $row['detail_page'] ? ('detail_page=' . $row['detail_page'] . ' ') : '',
+ $row['has_teaser'] ? 'teaser ' : '',
+ $row['has_description'] ? 'desc' : ''
+ );
+}
+echo count($rows) . " solutions total\n";
diff --git a/packages/vitec/Classes/Command/AlignSolutionsCommand.php b/packages/vitec/Classes/Command/AlignSolutionsCommand.php
new file mode 100644
index 0000000..2fa2f56
--- /dev/null
+++ b/packages/vitec/Classes/Command/AlignSolutionsCommand.php
@@ -0,0 +1,300 @@
+ "(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). */
+ private 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 = [];
+ foreach ($connection->fetchAllAssociative(
+ 'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 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;
+ }
+}
diff --git a/packages/vitec/Classes/Command/MapSolutionPagesCommand.php b/packages/vitec/Classes/Command/MapSolutionPagesCommand.php
new file mode 100644
index 0000000..16a66dc
--- /dev/null
+++ b/packages/vitec/Classes/Command/MapSolutionPagesCommand.php
@@ -0,0 +1,176 @@
+ "plus")
+ * 2. last page-slug segment == solution slug
+ * Only UNIQUE matches are written; `detail_page` is fill-only (a record that
+ * already points at a page is never changed - a differing find is reported).
+ * Hidden pages match too (pages may be unpublished while content is built);
+ * deleted and sys-folder/link pages never.
+ *
+ * vendor/bin/typo3 vitec:map-solution-pages --dry-run
+ * vendor/bin/typo3 vitec:map-solution-pages
+ */
+#[AsCommand(
+ name: 'vitec:map-solution-pages',
+ description: 'Write matching page uids into the solutions\' detail_page field (unique matches, fill-only)'
+)]
+final class MapSolutionPagesCommand extends Command
+{
+ private const TABLE = 'tx_vitec_domain_model_solution';
+
+ protected function configure(): void
+ {
+ $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
+ $this->addOption('prune-duplicate-pages', null, InputOption::VALUE_NONE, 'Soft-delete duplicate pages (slug suffix -N, zero content elements) that lost the disambiguation');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ Bootstrap::initializeBackendAuthentication();
+ $dryRun = (bool)$input->getOption('dry-run');
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
+
+ $solutions = $connection->fetchAllAssociative(
+ 'SELECT uid, title, slug, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0 ORDER BY title'
+ );
+
+ // standard pages only (doktype 1); hidden allowed, deleted not
+ $pages = $connection->fetchAllAssociative(
+ 'SELECT uid, pid, title, slug, hidden FROM pages WHERE deleted = 0 AND doktype = 1'
+ );
+ $pagesByNormTitle = [];
+ $pagesBySlugTail = [];
+ foreach ($pages as $page) {
+ $pagesByNormTitle[$this->normalize((string)$page['title'])][] = $page;
+ $tail = strtolower(trim((string)strrchr('/' . trim((string)$page['slug'], '/'), '/'), '/'));
+ if ($tail !== '') {
+ $pagesBySlugTail[$tail][] = $page;
+ }
+ }
+
+ $datamap = [];
+ $planned = 0;
+ foreach ($solutions as $solution) {
+ $uid = (int)$solution['uid'];
+ $candidates = $pagesByNormTitle[$this->normalize((string)$solution['title'])] ?? [];
+ if ($candidates === [] && (string)$solution['slug'] !== '') {
+ $candidates = $pagesBySlugTail[strtolower((string)$solution['slug'])] ?? [];
+ }
+ if ($candidates === []) {
+ $output->writeln(sprintf('keine Seite: %s', $solution['title']));
+ continue;
+ }
+ $duplicatePages = [];
+ if (count($candidates) > 1) {
+ // Page-tree duplicates carry TYPO3's slug dedupe suffix "-N"
+ // (found 2026-09-04: two solution branches were created twice,
+ // empty). Prefer the clean slug; the suffixed ones can be
+ // pruned via --prune-duplicate-pages.
+ $clean = array_values(array_filter($candidates, static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug'])));
+ if (count($clean) === 1) {
+ $duplicatePages = array_values(array_filter($candidates, static fn(array $p): bool => (int)$p['uid'] !== (int)$clean[0]['uid']));
+ $candidates = $clean;
+ }
+ }
+ if (count($candidates) > 1) {
+ $output->writeln(sprintf('MEHRDEUTIG (%d Seiten): %s - uids %s',
+ count($candidates), $solution['title'], implode(',', array_column($candidates, 'uid'))));
+ continue;
+ }
+ foreach ($duplicatePages as $duplicate) {
+ $this->duplicates[(int)$duplicate['uid']] = (string)$duplicate['slug'];
+ }
+ $page = $candidates[0];
+ $pageUid = (int)$page['uid'];
+ $current = (int)$solution['detail_page'];
+ if ($current === $pageUid) {
+ $output->writeln(sprintf('ok %s -> Seite %d (bereits gesetzt)', $solution['title'], $pageUid));
+ continue;
+ }
+ if ($current > 0) {
+ $output->writeln(sprintf('KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst',
+ $solution['title'], $current, $pageUid, $page['title']));
+ continue;
+ }
+ $datamap[self::TABLE][$uid]['detail_page'] = $pageUid;
+ $planned++;
+ $output->writeln(sprintf('%s %s -> Seite %d "%s" (/%s)%s',
+ $dryRun ? 'plan ' : 'WRITE ', $solution['title'], $pageUid, $page['title'],
+ trim((string)$page['slug'], '/'), $page['hidden'] ? ' [Seite versteckt]' : ''));
+ }
+
+ $output->writeln(sprintf("\n%d von %d Solutions zu mappen.", $planned, count($solutions)));
+
+ $prune = (bool)$input->getOption('prune-duplicate-pages');
+ $cmdmap = [];
+ foreach ($this->duplicates as $pageUid => $slug) {
+ $contentCount = (int)$connection->fetchOne(
+ 'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$pageUid]
+ );
+ if ($contentCount > 0) {
+ $output->writeln(sprintf('Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht', $pageUid, trim($slug, '/'), $contentCount));
+ continue;
+ }
+ if ($prune) {
+ $cmdmap['pages'][$pageUid]['delete'] = 1;
+ }
+ $output->writeln(sprintf('%s Duplikat-Seite %d (/%s, leer)', $prune ? ($dryRun ? 'plan-DELETE' : 'DELETE ') : 'Duplikat: ', $pageUid, trim($slug, '/')));
+ }
+ if (!$prune && $this->duplicates !== []) {
+ $output->writeln('(Duplikat-Seiten bleiben stehen - mit --prune-duplicate-pages loeschen.)');
+ }
+
+ if ($datamap === [] && $cmdmap === []) {
+ return Command::SUCCESS;
+ }
+ if ($dryRun) {
+ $output->writeln('DRY RUN - nichts geschrieben.');
+ return Command::SUCCESS;
+ }
+
+ $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+ $dataHandler->start($datamap, $cmdmap);
+ if ($datamap !== []) {
+ $dataHandler->process_datamap();
+ }
+ if ($cmdmap !== []) {
+ $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;
+ }
+
+ /** @var array duplicate page uid => slug, collected during matching */
+ private array $duplicates = [];
+
+ private function normalize(string $value): string
+ {
+ return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
+ }
+}