Product texts: mapping completed; product model: solutions + resources
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>
This commit is contained in:
1 parent
3df66b1353
commit
6811463343
12 files changed
+325
-12
No files matched your search
@@ -49,7 +49,7 @@ 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 = [
|
||||
public const TARGET = [
|
||||
'Control Room & Command Centre Solutions' => [
|
||||
'Control Room Platforms (SOC / NOC / Command Centres)',
|
||||
'Command & Control Visualisation',
|
||||
@@ -170,8 +170,10 @@ final class AlignSolutionsCommand extends Command
|
||||
}
|
||||
|
||||
$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'
|
||||
'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']];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Hybrid decision 2026-09-24: the eight solution GROUPS become selectable
|
||||
* records (is_group = 1) so the product `solutions` relation can point at a
|
||||
* group or at an individual Generic Solution - the agency workbooks
|
||||
* reference the group level in 98% of their Solution Cards.
|
||||
*
|
||||
* Per group (source of truth: AlignSolutionsCommand::TARGET keys):
|
||||
* - solution record, is_group = 1, slug from the title (collision-safe)
|
||||
* - categories := the group's own sys_category (below root "Solution")
|
||||
* - detail_page := the /solutions group page, matched by normalized title
|
||||
* (rule of vitec:map-solution-pages; `-N` duplicate slugs lose,
|
||||
* ambiguity is reported, never guessed)
|
||||
*
|
||||
* Group records are invisible to solution lists, to vitec:align-solutions
|
||||
* and to Solr - they exist for the product relation. Idempotent: existing
|
||||
* group records are left alone.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:create-solution-groups --dry-run
|
||||
* vendor/bin/typo3 vitec:create-solution-groups
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:create-solution-groups',
|
||||
description: 'Create the 8 solution-group records (is_group=1) for the product solutions relation'
|
||||
)]
|
||||
final class CreateSolutionGroupsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_solution';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
|
||||
$groups = array_keys(AlignSolutionsCommand::TARGET);
|
||||
|
||||
// Existing records: titles for idempotency, slugs for collision checks.
|
||||
$existingByTitle = [];
|
||||
$usedSlugs = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug, is_group FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
) as $row) {
|
||||
$existingByTitle[$this->normalize((string)$row['title'])] = $row;
|
||||
$usedSlugs[(string)$row['slug']] = true;
|
||||
}
|
||||
|
||||
$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);
|
||||
if ($storagePid <= 0) {
|
||||
$output->writeln('<error>No storage pid detectable (no solution records?)</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Group categories below the root category "Solution".
|
||||
$rootCat = (int)($connection->fetchOne(
|
||||
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Solution'"
|
||||
) ?: 0);
|
||||
$catByTitle = [];
|
||||
if ($rootCat > 0) {
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND parent = ?',
|
||||
[$rootCat]
|
||||
) as $row) {
|
||||
$catByTitle[$this->normalize((string)$row['title'])] = (int)$row['uid'];
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate pages: standard pages, hidden allowed (map-solution-pages rule).
|
||||
$pagesByTitle = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND doktype = 1'
|
||||
) as $row) {
|
||||
$pagesByTitle[$this->normalize((string)$row['title'])][] = $row;
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
$newIndex = 0;
|
||||
$created = 0;
|
||||
foreach ($groups as $group) {
|
||||
$key = $this->normalize($group);
|
||||
$existing = $existingByTitle[$key] ?? null;
|
||||
if ($existing !== null) {
|
||||
$state = ((int)$existing['is_group'] === 1) ? 'ok (is_group=1)' : '<error>KONFLIKT: Record gleichen Titels ohne is_group</error>';
|
||||
$output->writeln(sprintf(' %-45s existiert, uid %d - %s', $group, (int)$existing['uid'], $state));
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageUid = 0;
|
||||
$candidates = $pagesByTitle[$key] ?? [];
|
||||
if (count($candidates) > 1) {
|
||||
// `-N` duplicate slugs lose the disambiguation.
|
||||
$candidates = array_values(array_filter(
|
||||
$candidates,
|
||||
static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug'])
|
||||
)) ?: $candidates;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
$pageUid = (int)$candidates[0]['uid'];
|
||||
} elseif (count($candidates) > 1) {
|
||||
$output->writeln(sprintf(' <comment>%s: Seite mehrdeutig (%d Kandidaten) - detail_page bleibt leer</comment>', $group, count($candidates)));
|
||||
} else {
|
||||
$output->writeln(sprintf(' <comment>%s: keine Gruppen-Seite gefunden - detail_page bleibt leer</comment>', $group));
|
||||
}
|
||||
|
||||
$slug = $this->slugFor($group, $usedSlugs);
|
||||
$fields = [
|
||||
'pid' => $storagePid,
|
||||
'title' => $group,
|
||||
'slug' => $slug,
|
||||
'is_group' => 1,
|
||||
'detail_page' => $pageUid,
|
||||
];
|
||||
$catUid = $catByTitle[$key] ?? 0;
|
||||
if ($catUid > 0) {
|
||||
$fields['categories'] = (string)$catUid;
|
||||
} else {
|
||||
$output->writeln(sprintf(' <comment>%s: keine Gruppen-Kategorie gefunden</comment>', $group));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
' %-45s %s slug=%s page=%s cat=%s',
|
||||
$group,
|
||||
$dryRun ? 'wuerde angelegt' : 'anlegen',
|
||||
$slug,
|
||||
$pageUid ?: '-',
|
||||
$catUid ?: '-'
|
||||
));
|
||||
$datamap[self::TABLE]['NEWGROUP' . (++$newIndex)] = $fields;
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('<info>DRY-RUN: %d Gruppen-Records wuerden angelegt.</info>', $created));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($datamap !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln("<error>$error</error>");
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('<info>%d Gruppen-Records angelegt.</info>', $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $title): string
|
||||
{
|
||||
$title = str_replace('&', 'and', mb_strtolower($title));
|
||||
$title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title);
|
||||
return trim((string)preg_replace('/\s+/', ' ', $title));
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $usedSlugs */
|
||||
private function slugFor(string $title, array &$usedSlugs): string
|
||||
{
|
||||
$slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($title))), '-');
|
||||
$candidate = $slug;
|
||||
$i = 1;
|
||||
while (isset($usedSlugs[$candidate])) {
|
||||
$candidate = $slug . '-group' . ($i > 1 ? '-' . $i : '');
|
||||
$i++;
|
||||
}
|
||||
$usedSlugs[$candidate] = true;
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user