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
This commit is contained in:
2026-09-04 12:59:17 +02:00
parent 7833e1b668
commit 5499ca54af
12 changed files with 1186 additions and 121 deletions

View File

@@ -111,6 +111,12 @@ class ProductListJsonRenderer
$categoryUids = array_filter(
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
);
// A selected category matches its WHOLE subtree (decision
// 2026-09-04): editors pick e.g. the "Platforms and End-Points"
// parent, the products hang on its child categories.
if ($categoryUids !== []) {
$categoryUids = $this->expandWithDescendants($categoryUids);
}
$debugMode = (bool)($settings['debug'] ?? false);
$allProducts = (bool)($settings['allproducts'] ?? false);
@@ -147,6 +153,41 @@ class ProductListJsonRenderer
$products = $productQuery->executeQuery()->fetchAllAssociative();
// Order by category in TREE order (decision 2026-09-04): products
// of the first selected/child category first, then the next, so a
// list over a parent category groups its families like the
// sitemap. $categoryUids comes from expandWithDescendants in
// depth-first tree order; within one category the uid order is
// kept - the records were created in sitemap-V2 row order.
if (!empty($categoryUids) && !$allProducts && $products !== []) {
$rankByCategory = array_flip($categoryUids);
$mmQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_category_record_mm');
$assignments = $mmQueryBuilder->select('uid_local', 'uid_foreign')
->from('sys_category_record_mm')
->where(
$mmQueryBuilder->expr()->eq('tablenames', $mmQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$mmQueryBuilder->expr()->eq('fieldname', $mmQueryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$mmQueryBuilder->expr()->in('uid_foreign', $mmQueryBuilder->createNamedParameter(
array_map(static fn(array $p): int => (int)$p['uid'], $products),
Connection::PARAM_INT_ARRAY
))
)->executeQuery()->fetchAllAssociative();
$rankByProduct = [];
foreach ($assignments as $assignment) {
$productUid = (int)$assignment['uid_foreign'];
$rank = $rankByCategory[(int)$assignment['uid_local']] ?? null;
if ($rank !== null && $rank < ($rankByProduct[$productUid] ?? PHP_INT_MAX)) {
$rankByProduct[$productUid] = $rank;
}
}
usort($products, static function (array $a, array $b) use ($rankByProduct): int {
$rankA = $rankByProduct[(int)$a['uid']] ?? PHP_INT_MAX;
$rankB = $rankByProduct[(int)$b['uid']] ?? PHP_INT_MAX;
return $rankA <=> $rankB ?: (int)$a['uid'] <=> (int)$b['uid'];
});
}
$productsData = [];
foreach ($products as $product) {
$productsData[] = $this->serializeProduct($product);
@@ -181,6 +222,45 @@ class ProductListJsonRenderer
}
}
/**
* The given category uids plus every descendant category uid, DEPTH first
* over sys_category.parent with siblings in sys_category.sorting order -
* the result is in backend-tree order and doubles as the sort rank for
* the list. One query for the whole table - the tree is small (< 100
* rows).
*
* @param int[] $categoryUids
* @return int[]
*/
private function expandWithDescendants(array $categoryUids): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder->select('uid', 'parent')->from('sys_category')
->where($queryBuilder->expr()->eq('deleted', 0))
->orderBy('parent')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
$childrenByParent = [];
foreach ($rows as $row) {
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
}
$result = [];
$visit = function (int $categoryUid) use (&$visit, &$result, $childrenByParent): void {
$result[] = $categoryUid;
foreach ($childrenByParent[$categoryUid] ?? [] as $childUid) {
if (!in_array($childUid, $result, true)) {
$visit($childUid);
}
}
};
foreach (array_map('intval', $categoryUids) as $categoryUid) {
if (!in_array($categoryUid, $result, true)) {
$visit($categoryUid);
}
}
return $result;
}
/**
* Serialize a single product DB row to the full headless JSON structure.
*