- 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
154 lines
6.4 KiB
PHP
154 lines
6.4 KiB
PHP
<?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;
|
|
}
|
|
}
|