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('category "%s" not found - skipped', $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('slug "%s" already taken - "%s" skipped, resolve manually', $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 . ''); } 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 . ''); } return Command::FAILURE; } } $output->writeln('Done. Flush the frontend cache to publish: vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } /** @return array 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> */ 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 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); } }