Align product records with sitemap V2; product family model; CLI workbook import

- vitec:migrate-product-structure: exact V2 titles, Milestone typo fix (incl.
  slug), QTX100 stray VSN category removed, Aligo record moved to its own
  category with slug /product/aligo, new Aligo Workstation record takes over
  /product/aligo-workstation
- product families (sitemap column K) are ordinary content pages now:
  vitec:remove-family-records deletes the interim landing records again,
  reset_subproduct_flags.php clears the misused subproduct flag (the list
  renderer has always excluded subproduct=1)
- vitec:import-product-workbook: non-interactive counterpart of the module
  import (same reader, mapping and DataHandler path); Arqa imported, Aligo
  re-applied with the corrected capabilities markup
- ProductXlsxReader: new :copy cell selector (Body Copy with CTA fallback)
  absorbs the agency's column drift; default teaser mapping uses it
- ProductListJsonRenderer: selected categories now match their whole subtree,
  results ordered by category tree position, sheet order within a category
- read-only diagnostics: check_product_structure.php, check_productlist_ces.php
This commit is contained in:
2026-09-04 12:59:17 +02:00
parent 7833e1b668
commit 5499ca54af
12 changed files with 1186 additions and 121 deletions

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
use Evomedien\Vitec\Import\MappingRepository;
use Evomedien\Vitec\Import\ProductXlsxReader;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
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;
/**
* Non-interactive counterpart of the backend module's product-text import:
* apply one agency "Product Page Content" workbook to one product record
* from the CLI. Same reader, same mapping (the saved one from
* tx_vitec_import_mapping, falling back to the module's DEFAULT_MAPPING),
* same DataHandler write path.
*
* Writes only mapped fields whose composed value differs from the record;
* unmapped fields and the related-product cards are untouched (review those
* in the module, which also shows this run's workbook - the parse is stored
* in tx_vitec_product_workbook like an upload).
*
* vendor/bin/typo3 vitec:import-product-workbook \
* "migrations/products_xlsx/Arqa_Product Page Content.xlsx" arqa --dry-run
*/
#[AsCommand(
name: 'vitec:import-product-workbook',
description: 'Apply a product-content workbook (XLSX) to a product record, using the module mapping'
)]
final class ImportProductWorkbookCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_product';
public function __construct(
private readonly ProductXlsxReader $reader,
private readonly MappingRepository $mappingRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'Path to the workbook (XLSX)');
$this->addArgument('product', InputArgument::REQUIRED, 'Slug or uid of the target product');
$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');
$file = (string)$input->getArgument('file');
if (!is_file($file)) {
$output->writeln('<error>File not found: ' . $file . '</error>');
return Command::FAILURE;
}
$product = $this->loadProduct((string)$input->getArgument('product'));
if ($product === null) {
$output->writeln('<error>No product record for "' . $input->getArgument('product') . '".</error>');
return Command::FAILURE;
}
$uid = (int)$product['uid'];
$output->writeln(sprintf('Target: uid %d "%s" (/%s)%s', $uid, $product['title'], $product['slug'], $dryRun ? ' - DRY RUN' : ''));
$parsed = $this->reader->parse($file);
$pageType = (string)($parsed['pageType'] ?? 'unknown');
if ($pageType !== 'product') {
$output->writeln('<error>Not a product workbook (pageType "' . $pageType . '") - nothing imported.</error>');
return Command::FAILURE;
}
// Workbook URL vs record slug - warn only, same as the module.
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
$slug = trim((string)$product['slug'], '/');
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
}
$mapping = array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []);
if ($mapping === []) {
$mapping = ProductTextImportController::DEFAULT_MAPPING;
}
$data = [];
foreach ($mapping as $field => $selector) {
$value = $this->reader->valueFor($parsed, $selector);
if ($value === null) {
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
continue;
}
$old = (string)($product[$field] ?? '');
if (trim($old) === trim($value)) {
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
continue;
}
$data[$field] = $value;
$output->writeln(sprintf('%-20s %-34s -> WRITE (%d chars%s)', $field, $selector, mb_strlen($value), $old === '' ? ', was empty' : ''));
}
if ($data === []) {
$output->writeln('All mapped fields are up to date - nothing to write.');
return Command::SUCCESS;
}
if ($dryRun) {
$output->writeln(sprintf('DRY RUN - %d field(s) would be written.', count($data)));
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
$this->storeWorkbook($uid, $parsed, basename($file));
$output->writeln(sprintf('%d field(s) written (%s). Workbook stored for the module. Flush the cache: vendor/bin/typo3 cache:flush', count($data), implode(', ', array_keys($data))));
return Command::SUCCESS;
}
/** @return array<string,mixed>|null */
private function loadProduct(string $slugOrUid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$qb->select('*')->from(self::TABLE)->where($qb->expr()->eq('deleted', 0));
if (ctype_digit($slugOrUid)) {
$qb->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter((int)$slugOrUid, ParameterType::INTEGER)));
} else {
$qb->andWhere($qb->expr()->eq('slug', $qb->createNamedParameter($slugOrUid, ParameterType::STRING)));
}
$row = $qb->executeQuery()->fetchAssociative();
return $row ?: null;
}
/**
* Same storage the module upload uses, so "Edit Product" reopens on this
* workbook (related-card review without re-upload).
*
* @param array<string,mixed> $parsed
*/
private function storeWorkbook(int $productUid, array $parsed, string $filename): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook');
$values = [
'filename' => mb_substr($filename, 0, 255),
'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE),
'be_user' => 0,
'tstamp' => time(),
];
if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) {
$connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]);
} else {
$connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]);
}
}
}

View File

@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Doctrine\DBAL\ParameterType;
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;
/**
* Aligns the product records with the agency's "Merged site map" V2
* (Products tab, columns J/K/L - the binding structure per decision
* 2026-09-04). The sys_category tree already matches V2; this command fixes
* the records:
*
* Model: K-level entries ("Products") are landing records - one product
* record per level-2 category, same title as the category, subproduct=0,
* its sub-products teasered via `relatedprodukt`. L-level entries
* ("Sub-Products") are classic detail-page products with subproduct=1.
* A record is recognized as K-record by title == title of its category.
*
* Steps (in order, each idempotent):
* 1. uid 36 "Aligo": slug aligo-workstation -> aligo (frees the slug for
* the new Aligo Workstation record), category Operator Workstations ->
* Aligo (decision 3a: uid 36 stays the platform landing, the imported
* workbook content stays put)
* 2. renames to the exact V2 strings (4 records) + Mileston -> Milestone
* (typo, also in V2 itself; incl. slug)
* 3. QTX100: drop the stray VSN category (V2: Aligo only)
* 4. create the L-record "Aligo Workstation" (Operator Workstations)
* 5. create the missing K-records (27 categories without a landing record)
* 6. subproduct flag: 1 for every L-record, 0 for K-records
* 7. relatedprodukt of K-records: when EMPTY, fill with the products of
* the K category (curated lists - e.g. Aligo's imported aliases - are
* never touched)
*
* Status cases (APEX not launched, 39-Series coming soon, MGES EOL) stay
* visible per decision 2026-09-04.
*
* vendor/bin/typo3 vitec:migrate-product-structure --dry-run
* vendor/bin/typo3 vitec:migrate-product-structure
*/
#[AsCommand(
name: 'vitec:migrate-product-structure',
description: 'Align product records with the Merged-site-map V2 structure (K landing records, L subproduct flags)'
)]
final class MigrateProductStructureCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_product';
/** Exact V2 titles for records whose current title deviates. */
private const RENAMES = [
6 => 'Avedia End-Points (EP6, 95-Series)',
11 => 'MGW Diamond-H (HDMI encoder)',
14 => 'MGW Ace decoder (ultra-low latency decoder)',
21 => 'MGW Diamond-Hx OG (HDMI/DVI blade encoder)',
55 => 'Milestone',
];
/**
* K-level entries: category title => slug for the landing record.
* Slugs are explicit because several natural ones are taken by L-records
* (aetria, channellink, activesqx, visionav ...).
*/
private const K_RECORDS = [
'Avedia Platform' => 'avedia-platform',
'EZ TV Platform' => 'ez-tv-platform',
'APEX Platform' => 'apex-platform',
'Appliances' => 'appliances',
'Avedia Modular System' => 'avedia-modular-system',
'VITEC OG Modular System' => 'vitec-og-modular-system',
'MGW Blade System' => 'mgw-blade-system',
'Avedia Modular System (RF gateways)' => 'avedia-modular-system-rf-gateways',
'ChannelLink (IP to IP gateways)' => 'channellink-ip-to-ip-gateways',
'PRISM Transcoder' => 'prism-transcoder',
'Aetria' => 'aetria-platform',
'Operator Workstations' => 'operator-workstations',
'Aligo' => 'aligo',
'Arqa' => 'arqa',
'VSN' => 'vsn',
'Video Wall Management Software' => 'video-wall-management-software',
'X-Series (Multi-display Processors)' => 'x-series-multi-display-processors',
'VMS Plugins & Integrations' => 'vms-plugins-integrations',
'Image Graphics Cards (multi-output GPU cards)' => 'image-graphics-cards',
'IQS4 (4K splitter)' => 'iqs4-4k-splitter',
'VisionSC (scalable capture & processing)' => 'visionsc-scalable-capture-processing',
'VisionIO (real-time capture + overlay)' => 'visionio-real-time-capture-overlay',
'VisionAV (video + audio capture)' => 'visionav-video-audio-capture',
'Vision (DVI & SDI capture cards)' => 'vision-dvi-sdi-capture-cards',
'VisionLC (low-profile capture cards)' => 'visionlc-low-profile-capture-cards',
'ActiveSQX (IP encode/decode)' => 'activesqx-ip-encode-decode',
'Express Backplanes' => 'express-backplanes',
'Accessories & Cables' => 'accessories-cables',
];
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new records (default: pid of the existing products)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run');
$categories = $this->productCategories(); // title => uid (level-2 under "Product")
$products = $this->products(); // uid => row
$productCats = $this->productCategoryMap(); // product uid => [category uids]
$pid = (int)($input->getOption('pid') ?? 0);
if ($pid <= 0) {
$pid = $this->detectPid();
}
$output->writeln(sprintf('%d products, %d level-2 categories, storage pid %d%s',
count($products), count($categories), $pid, $dryRun ? ' - DRY RUN' : ''));
$datamap = [];
$newIndex = 0;
// ---- 1. Aligo (36): slug + category move ---------------------------
$aligo = $products[36] ?? null;
if ($aligo !== null) {
if ($aligo['slug'] === 'aligo-workstation') {
$datamap[36]['slug'] = 'aligo';
$output->writeln('uid 36 Aligo: slug aligo-workstation -> aligo');
}
$cats = $productCats[36] ?? [];
if (in_array((int)($categories['Operator Workstations'] ?? -1), $cats, true)) {
$newCats = array_diff($cats, [(int)$categories['Operator Workstations']]);
$newCats[] = (int)$categories['Aligo'];
$datamap[36]['categories'] = implode(',', array_unique($newCats));
$output->writeln('uid 36 Aligo: category Operator Workstations -> Aligo');
}
}
// ---- 2. renames ----------------------------------------------------
foreach (self::RENAMES as $uid => $title) {
if (isset($products[$uid]) && $products[$uid]['title'] !== $title) {
$datamap[$uid]['title'] = $title;
$output->writeln(sprintf('uid %d: "%s" -> "%s"', $uid, $products[$uid]['title'], $title));
}
}
if (isset($products[55]) && $products[55]['slug'] === 'mileston') {
$datamap[55]['slug'] = 'milestone';
$output->writeln('uid 55: slug mileston -> milestone');
}
// ---- 3. QTX100: drop VSN -------------------------------------------
$vsnCat = (int)($categories['VSN'] ?? -1);
if (isset($productCats[37]) && in_array($vsnCat, $productCats[37], true)) {
$datamap[37]['categories'] = implode(',', array_diff($productCats[37], [$vsnCat]));
$output->writeln('uid 37 QTX100: category VSN removed (V2: Aligo only)');
}
// ---- 4. + 5. creates ------------------------------------------------
// Existing K-record = a product whose title equals the title of one of
// its categories.
$existsAsK = function (string $catTitle) use ($products, $productCats, $categories): bool {
$catUid = (int)($categories[$catTitle] ?? -1);
foreach ($products as $uid => $p) {
if ($p['title'] === $catTitle && in_array($catUid, $productCats[$uid] ?? [], true)) {
return true;
}
}
return false;
};
$slugs = array_column($products, 'slug');
$hasAligoWorkstation = false;
foreach ($products as $p) {
if ($p['title'] === 'Aligo Workstation') {
$hasAligoWorkstation = true;
}
}
if (!$hasAligoWorkstation) {
$newId = 'NEW' . ++$newIndex;
$datamap[$newId] = [
'pid' => $pid,
'title' => 'Aligo Workstation',
'slug' => 'aligo-workstation',
'categories' => (string)($categories['Operator Workstations'] ?? ''),
'subproduct' => 1,
];
$output->writeln('create L-record: Aligo Workstation (Operator Workstations)');
}
foreach (self::K_RECORDS as $catTitle => $slug) {
if (!isset($categories[$catTitle])) {
$output->writeln(sprintf('<error>category "%s" not found - skipped</error>', $catTitle));
continue;
}
// uid 36 IS the Aligo K-record; its move into the Aligo category
// happens in this very datamap, so $existsAsK cannot see it yet.
if ($catTitle === 'Aligo' && isset($products[36])) {
continue;
}
if ($existsAsK($catTitle)) {
continue;
}
if (in_array($slug, $slugs, true) && !($slug === 'aligo' && isset($datamap[36]['slug']))) {
$output->writeln(sprintf('<error>slug "%s" already taken - "%s" skipped, resolve manually</error>', $slug, $catTitle));
continue;
}
$newId = 'NEW' . ++$newIndex;
$datamap[$newId] = [
'pid' => $pid,
'title' => $catTitle,
'slug' => $slug,
'categories' => (string)$categories[$catTitle],
'subproduct' => 0,
];
$output->writeln(sprintf('create K-record: %s (slug %s)', $catTitle, $slug));
}
// ---- 6. subproduct flags on existing records -----------------------
$catTitleByUid = array_flip($categories);
$flagged = 0;
foreach ($products as $uid => $p) {
$isK = false;
foreach ($productCats[$uid] ?? [] as $catUid) {
if (($catTitleByUid[$catUid] ?? null) === $p['title']) {
$isK = true;
}
}
// uid 36 becomes K through this run's category move
if ($uid === 36 && isset($datamap[36]['categories'])) {
$isK = true;
}
$target = $isK ? 0 : 1;
if ((int)$p['subproduct'] !== $target) {
$datamap[$uid]['subproduct'] = $target;
$flagged++;
}
}
$output->writeln(sprintf('subproduct flags to update: %d records', $flagged));
if ($datamap === []) {
$output->writeln('Nothing to do - structure already matches V2.');
return Command::SUCCESS;
}
if ($dryRun) {
$output->writeln(sprintf('DRY RUN - %d datamap entries, nothing written.', count($datamap)));
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => $datamap], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
$output->writeln(sprintf('%d records written/created.', count($datamap)));
// ---- 7. relatedprodukt on K-records (only when empty) --------------
// Re-read: creates need their real uids, categories their fresh state.
$products = $this->products();
$productCats = $this->productCategoryMap();
$related = [];
foreach ($products as $uid => $p) {
$catUid = (int)($categories[$p['title']] ?? -1);
if ($catUid < 0 || !in_array($catUid, $productCats[$uid] ?? [], true)) {
continue; // not a K-record
}
if ($this->relatedCount($uid) > 0) {
continue; // curated - never touch
}
$subs = [];
foreach ($productCats as $otherUid => $cats) {
if ($otherUid !== $uid && in_array($catUid, $cats, true)) {
$subs[] = $otherUid;
}
}
sort($subs);
if ($subs !== []) {
$related[$uid] = ['relatedprodukt' => implode(',', $subs)];
$output->writeln(sprintf('K-record %d %s: relatedprodukt = %s', $uid, $p['title'], implode(',', $subs)));
}
}
if ($related !== []) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => $related], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
}
$output->writeln('Done. Flush the frontend cache to publish: vendor/bin/typo3 cache:flush');
return Command::SUCCESS;
}
/** @return array<string,int> level-2 category title => uid (parents under root "Product") */
private function productCategories(): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
$rootUid = (int)$qb->select('uid')->from('sys_category')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('parent', 0),
$qb->expr()->eq('title', $qb->createNamedParameter('Product', ParameterType::STRING))
)->executeQuery()->fetchOne();
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
$level1 = $qb->select('uid')->from('sys_category')
->where($qb->expr()->eq('deleted', 0), $qb->expr()->eq('parent', $rootUid))
->executeQuery()->fetchFirstColumn();
if ($level1 === []) {
return [];
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
$rows = $qb->select('uid', 'title')->from('sys_category')
->where($qb->expr()->eq('deleted', 0), $qb->expr()->in('parent', array_map('intval', $level1)))
->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(string)$row['title']] = (int)$row['uid'];
}
return $map;
}
/** @return array<int,array<string,mixed>> */
private function products(): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb->select('uid', 'pid', 'title', 'slug', 'subproduct')->from(self::TABLE)
->where($qb->expr()->eq('deleted', 0))
->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid']] = $row;
}
return $map;
}
/** @return array<int,int[]> product uid => category uids */
private function productCategoryMap(): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category_record_mm');
$rows = $qb->select('uid_local', 'uid_foreign')->from('sys_category_record_mm')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter('categories', ParameterType::STRING))
)->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
}
return $map;
}
private function relatedCount(int $productUid): int
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm');
return (int)$qb->count('*')->from('tx_vitec_product_related_mm')
->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
->executeQuery()->fetchOne();
}
private function detectPid(): int
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$pids = $qb->select('pid')->from(self::TABLE)
->where($qb->expr()->eq('deleted', 0))
->executeQuery()->fetchFirstColumn();
if ($pids === []) {
return 0;
}
$counts = array_count_values(array_map('intval', $pids));
arsort($counts);
return (int)array_key_first($counts);
}
}

View File

@@ -0,0 +1,153 @@
<?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;
/**
* Removes the K-level "family landing" product records again.
*
* Background (2026-09-04): the V2 structure migration created one landing
* record per level-2 product category. The model decision changed the same
* day - product families are ordinary, manually maintained CONTENT PAGES
* with cards, not records - so those empty landing records would only
* pollute lists and the search index.
*
* A record qualifies for deletion ONLY when ALL of these hold:
* - its title equals the title of one of its level-2 categories
* (the K-record signature),
* - subproduct = 0,
* - teaser, description and capabilities are all empty
* (a content-bearing record is never deleted - it is reported instead),
* - it is not Aligo (36) or Arqa (93): they carry imported workbook
* content and stay until their family pages are built.
*
* Deletion goes through DataHandler (soft delete, history, relation
* handling). Idempotent: a second run finds nothing.
*
* vendor/bin/typo3 vitec:remove-family-records --dry-run
* vendor/bin/typo3 vitec:remove-family-records
*/
#[AsCommand(
name: 'vitec:remove-family-records',
description: 'Delete the empty K-level family landing records (families are content pages now)'
)]
final class RemoveFamilyRecordsCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_product';
/** Carry imported workbook content - kept until their family pages exist. */
private const KEEP = [36, 93];
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the candidates - delete nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run');
// Level-2 category titles under root "Product" (parent chain root->J->K).
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
$rootUid = (int)$connection->fetchOne(
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Product'"
);
$level1 = $connection->fetchFirstColumn(
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ?',
[$rootUid]
);
if ($level1 === []) {
$output->writeln('<error>No level-1 product categories found.</error>');
return Command::FAILURE;
}
$placeholders = implode(',', array_fill(0, count($level1), '?'));
$catTitleByUid = [];
foreach ($connection->fetchAllAssociative(
"SELECT uid, title FROM sys_category WHERE deleted = 0 AND parent IN ($placeholders)",
array_map('intval', $level1)
) as $row) {
$catTitleByUid[(int)$row['uid']] = (string)$row['title'];
}
// Products with their category assignments and the content probe.
$productConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$products = $productConnection->fetchAllAssociative(
'SELECT uid, title, slug, subproduct, teaser, description, capabilities FROM ' . self::TABLE . ' WHERE deleted = 0'
);
$mm = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category_record_mm')
->fetchAllAssociative(
"SELECT uid_local, uid_foreign FROM sys_category_record_mm
WHERE tablenames = ? AND fieldname = ?",
[self::TABLE, 'categories']
);
$catsByProduct = [];
foreach ($mm as $row) {
$catsByProduct[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
}
$delete = [];
foreach ($products as $product) {
$uid = (int)$product['uid'];
$isK = false;
foreach ($catsByProduct[$uid] ?? [] as $catUid) {
if (($catTitleByUid[$catUid] ?? null) === (string)$product['title']) {
$isK = true;
}
}
if (!$isK || (int)$product['subproduct'] === 1) {
continue;
}
if (in_array($uid, self::KEEP, true)) {
$output->writeln(sprintf('keep %d %s - carries imported workbook content (delete manually once its family page exists)', $uid, $product['title']));
continue;
}
$hasContent = trim((string)$product['teaser']) !== ''
|| trim((string)$product['description']) !== ''
|| trim((string)$product['capabilities']) !== '';
if ($hasContent) {
$output->writeln(sprintf('<comment>skip %d %s - has content, review manually</comment>', $uid, $product['title']));
continue;
}
$delete[] = $uid;
$output->writeln(sprintf('delete %d %s (/%s)', $uid, $product['title'], $product['slug']));
}
if ($delete === []) {
$output->writeln('No empty family landing records found - nothing to delete.');
return Command::SUCCESS;
}
if ($dryRun) {
$output->writeln(sprintf('DRY RUN - %d record(s) would be deleted.', count($delete)));
return Command::SUCCESS;
}
$cmdmap = [];
foreach ($delete as $uid) {
$cmdmap[self::TABLE][$uid]['delete'] = 1;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], $cmdmap);
$dataHandler->process_cmdmap();
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
$output->writeln(sprintf('%d record(s) deleted. Flush the cache: vendor/bin/typo3 cache:flush', count($delete)));
return Command::SUCCESS;
}
}

View File

@@ -87,9 +87,11 @@ final class ProductTextImportController
* `subtitle` and onto columns that exist only in the DB but not in the
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
*/
private const DEFAULT_MAPPING = [
public const DEFAULT_MAPPING = [
'seotitle' => 'x:seotitle',
'teaser' => 'c:Hero — H1#1:body',
// :copy = body with cta fallback - Aligo carries the hero one-liner
// in "Body Copy", Arqa in "CTA / Card Copy" (column drift).
'teaser' => 'c:Hero — H1#1:copy',
'description' => 'c:Product Introduction#1:body',
'description2' => 'c:Product Overview — H2#1:body',
'capabilities' => 'g:Key Capability Group',

View File

@@ -170,6 +170,22 @@ final class ProductXlsxReader
);
}
// Drift-proof copy selector: the agency fills text sometimes into
// "Body Copy" (D), sometimes into "CTA / Card Copy" (E) - Aligo's
// hero one-liner sits in D, Arqa's in E. `copy` reads body and
// falls back to cta, so one stored mapping fits every delivery.
$copy = trim((string)($component['body'] ?? ''));
if ($copy === '') {
$copy = trim((string)($component['cta'] ?? ''));
}
if ($copy !== '') {
$groups['Components'][] = $this->entry(
'c:' . $name . '#' . $component['occurrence'] . ':copy',
$name . $suffix . ' · Copy (Body, sonst CTA)',
$copy
);
}
// Section-intro rows ("... — H2") additionally as ONE ready RTE
// value: heading plus paragraph in the exact markup the finished
// MGW-Diamond product stores in `textrelatedproducts` - imported
@@ -250,10 +266,18 @@ final class ProductXlsxReader
$value = $parsed['seo'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
}
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) {
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta|copy)$/', $selector, $m)) {
foreach ($parsed['components'] ?? [] as $component) {
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
$value = (string)($component[$m[3]] ?? '');
if ($m[3] === 'copy') {
$value = trim((string)($component['body'] ?? ''));
if ($value === '') {
$value = trim((string)($component['cta'] ?? ''));
}
$value = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $value));
} else {
$value = (string)($component[$m[3]] ?? '');
}
return $value !== '' ? $value : null;
}
}

View File

@@ -111,6 +111,12 @@ class ProductListJsonRenderer
$categoryUids = array_filter(
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
);
// A selected category matches its WHOLE subtree (decision
// 2026-09-04): editors pick e.g. the "Platforms and End-Points"
// parent, the products hang on its child categories.
if ($categoryUids !== []) {
$categoryUids = $this->expandWithDescendants($categoryUids);
}
$debugMode = (bool)($settings['debug'] ?? false);
$allProducts = (bool)($settings['allproducts'] ?? false);
@@ -147,6 +153,41 @@ class ProductListJsonRenderer
$products = $productQuery->executeQuery()->fetchAllAssociative();
// Order by category in TREE order (decision 2026-09-04): products
// of the first selected/child category first, then the next, so a
// list over a parent category groups its families like the
// sitemap. $categoryUids comes from expandWithDescendants in
// depth-first tree order; within one category the uid order is
// kept - the records were created in sitemap-V2 row order.
if (!empty($categoryUids) && !$allProducts && $products !== []) {
$rankByCategory = array_flip($categoryUids);
$mmQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_category_record_mm');
$assignments = $mmQueryBuilder->select('uid_local', 'uid_foreign')
->from('sys_category_record_mm')
->where(
$mmQueryBuilder->expr()->eq('tablenames', $mmQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$mmQueryBuilder->expr()->eq('fieldname', $mmQueryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$mmQueryBuilder->expr()->in('uid_foreign', $mmQueryBuilder->createNamedParameter(
array_map(static fn(array $p): int => (int)$p['uid'], $products),
Connection::PARAM_INT_ARRAY
))
)->executeQuery()->fetchAllAssociative();
$rankByProduct = [];
foreach ($assignments as $assignment) {
$productUid = (int)$assignment['uid_foreign'];
$rank = $rankByCategory[(int)$assignment['uid_local']] ?? null;
if ($rank !== null && $rank < ($rankByProduct[$productUid] ?? PHP_INT_MAX)) {
$rankByProduct[$productUid] = $rank;
}
}
usort($products, static function (array $a, array $b) use ($rankByProduct): int {
$rankA = $rankByProduct[(int)$a['uid']] ?? PHP_INT_MAX;
$rankB = $rankByProduct[(int)$b['uid']] ?? PHP_INT_MAX;
return $rankA <=> $rankB ?: (int)$a['uid'] <=> (int)$b['uid'];
});
}
$productsData = [];
foreach ($products as $product) {
$productsData[] = $this->serializeProduct($product);
@@ -181,6 +222,45 @@ class ProductListJsonRenderer
}
}
/**
* The given category uids plus every descendant category uid, DEPTH first
* over sys_category.parent with siblings in sys_category.sorting order -
* the result is in backend-tree order and doubles as the sort rank for
* the list. One query for the whole table - the tree is small (< 100
* rows).
*
* @param int[] $categoryUids
* @return int[]
*/
private function expandWithDescendants(array $categoryUids): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder->select('uid', 'parent')->from('sys_category')
->where($queryBuilder->expr()->eq('deleted', 0))
->orderBy('parent')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
$childrenByParent = [];
foreach ($rows as $row) {
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
}
$result = [];
$visit = function (int $categoryUid) use (&$visit, &$result, $childrenByParent): void {
$result[] = $categoryUid;
foreach ($childrenByParent[$categoryUid] ?? [] as $childUid) {
if (!in_array($childUid, $result, true)) {
$visit($childUid);
}
}
};
foreach (array_map('intval', $categoryUids) as $categoryUid) {
if (!in_array($categoryUid, $result, true)) {
$visit($categoryUid);
}
}
return $result;
}
/**
* Serialize a single product DB row to the full headless JSON structure.
*