Product content check as page content (vitec_productcheck)
The product audit of public/product-check/ becomes data for the React frontend: new content element vitec_productcheck (plugin registration, wizard entry, TypoScript USER func) delivers under content.productcheck either the overview (every product with one {ok,count} check per field - hero, photos, og, video, teaser, subtitle, description, description2, highlights, capabilities, applications, features incl. icon count, resources, datasheets, solutions - plus per-field summary) or, with ?check=<uid>, the detail of one product (texts as stored HTML, images with original URL and 480px thumbnail, videos, feature cards with icons, resources, datasheets, solutions). ProductCheckService ports the PDO queries of the standalone page to Doctrine; the renderer is always USER_INT so the audit is never served from the page cache; check is excluded from the cHash. Migration adds the element to page 607 (/products/product-check). The standalone page stays untouched. Verified on DEV: overview 85 products in 0.13 s warm, detail 0.2-0.9 s, thumbnails resolve.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b36dceb486
commit
4bc38b1760
8 files changed
+519
No files matched your search
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Puts the vitec_productcheck content element on the page /products/product-check
|
||||||
|
// (uid 607) once. Idempotent: a second run reports the existing element.
|
||||||
|
// Run on dedi: php migrations/add_product_check_element_20261112.php
|
||||||
|
error_reporting(E_ALL); ini_set('display_errors', '1');
|
||||||
|
$pageUid = 607;
|
||||||
|
$root = null; $dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); }
|
||||||
|
if ($root === null) { exit("no root\n"); }
|
||||||
|
$db = (require $root . '/config/system/settings.php')['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||||
|
|
||||||
|
$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch();
|
||||||
|
if ($page === false) { exit("page $pageUid not found\n"); }
|
||||||
|
echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n";
|
||||||
|
|
||||||
|
$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch();
|
||||||
|
if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); }
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted)
|
||||||
|
VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)');
|
||||||
|
$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']);
|
||||||
|
echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n";
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product content audit as data: which product record has which content,
|
||||||
|
* and the full content of one product for the detail pane.
|
||||||
|
*
|
||||||
|
* This is the data model behind the standalone page public/product-check/
|
||||||
|
* (PHP + PDO, 2026-11-03), ported to Doctrine so the React frontend can
|
||||||
|
* render the same audit as a page (/products/product-check, content
|
||||||
|
* element vitec_productcheck, see ProductCheckJsonRenderer). Both read the
|
||||||
|
* database fresh on every call; nothing here is cached on purpose.
|
||||||
|
*
|
||||||
|
* overview(): every product (alphabetical) with one check per field
|
||||||
|
* {ok, count[, icons]} plus the per-field summary.
|
||||||
|
* detail(): one product with all texts (RTE HTML as stored), images
|
||||||
|
* (original URL + ~480 px thumbnail), videos, feature cards
|
||||||
|
* with icons, resources, datasheets, solutions.
|
||||||
|
*/
|
||||||
|
final class ProductCheckService
|
||||||
|
{
|
||||||
|
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns of the audit table, in display order. kind drives the cell
|
||||||
|
* format in the frontend: images/video/features/list show a count,
|
||||||
|
* text shows a plain check.
|
||||||
|
*/
|
||||||
|
private const FIELDS = [
|
||||||
|
['key' => 'hero', 'label' => 'Hero', 'kind' => 'images'],
|
||||||
|
['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'],
|
||||||
|
['key' => 'og', 'label' => 'OG image', 'kind' => 'images'],
|
||||||
|
['key' => 'video', 'label' => 'Video', 'kind' => 'video'],
|
||||||
|
['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'],
|
||||||
|
['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'],
|
||||||
|
['key' => 'description', 'label' => 'Description', 'kind' => 'text'],
|
||||||
|
['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'],
|
||||||
|
['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'],
|
||||||
|
['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'],
|
||||||
|
['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'],
|
||||||
|
['key' => 'features', 'label' => 'Features', 'kind' => 'features'],
|
||||||
|
['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'],
|
||||||
|
['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'],
|
||||||
|
['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const FLAGS = [
|
||||||
|
'hidden' => 'hidden',
|
||||||
|
'legacy' => 'legacy',
|
||||||
|
'supportproduct' => 'support',
|
||||||
|
'subproduct' => 'sub',
|
||||||
|
'hideonwebsite' => 'hideWeb',
|
||||||
|
'hideonproducts' => 'hideList',
|
||||||
|
'showdatapath' => 'datapath',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const TEXT_FIELDS = [
|
||||||
|
'teaser' => 'Teaser',
|
||||||
|
'subtitle' => 'Subtitle',
|
||||||
|
'description' => 'Description',
|
||||||
|
'description2' => 'Description 2',
|
||||||
|
'highlights' => 'Highlights',
|
||||||
|
'capabilities' => 'Capabilities',
|
||||||
|
'applications' => 'Applications',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{generatedAt: string, fields: array<int, array{key: string, label: string, kind: string}>, summary: array{products: int, filled: array<string, int>}, products: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function overview(): array
|
||||||
|
{
|
||||||
|
$products = $this->products();
|
||||||
|
$uids = array_map(static fn(array $p): int => (int)$p['uid'], $products);
|
||||||
|
$files = $this->fileCounts($uids);
|
||||||
|
$features = $this->featureCounts($uids);
|
||||||
|
$resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0');
|
||||||
|
$datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download');
|
||||||
|
$solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution');
|
||||||
|
|
||||||
|
$filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($products as $p) {
|
||||||
|
$uid = (int)$p['uid'];
|
||||||
|
$f = $files[$uid] ?? [];
|
||||||
|
$checks = [
|
||||||
|
'hero' => $this->check($f['heroimage'] ?? 0),
|
||||||
|
'photos' => $this->check($f['productimage'] ?? 0),
|
||||||
|
'og' => $this->check($f['ogimage'] ?? 0),
|
||||||
|
'video' => $this->check($f['videofile'] ?? 0),
|
||||||
|
'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0),
|
||||||
|
'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0),
|
||||||
|
'description' => $this->check($this->filled($p['description']) ? 1 : 0),
|
||||||
|
'description2' => $this->check($this->filled($p['description2']) ? 1 : 0),
|
||||||
|
'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0),
|
||||||
|
'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0),
|
||||||
|
'applications' => $this->check($this->filled($p['applications']) ? 1 : 0),
|
||||||
|
'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0],
|
||||||
|
'resources' => $this->check($resources[$uid] ?? 0),
|
||||||
|
'datasheets' => $this->check($datasheets[$uid] ?? 0),
|
||||||
|
'solutions' => $this->check($solutions[$uid] ?? 0),
|
||||||
|
];
|
||||||
|
foreach ($checks as $key => $c) {
|
||||||
|
if ($c['ok']) {
|
||||||
|
$filled[$key]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $this->base($p) + ['checks' => $checks];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'generatedAt' => date(\DATE_ATOM),
|
||||||
|
'fields' => self::FIELDS,
|
||||||
|
'summary' => ['products' => count($rows), 'filled' => $filled],
|
||||||
|
'products' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null null when the product does not exist
|
||||||
|
*/
|
||||||
|
public function detail(int $uid): ?array
|
||||||
|
{
|
||||||
|
$p = $this->connection()->executeQuery(
|
||||||
|
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
|
||||||
|
capabilities, applications, resources_layout,
|
||||||
|
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
|
||||||
|
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?',
|
||||||
|
[$uid]
|
||||||
|
)->fetchAssociative();
|
||||||
|
if ($p === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = $this->files($uid);
|
||||||
|
$texts = [];
|
||||||
|
foreach (self::TEXT_FIELDS as $field => $label) {
|
||||||
|
$texts[$field] = [
|
||||||
|
'label' => $label,
|
||||||
|
'ok' => $this->filled($p[$field]),
|
||||||
|
'html' => (string)$p[$field],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->base($p) + [
|
||||||
|
'images' => [
|
||||||
|
'hero' => $files['heroimage'] ?? [],
|
||||||
|
'photos' => $files['productimage'] ?? [],
|
||||||
|
'og' => $files['ogimage'] ?? [],
|
||||||
|
],
|
||||||
|
'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []),
|
||||||
|
'texts' => $texts,
|
||||||
|
'features' => $this->features($uid),
|
||||||
|
'resources' => $this->resources($uid),
|
||||||
|
'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid),
|
||||||
|
'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- building blocks ----------------------------------------------------
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $p */
|
||||||
|
private function base(array $p): array
|
||||||
|
{
|
||||||
|
$slug = trim((string)$p['slug'], '/');
|
||||||
|
$flags = [];
|
||||||
|
foreach (self::FLAGS as $column => $label) {
|
||||||
|
if ((int)($p[$column] ?? 0) === 1) {
|
||||||
|
$flags[] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'uid' => (int)$p['uid'],
|
||||||
|
'title' => (string)$p['title'],
|
||||||
|
'slug' => $slug,
|
||||||
|
'url' => '/product/' . $slug,
|
||||||
|
'flags' => $flags,
|
||||||
|
'resourcesLayout' => (string)($p['resources_layout'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, count: int} */
|
||||||
|
private function check(int $count): array
|
||||||
|
{
|
||||||
|
return ['ok' => $count > 0, 'count' => $count];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filled(mixed $value): bool
|
||||||
|
{
|
||||||
|
return trim(strip_tags((string)$value)) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array<string, mixed>> */
|
||||||
|
private function products(): array
|
||||||
|
{
|
||||||
|
return $this->connection()->executeQuery(
|
||||||
|
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
|
||||||
|
capabilities, applications, resources_layout,
|
||||||
|
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
|
||||||
|
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid'
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible FAL references per product and field.
|
||||||
|
*
|
||||||
|
* @param int[] $uids
|
||||||
|
* @return array<int, array<string, int>> [productUid][fieldname] = count
|
||||||
|
*/
|
||||||
|
private function fileCounts(array $uids): array
|
||||||
|
{
|
||||||
|
if ($uids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = $this->connection()->executeQuery(
|
||||||
|
"SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product'
|
||||||
|
AND fieldname IN ('heroimage','productimage','ogimage','videofile')
|
||||||
|
AND uid_foreign IN (" . implode(',', array_map('intval', $uids)) . ')
|
||||||
|
GROUP BY uid_foreign, fieldname'
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files of one product with original URL and a ~480 px thumbnail
|
||||||
|
* (SVG and non-images keep the original as thumbnail).
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, array{url: string, thumb: string, name: string, mime: string}>>
|
||||||
|
*/
|
||||||
|
private function files(int $uid): array
|
||||||
|
{
|
||||||
|
$rows = $this->connection()->executeQuery(
|
||||||
|
"SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
|
||||||
|
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
||||||
|
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
|
||||||
|
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ?
|
||||||
|
ORDER BY r.fieldname, r.sorting_foreign",
|
||||||
|
[$uid]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$url = '/fileadmin' . $r['identifier'];
|
||||||
|
$out[(string)$r['fieldname']][] = [
|
||||||
|
'url' => $url,
|
||||||
|
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
|
||||||
|
'name' => (string)$r['name'],
|
||||||
|
'mime' => (string)$r['mime_type'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function thumbnail(int $fileUid, string $mime, string $fallback): string
|
||||||
|
{
|
||||||
|
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
|
||||||
|
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||||
|
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
|
||||||
|
return $imageService->getImageUri($processed);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $uids
|
||||||
|
* @return array<int, array{cards: int, icons: int}>
|
||||||
|
*/
|
||||||
|
private function featureCounts(array $uids): array
|
||||||
|
{
|
||||||
|
if ($uids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$in = implode(',', array_map('intval', $uids));
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->connection()->executeQuery(
|
||||||
|
"SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product"
|
||||||
|
)->fetchAllAssociative() as $r) {
|
||||||
|
$out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0];
|
||||||
|
}
|
||||||
|
foreach ($this->connection()->executeQuery(
|
||||||
|
"SELECT fe.product, COUNT(DISTINCT fe.uid) AS n
|
||||||
|
FROM tx_vitec_product_feature fe
|
||||||
|
JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature'
|
||||||
|
AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0
|
||||||
|
WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product"
|
||||||
|
)->fetchAllAssociative() as $r) {
|
||||||
|
$out[(int)$r['product']]['icons'] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{title: string, text: string, icon: array{url: string, name: string}|null}> */
|
||||||
|
private function features(int $uid): array
|
||||||
|
{
|
||||||
|
$cards = $this->connection()->executeQuery(
|
||||||
|
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
|
||||||
|
[$uid]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
if ($cards === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$icons = [];
|
||||||
|
foreach ($this->connection()->executeQuery(
|
||||||
|
"SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
||||||
|
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon'
|
||||||
|
AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ')
|
||||||
|
ORDER BY r.uid_foreign, r.sorting_foreign'
|
||||||
|
)->fetchAllAssociative() as $r) {
|
||||||
|
$icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
|
||||||
|
}
|
||||||
|
return array_map(static fn(array $c): array => [
|
||||||
|
'title' => (string)$c['title'],
|
||||||
|
'text' => (string)$c['cardtext'],
|
||||||
|
'icon' => $icons[(int)$c['uid']] ?? null,
|
||||||
|
], $cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{title: string, type: string, link: string, text: string}> */
|
||||||
|
private function resources(int $uid): array
|
||||||
|
{
|
||||||
|
return array_map(static fn(array $r): array => [
|
||||||
|
'title' => (string)$r['title'],
|
||||||
|
'type' => (string)$r['restype'],
|
||||||
|
'link' => (string)$r['link'],
|
||||||
|
'text' => (string)$r['cardtext'],
|
||||||
|
], $this->connection()->executeQuery(
|
||||||
|
'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
|
||||||
|
[$uid]
|
||||||
|
)->fetchAllAssociative());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, int> [productUid] = count */
|
||||||
|
private function countBy(string $table, string $column, string $where): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->connection()->executeQuery(
|
||||||
|
"SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column"
|
||||||
|
)->fetchAllAssociative() as $r) {
|
||||||
|
$out[(int)$r['k']] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, int> [productUid] = count of non-deleted foreign records */
|
||||||
|
private function mmCounts(string $mmTable, string $foreignTable): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->connection()->executeQuery(
|
||||||
|
"SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm
|
||||||
|
JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
|
||||||
|
GROUP BY mm.uid_local"
|
||||||
|
)->fetchAllAssociative() as $r) {
|
||||||
|
$out[(int)$r['k']] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{uid: int, title: string}> */
|
||||||
|
private function mmTitles(string $mmTable, string $foreignTable, int $uid): array
|
||||||
|
{
|
||||||
|
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery(
|
||||||
|
"SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
|
||||||
|
WHERE mm.uid_local = ? ORDER BY mm.sorting",
|
||||||
|
[$uid]
|
||||||
|
)->fetchAllAssociative());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connection(): \TYPO3\CMS\Core\Database\Connection
|
||||||
|
{
|
||||||
|
return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\UserFunc;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\ProductCheckService;
|
||||||
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON for the content element `vitec_productcheck` (page /products/
|
||||||
|
* product-check): the product content audit, rendered by the React front
|
||||||
|
* end instead of the standalone page public/product-check/.
|
||||||
|
*
|
||||||
|
* Emitted under content.productcheck:
|
||||||
|
*
|
||||||
|
* without query {mode: "overview", generatedAt, fields[], summary, products[]}
|
||||||
|
* ?check=<uid> {mode: "detail", product: {…} | null}
|
||||||
|
* on failure {mode: "error", error}
|
||||||
|
*
|
||||||
|
* Always USER_INT: the audit must show the database as it is right now,
|
||||||
|
* so neither the TYPO3 page cache nor the `check` variant may stick.
|
||||||
|
* `check` is excluded from the cHash in ext_localconf.php. The front end
|
||||||
|
* is expected to load the detail variant client-side from /_json (one
|
||||||
|
* request per opened row), not to navigate the SSR page with the query.
|
||||||
|
*/
|
||||||
|
final class ProductCheckJsonRenderer
|
||||||
|
{
|
||||||
|
protected ?ContentObjectRenderer $cObj = null;
|
||||||
|
|
||||||
|
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
||||||
|
{
|
||||||
|
$this->cObj = $cObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsAllowedCallable]
|
||||||
|
public function render(string $content, array $conf): string
|
||||||
|
{
|
||||||
|
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
|
||||||
|
$this->cObj->convertToUserIntObject();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$service = GeneralUtility::makeInstance(ProductCheckService::class);
|
||||||
|
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
||||||
|
$check = (int)($queryParams['check'] ?? 0);
|
||||||
|
if ($check > 0) {
|
||||||
|
$payload = ['mode' => 'detail', 'product' => $service->detail($check)];
|
||||||
|
} else {
|
||||||
|
$payload = ['mode' => 'overview'] + $service->overview();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ services:
|
|||||||
Evomedien\Vitec\Service\SsrPurgeService:
|
Evomedien\Vitec\Service\SsrPurgeService:
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
|
# Fetched via GeneralUtility::makeInstance from the USER func
|
||||||
|
# ProductCheckJsonRenderer (TypoScript userFunc, no DI there).
|
||||||
|
Evomedien\Vitec\Service\ProductCheckService:
|
||||||
|
public: true
|
||||||
|
|
||||||
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
|
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
|
||||||
# injected by the dashboard compiler pass via the tag).
|
# injected by the dashboard compiler pass via the tag).
|
||||||
Evomedien\Vitec\Widgets\SsrStatusWidget:
|
Evomedien\Vitec\Widgets\SsrStatusWidget:
|
||||||
|
|||||||
@@ -100,6 +100,20 @@ tt_content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Product Content Check - the product audit table as data (page
|
||||||
|
# /products/product-check). Always USER_INT, see ProductCheckJsonRenderer.
|
||||||
|
vitec_productcheck < lib.contentElementWithHeader
|
||||||
|
vitec_productcheck {
|
||||||
|
fields {
|
||||||
|
content {
|
||||||
|
fields {
|
||||||
|
productcheck = USER
|
||||||
|
productcheck.userFunc = Evomedien\Vitec\UserFunc\ProductCheckJsonRenderer->render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
||||||
solr_pi_results < lib.contentElementWithHeader
|
solr_pi_results < lib.contentElementWithHeader
|
||||||
solr_pi_results {
|
solr_pi_results {
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
|
|||||||
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
||||||
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
||||||
$registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist');
|
||||||
|
// Product content audit as page content (2026-11-12): no FlexForm, the
|
||||||
|
// element has nothing to configure - data comes from ProductCheckService.
|
||||||
|
ExtensionUtility::registerPlugin(
|
||||||
|
'Vitec',
|
||||||
|
'Productcheck',
|
||||||
|
'Product Content Check (audit table)',
|
||||||
|
'vitec-plugin-productlist',
|
||||||
|
'plugins',
|
||||||
|
'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.'
|
||||||
|
);
|
||||||
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
||||||
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
||||||
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ mod {
|
|||||||
CType = vitec_productfinder
|
CType = vitec_productfinder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
productcheck {
|
||||||
|
iconIdentifier = vitec-plugin-productlist
|
||||||
|
title = Product Content Check (audit table)
|
||||||
|
description = Live audit of all product records - one row per product, one check per content field. Internal page /products/product-check.
|
||||||
|
tt_content_defValues {
|
||||||
|
CType = vitec_productcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
productshow {
|
productshow {
|
||||||
iconIdentifier = vitec-plugin-productshow
|
iconIdentifier = vitec-plugin-productshow
|
||||||
title = Show single VITEC Product
|
title = Show single VITEC Product
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
|
|||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
||||||
|
// Product content audit (/products/product-check): ?check=<product uid>
|
||||||
|
// switches the vitec_productcheck element to the detail payload.
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'check';
|
||||||
|
|
||||||
// SSR auto-purge: editors' changes evict the matching pages from the SSR
|
// SSR auto-purge: editors' changes evict the matching pages from the SSR
|
||||||
// node's full-page cache (queued per request, one call per save/import).
|
// node's full-page cache (queued per request, one call per save/import).
|
||||||
|
|||||||
Reference in new issue
Block a user