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>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-09-24 15:50:16 +02:00
1 parent 6811463343
commit 6961254e57
5 files changed
+661 -9

No files matched your search

@@ -42,6 +42,15 @@ 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,
@@ -89,20 +98,30 @@ final class ImportProductWorkbookCommand extends Command
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
}
$mapping = array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []);
if ($mapping === []) {
$mapping = ProductTextImportController::DEFAULT_MAPPING;
}
// 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 (trim($old) === trim($value)) {
if ($this->isUnchanged($old, $value)) {
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
continue;
}
@@ -134,6 +153,22 @@ final class ImportProductWorkbookCommand extends Command
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
{