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:
orasche committed 2026-08-21 11:00:45 +02:00
1 parent 12eb40614b
commit 7bd1161abf
28 files changed
+2594 -39

No files matched your search

@@ -205,11 +205,15 @@ class ProductListJsonRenderer
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'description2' => RteResolver::html($product['description2'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
@@ -164,6 +164,19 @@ class ProductShowJsonRenderer
: '';
}
// Page <title> for the detail page: the product's seotitle, falling
// back to its title. Fed from here because in headless mode the
// Extbase showAction (which used to do this) never runs; headless's
// MetaHandler reads the provider chain when composing `seo`.
$pageTitle = trim((string)($product['seotitle'] ?? ''));
if ($pageTitle === '') {
$pageTitle = trim((string)($product['title'] ?? ''));
}
if ($pageTitle !== '') {
GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\ProductPageTitleProvider::class)
->setSeoTitle($pageTitle);
}
$response = [
'product' => $this->serializeProduct($product),
'layout' => $layout,
@@ -210,11 +223,15 @@ class ProductShowJsonRenderer
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'description2' => RteResolver::html($product['description2'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
@@ -230,6 +247,7 @@ class ProductShowJsonRenderer
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'heroimage' => $this->getProductImages($uid, 'heroimage'),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'videofile' => $this->getProductVideoFile($uid),
@@ -310,7 +328,7 @@ class ProductShowJsonRenderer
return '';
}
protected function getProductImages(int $productUid): array
protected function getProductImages(int $productUid, string $fieldName = 'productimage'): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -321,7 +339,7 @@ class ProductShowJsonRenderer
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
@@ -805,6 +823,7 @@ class ProductShowJsonRenderer
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->addSelectLiteral('t.cardtext')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
@@ -812,6 +831,16 @@ class ProductShowJsonRenderer
'mm',
'mm.uid_foreign = p.uid'
)
// Per-pair card copy from the workbook import. A separate table
// rather than an MM column, because DataHandler rewrites the MM
// rows on every product save - see tx_vitec_product_related_text
// in ext_tables.sql.
->leftJoin(
'mm',
'tx_vitec_product_related_text',
't',
't.product_uid = mm.uid_local AND t.related_uid = p.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
@@ -831,6 +860,7 @@ class ProductShowJsonRenderer
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => RteResolver::html($rel['description'] ?? ''),
'cardtext' => (string)($rel['cardtext'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];