Files
VITEC-website/packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php
Oliver Rasche 5499ca54af 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
2026-09-04 12:59:17 +02:00

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;
}
}