Files
VITEC-website/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
T
orascheandClaude Fable 5 6961254e57 Workbook import phase 1: manifest-driven batch command
Implements the 18.09. strategy's phase 1 on top of the existing single
importer (which stays the one write path):

- new vitec:import-product-workbooks: runs vitec:import-product-workbook
  per manifest entry (in-process sub-command), aggregates a per-file
  report (writes/unchanged/na, URL-mismatch and error flags, details on
  -v or failure) plus a summary; without --apply every run is a dry
  run; --only filters
- migrations/products_xlsx/ now holds working copies of all 100 agency
  workbooks in their category folders (git-ignored) plus the TRACKED
  manifest.json: workbook path -> product slug, family entries mark the
  K-level landing pages (never imported), product null = unresolved.
  Generated from the 18.09. analysis + sitemap column K:
  68 product / 17 family / 15 open
- --init-manifest resolves open entries against the full product table
  (hidden records included): URL segment/filename vs slug, normalized
  title, unique slug prefix; only unambiguous hits are written back
- single command compare is now tolerant of the RTE storage transform
  (whitespace collapse + <p>-wrap simulation), fixing the known
  every-run-WRITE false positive on re-imports

Post-dry-run hardening, applied the same day (69 products, 785 field
writes across two runs, zero errors, verified against the live JSON):

- PROTECTED_FIELDS title/urltitle in the single command: the stored
  module mapping would have overwritten six canonical sitemap-V2
  product titles
- the stored mapping predated the 18.09. defaults and silently dropped
  `highlights` on every product; the single command now merges
  DEFAULT_MAPPING under the stored mapping (defaults as base, stored
  as per-field override)
- known limitation for phase 2: the g: composed fields report WRITE on
  every run (RTE transform alters ul/strong markup beyond the p-wrap
  normalisation) - the change watch must compare against stored
  imported values, not the database

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 15:50:16 +02:00

208 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
use Evomedien\Vitec\Import\MappingRepository;
use Evomedien\Vitec\Import\ProductXlsxReader;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
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;
/**
* Non-interactive counterpart of the backend module's product-text import:
* apply one agency "Product Page Content" workbook to one product record
* from the CLI. Same reader, same mapping (the saved one from
* tx_vitec_import_mapping, falling back to the module's DEFAULT_MAPPING),
* same DataHandler write path.
*
* Writes only mapped fields whose composed value differs from the record;
* unmapped fields and the related-product cards are untouched (review those
* in the module, which also shows this run's workbook - the parse is stored
* in tx_vitec_product_workbook like an upload).
*
* vendor/bin/typo3 vitec:import-product-workbook \
* "migrations/products_xlsx/Arqa_Product Page Content.xlsx" arqa --dry-run
*/
#[AsCommand(
name: 'vitec:import-product-workbook',
description: 'Apply a product-content workbook (XLSX) to a product record, using the module mapping'
)]
final class ImportProductWorkbookCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_product';
/**
* Never written by the workbook import, whatever the stored mapping
* says: product titles are canonical per sitemap V2 (decision
* 2026-09-04) - the 2026-09-24 batch dry run showed a stored module
* mapping would have renamed six products. urltitle would only receive
* a duplicate of the hero one-liner.
*/
private const PROTECTED_FIELDS = ['title', 'urltitle'];
public function __construct(
private readonly ProductXlsxReader $reader,
private readonly MappingRepository $mappingRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'Path to the workbook (XLSX)');
$this->addArgument('product', InputArgument::REQUIRED, 'Slug or uid of the target product');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run');
$file = (string)$input->getArgument('file');
if (!is_file($file)) {
$output->writeln('<error>File not found: ' . $file . '</error>');
return Command::FAILURE;
}
$product = $this->loadProduct((string)$input->getArgument('product'));
if ($product === null) {
$output->writeln('<error>No product record for "' . $input->getArgument('product') . '".</error>');
return Command::FAILURE;
}
$uid = (int)$product['uid'];
$output->writeln(sprintf('Target: uid %d "%s" (/%s)%s', $uid, $product['title'], $product['slug'], $dryRun ? ' - DRY RUN' : ''));
$parsed = $this->reader->parse($file);
$pageType = (string)($parsed['pageType'] ?? 'unknown');
if ($pageType !== 'product') {
$output->writeln('<error>Not a product workbook (pageType "' . $pageType . '") - nothing imported.</error>');
return Command::FAILURE;
}
// Workbook URL vs record slug - warn only, same as the module.
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
$slug = trim((string)$product['slug'], '/');
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
}
// Defaults as the base, the stored module mapping as per-field
// override - a field the module never saved (its dropdown was empty
// on an old-format workbook, e.g. `highlights`) still imports with
// its default selector. Found in the 2026-09-24 batch run: the
// stored mapping predated the highlights default and silently
// dropped the field on all 69 products.
$mapping = array_merge(
ProductTextImportController::DEFAULT_MAPPING,
array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? [])
);
$data = [];
foreach ($mapping as $field => $selector) {
if (in_array($field, self::PROTECTED_FIELDS, true)) {
$output->writeln(sprintf('%-20s %-34s -> protected (canonical, never written)', $field, $selector));
continue;
}
$value = $this->reader->valueFor($parsed, $selector);
if ($value === null) {
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
continue;
}
$old = (string)($product[$field] ?? '');
if ($this->isUnchanged($old, $value)) {
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
continue;
}
$data[$field] = $value;
$output->writeln(sprintf('%-20s %-34s -> WRITE (%d chars%s)', $field, $selector, mb_strlen($value), $old === '' ? ', was empty' : ''));
}
if ($data === []) {
$output->writeln('All mapped fields are up to date - nothing to write.');
return Command::SUCCESS;
}
if ($dryRun) {
$output->writeln(sprintf('DRY RUN - %d field(s) would be written.', count($data)));
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
$this->storeWorkbook($uid, $parsed, basename($file));
$output->writeln(sprintf('%d field(s) written (%s). Workbook stored for the module. Flush the cache: vendor/bin/typo3 cache:flush', count($data), implode(', ', array_keys($data))));
return Command::SUCCESS;
}
/**
* Compare tolerant of the RTE storage transform, which wraps bare
* content in <p>...</p> and strips newlines on save - without this every
* re-run reported already-imported richtext fields as WRITE (the known
* false-positive from the module's preview).
*/
private function isUnchanged(string $old, string $new): bool
{
$normalize = static fn(string $s): string => trim((string)preg_replace('/\s+/u', ' ', $s));
if ($normalize($old) === $normalize($new)) {
return true;
}
return $normalize($old) === $normalize('<p>' . $new . '</p>');
}
/** @return array<string,mixed>|null */
private function loadProduct(string $slugOrUid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$qb->select('*')->from(self::TABLE)->where($qb->expr()->eq('deleted', 0));
if (ctype_digit($slugOrUid)) {
$qb->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter((int)$slugOrUid, ParameterType::INTEGER)));
} else {
$qb->andWhere($qb->expr()->eq('slug', $qb->createNamedParameter($slugOrUid, ParameterType::STRING)));
}
$row = $qb->executeQuery()->fetchAssociative();
return $row ?: null;
}
/**
* Same storage the module upload uses, so "Edit Product" reopens on this
* workbook (related-card review without re-upload).
*
* @param array<string,mixed> $parsed
*/
private function storeWorkbook(int $productUid, array $parsed, string $filename): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook');
$values = [
'filename' => mb_substr($filename, 0, 255),
'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE),
'be_user' => 0,
'tstamp' => time(),
];
if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) {
$connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]);
} else {
$connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]);
}
}
}