- 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
389 lines
17 KiB
PHP
389 lines
17 KiB
PHP
<?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);
|
|
}
|
|
}
|