Two work packages, one deploy.
Workbook field mapping completed (2026-09-18)
- highlights <- g:Technical Highlight (new spec-row component of the
full agency delivery; composeGroup() is generic, no change needed)
- applications <- g:Why Choose Card (benefit cards as one block)
- seometa <- new composed source x:seometa (Introduction body trimmed
at a word boundary to ~155 chars - the SEO sheet ships no meta
description, a trimmed intro beats an empty tag)
- teaser stays the hero one-liner, settling the varchar(255) question
- fix: "Save mapping" now merges over the stored mapping instead of
overwriting it - an empty dropdown no longer wipes a stored selector
Product model: solutions relation and Resources tab (2026-09-24)
- closes the gaps the full workbook walkthrough exposed (rows the
import deliberately skips because the model had no place for them)
- solutions: MM multi select to tx_vitec_domain_model_solution
(tx_vitec_product_solution_mm, relatedprodukt pattern), emitted by
both product renderers as solution cards in the shared shape
- Resources tab: inline child table tx_vitec_product_resource - per
entry a type (documentation/support), an editable card title/text,
an icon (free text with a value-picker seed set) and one link
(page/url/file/record via the link wizard). Deliberately no Extbase
model: only the UserFunc path reads the rows.
- datasheets: own MM selection of download records below the resource
list (tx_vitec_product_datasheet_mm); emitted into the SAME
resources envelope as ready-made type=datasheet cards (title/teaser/
icon from the record, url = the public /download/<slug> route, uid
fallback - private/hidden downloads 404 as everywhere)
- JSON `resources` (detail payload only, like heroimage): list envelope
{layout, items[]}; a hand-written card whose link is empty or
unresolvable is not emitted; layout switches to carousel above three
cards in total
- solutions hybrid (same day, after analysing all 100 workbooks: their
Solution Cards reference the 8 GROUPS in 98% of cases): new is_group
flag on the solution model, new idempotent CLI
vitec:create-solution-groups (8 group records; title source is
AlignSolutionsCommand::TARGET - now public -, category = the group's
own category, detail_page = the /solutions group page with -N
disambiguation). Group records are invisible to the solution list
and to vitec:align-solutions and only selectable in the product
relation; product solution cards carry isGroup
- spec raised to v1.18 (7.15 member table, 7.13.3 note, revision row;
the footer had been stuck at v1.15 since 1.16/1.17)
Requires on the server:
vendor/bin/typo3 database:updateschema "*.add,*.change"
vendor/bin/typo3 cache:flush
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
303 lines
13 KiB
PHP
303 lines
13 KiB
PHP
<?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). */
|
|
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('<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 = [];
|
|
// 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('<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;
|
|
}
|
|
}
|