Files
VITEC-website/packages/vitec/Classes/Service/ProductCheckService.php
T
orascheandClaude Fable 5.1 17b08df283 Content checks: flag placeholder images instead of counting them as done
Files below fileadmin/placeholders/ (or named "...placeholder...") are grey
dummies. PageCheckService and ProductCheckService now report them:

- image cells carry `placeholder` = number of placeholder files (only when > 0);
  `ok` is unchanged (something is attached)
- summary.filled counts only cells without placeholders, new summary.placeholder
  counts the cells that have one
- detail images carry `placeholder: true|false`
- cards: `placeholder` = cards whose icon/image is a placeholder; record: the
  record image

Additive change to the JSON contract; frontend rendering follows (ticket).
Requested by Harry 2026-11-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 18:47:12 +01:00

415 lines
18 KiB
PHP

<?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.
*
* Placeholders (Harry 2026-11-13, same rule as PageCheckService): files below
* fileadmin/placeholders/ or named "...placeholder..." do not pass as
* finished. Image cells carry `placeholder` = number of such files (only when
* > 0), summary.filled counts only cells without placeholders and
* summary.placeholder the cells that have one; detail images carry
* `placeholder: true|false`.
*/
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);
$placeholders = $this->fileCounts($uids, true);
$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);
$placeholder = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
$rows = [];
foreach ($products as $p) {
$uid = (int)$p['uid'];
$f = $files[$uid] ?? [];
$ph = $placeholders[$uid] ?? [];
$checks = [
'hero' => $this->check($f['heroimage'] ?? 0, $ph['heroimage'] ?? 0),
'photos' => $this->check($f['productimage'] ?? 0, $ph['productimage'] ?? 0),
'og' => $this->check($f['ogimage'] ?? 0, $ph['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['placeholder'] ?? 0) > 0) {
$placeholder[$key]++;
} elseif ($c['ok']) {
$filled[$key]++;
}
}
$rows[] = $this->base($p) + ['checks' => $checks];
}
return [
'generatedAt' => date(\DATE_ATOM),
'fields' => self::FIELDS,
'summary' => ['products' => count($rows), 'filled' => $filled, 'placeholder' => $placeholder],
'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, int $placeholder = 0): array
{
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
}
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, bool $placeholdersOnly = false): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$sql = $placeholdersOnly
? "SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n 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 IN (" . $in . ') AND ' . PageCheckService::PLACEHOLDER_SQL . '
GROUP BY r.uid_foreign, r.fieldname'
: "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 (" . $in . ')
GROUP BY uid_foreign, fieldname';
$rows = $this->connection()->executeQuery($sql)->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, placeholder: bool}>>
*/
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'],
'placeholder' => PageCheckService::isPlaceholder((string)$r['identifier'], (string)$r['name']),
];
}
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);
}
}