Align solutions to the sitemap's 39 Generic Solutions; map detail pages

- vitec:align-solutions: declarative target = the 39 Generic Solutions in 8
  group categories under the Solution root (sheet is binding, group typo
  Engagament->Engagement fixed). Two legacy records renamed onto their sheet
  names instead of deleted (keeps the only editorial teaser/description),
  slugs generated for all, market categories stripped from solutions, the
  ~82 market-specific records soft-deleted (markets link editorially)
- vitec:map-solution-pages: writes matching page uids into detail_page
  (unique matches, fill-only); disambiguates the duplicated page branches
  by slug suffix and prunes the 10 empty duplicate pages
- read-only diagnostics: check_solutions.php, check_dup_solution_pages.php
This commit is contained in:
2026-09-04 15:30:27 +02:00
parent c91063d6f3
commit e9eaa62d89
4 changed files with 549 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/** Read-only: show the duplicate solution page branches (2026-09-04). */
$root = null; $dir = __DIR__;
for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); }
if ($root === null) { exit("no root\n"); }
$s = require $root . '/config/system/settings.php';
$db = $s['DB']['Connections']['Default'];
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => 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']));
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Read-only: dump every solution record (tx_vitec_domain_model_solution) -
* for the comparison against the "Generic Solutions" tab of the merged
* sitemap (2026-09-04).
*
* Usage: php migrations/check_solutions.php
*/
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) {
$root = $dir;
break;
}
$dir = dirname($dir);
}
if ($root === null) {
exit("Could not locate project root above " . __DIR__ . "\n");
}
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '',
$db['password'] ?? '',
[PDO::ATTR_ERRMODE => 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";

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* DECLARATIVE alignment of the solution records with the "Generic Solutions"
* tab of the merged sitemap - the binding target per decision 2026-09-04
* (Olli, after the interim state briefly held 121 records): the solution
* table contains EXACTLY these 39 solutions in 8 thematic groups. Market-
* specific solutions are NOT records; the market connection is editorial
* (solution lists on market pages).
*
* What a run does (idempotent, --dry-run):
* 1. ensures the 8 group categories under the root category "Solution"
* (group typo "Engagament" corrected to "Engagement")
* 2. renames two legacy records onto their sheet names instead of
* deleting them:
* "Real-time Data Monitoring (CCTV, sensors)" -> "(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('<error>Root category "Solution" not found.</error>');
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('<comment>Rename uebersprungen, Ziel existiert schon: ' . $targetTitle . '</comment>');
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>' . $error . '</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;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Map backend pages to solution records: writes the page uid into the
* solution's `detail_page` field (2026-09-04, request before the weekend).
*
* Matching per solution, conservative:
* 1. page title == solution title (normalized: lowercase, alphanumerics,
* "+" -> "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('<comment>keine Seite: %s</comment>', $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('<comment>MEHRDEUTIG (%d Seiten): %s - uids %s</comment>',
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('<comment>KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst</comment>',
$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('<comment>Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht</comment>', $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>' . $error . '</error>');
}
return Command::FAILURE;
}
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
return Command::SUCCESS;
}
/** @var array<int,string> 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)));
}
}