Product text import from agency XLSX workbooks

VITEC Import module: Products tab is now a searchable/sortable product
overview. "Edit Product" opens an XLSX upload with per-field source
mapping (component + cell), old/new preview and checkbox apply via
DataHandler; the mapping and manual matches are persisted and preselect
the next workbook. Category workbooks are detected and rejected.

- new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController,
  Products/ProductTexts templates, 4 module routes
- related products: per-pair card text in new side table
  tx_vitec_product_related_text (survives MM rewrites), emitted as
  `cardtext` in the product JSON; missing MM relations added add-only
- card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) -
  the workbooks fill either depending on row type
- product detail page <title> now uses seotitle with title fallback
  (provider made singleton and fed from the JSON renderer)
- per-user recent-search badges; last loaded workbook stored per product
  (tx_vitec_product_workbook), Edit Product reopens on it
- composer: add phpoffice/phpspreadsheet ^5.9
- diagnostics: migrations/check_workbook.php
This commit is contained in:
2026-08-21 11:00:45 +02:00
parent 12eb40614b
commit 7bd1161abf
28 changed files with 2594 additions and 39 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* Diagnose: what does ProductXlsxReader actually extract from a workbook?
*
* Standalone on purpose - the reader has no TYPO3 dependencies, only
* PhpSpreadsheet, so composer's autoloader is all it needs. That makes this the
* exact code path the backend module runs, without cache or session in the way.
*
* Usage: php migrations/check_workbook.php migrations/aligo-test.xlsx
*/
require __DIR__ . '/../vendor/autoload.php';
$file = $argv[1] ?? '';
if ($file === '' || !is_file($file)) {
exit("Usage: php migrations/check_workbook.php <file.xlsx>\n");
}
$reader = new \Evomedien\Vitec\Import\ProductXlsxReader();
$parsed = $reader->parse($file);
echo "pageType : " . $parsed['pageType'] . "\n";
echo "meta : " . json_encode($parsed['meta'], JSON_UNESCAPED_UNICODE) . "\n";
echo "seo keys : " . implode(' | ', array_keys($parsed['seo'])) . "\n";
echo "components: " . count($parsed['components']) . "\n\n";
printf("%-3s %-34s %-3s %-28s %-9s %-12s\n", '#', 'component', 'occ', 'title', 'body-len', 'cta-in-body');
foreach ($parsed['components'] as $c) {
printf(
"%-3d %-34s %-3d %-28s %-9d %s\n",
$c['order'],
mb_substr($c['component'], 0, 34),
$c['occurrence'],
mb_substr($c['title'], 0, 28),
mb_strlen((string)$c['body']),
str_contains((string)$c['body'], '| CTA:') ? 'yes' : '-'
);
}
echo "\n--- Related Product Cards, raw ---\n";
foreach ($parsed['components'] as $c) {
if ($c['component'] !== 'Related Product Card') {
continue;
}
echo "title: [" . $c['title'] . "]\n";
echo "body : [" . $c['body'] . "]\n";
echo "cta : [" . $c['cta'] . "]\n\n";
}