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:
1 parent
7833e1b668
commit
5499ca54af
12 files changed
+1186
-121
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user