diff --git a/migrations/check_usecase_markets.php b/migrations/check_usecase_markets.php new file mode 100644 index 0000000..51db527 --- /dev/null +++ b/migrations/check_usecase_markets.php @@ -0,0 +1,80 @@ + PDO::ERRMODE_EXCEPTION] +); + +function one(PDO $pdo, string $sql): array +{ + return $pdo->query($sql)->fetch(PDO::FETCH_ASSOC) ?: []; +} +function all(PDO $pdo, string $sql): array +{ + return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); +} + +echo "=== 1. MM-Tabellen: Zeilen ueberhaupt da? ===\n"; +foreach (['tx_vitec_usecase_market_mm', 'tx_vitec_usecase_solution_mm'] as $mm) { + $n = one($pdo, "SELECT COUNT(*) c FROM {$mm}"); + echo sprintf("%-32s %s Zeilen\n", $mm, $n['c'] ?? '?'); +} + +echo "\n=== 2. Waisen: MM -> Markets, die es nicht (mehr) gibt ===\n"; +echo "verknuepfte Market-uids und ihr Zustand:\n"; +$rows = all($pdo, " + SELECT mm.uid_foreign AS market_uid, + COUNT(*) AS stories, + MAX(m.uid IS NULL) AS fehlt_ganz, + MAX(COALESCE(m.deleted, -1)) AS deleted, + MAX(COALESCE(m.hidden, -1)) AS hidden, + MAX(COALESCE(m.title, '?')) AS title + FROM tx_vitec_usecase_market_mm mm + LEFT JOIN tx_vitec_domain_model_market m ON m.uid = mm.uid_foreign + GROUP BY mm.uid_foreign + ORDER BY mm.uid_foreign +"); +foreach ($rows as $r) { + $state = $r['fehlt_ganz'] ? 'FEHLT KOMPLETT' : (($r['deleted'] ?? 0) ? 'GELOESCHT' : ((($r['hidden'] ?? 0) ? 'versteckt' : 'ok'))); + echo sprintf(" market uid %-5s %-15s %-40s (%s Stories)\n", $r['market_uid'], $state, mb_substr((string)$r['title'], 0, 40), $r['stories']); +} + +echo "\n=== 3. Markets-Tabelle: Bestand ===\n"; +$m = one($pdo, "SELECT COUNT(*) total, SUM(deleted) del, SUM(hidden) hid, MIN(uid) minuid, MAX(uid) maxuid FROM tx_vitec_domain_model_market"); +echo sprintf("gesamt %s | geloescht %s | versteckt %s | uid-Bereich %s-%s\n", $m['total'], $m['del'], $m['hid'], $m['minuid'], $m['maxuid']); +echo "geloeschte Markets (falls vorhanden):\n"; +foreach (all($pdo, "SELECT uid, title, FROM_UNIXTIME(tstamp) t FROM tx_vitec_domain_model_market WHERE deleted = 1 ORDER BY uid") as $r) { + echo sprintf(" uid %-5s %-45s zuletzt %s\n", $r['uid'], mb_substr((string)$r['title'], 0, 45), $r['t']); +} + +echo "\n=== 4. sys_category-Zuordnungen der Stories (JSON-Feld `categories`) ===\n"; +$c = one($pdo, "SELECT COUNT(*) c FROM sys_category_record_mm WHERE tablenames = 'tx_vitec_domain_model_usecase'"); +echo "sys_category_record_mm fuer usecases: " . ($c['c'] ?? '?') . " Zeilen\n"; + +echo "\n=== 5. Stichprobe: 3 Stories mit ihren MM-Zeilen ===\n"; +foreach (all($pdo, " + SELECT u.uid, u.title, + (SELECT COUNT(*) FROM tx_vitec_usecase_market_mm mm WHERE mm.uid_local = u.uid) AS mmrows + FROM tx_vitec_domain_model_usecase u + WHERE u.deleted = 0 + ORDER BY u.uid LIMIT 3 +") as $r) { + echo sprintf(" story uid %-4s %-40s -> %s MM-Zeile(n)\n", $r['uid'], mb_substr((string)$r['title'], 0, 40), $r['mmrows']); +} + +echo "\nFertig - nichts geschrieben.\n"; diff --git a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php index c349501..1cc4378 100755 --- a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php +++ b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php @@ -58,6 +58,25 @@ final class ImportSuccessStoriesCommand extends Command 'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation', ]; + /** + * --markets-only: old industry names -> the nine main markets of the + * current (SEO-CSV) taxonomy, decision 2026-08-26. The repair mode never + * creates market records; a target title that does not exist is reported + * as unresolved instead. Titles verified against the live records. + */ + private const INDUSTRY_TO_MARKET = [ + 'Sports' => 'Sports, Venues & Entertainment', + 'Venues' => 'Sports, Venues & Entertainment', + 'Government' => 'Defense, Government & Public Sector', + 'Military' => 'Defense, Government & Public Sector', + 'Corporate' => 'Corporate & Enterprise', + 'Broadcast' => 'Media, Broadcast & Telecom', + 'Education' => 'Healthcare & Education', + 'Healthcare' => 'Healthcare & Education', + 'Hospitality & Leisure' => 'Retail, Hospitality & Leisure', + 'Accommodation' => 'Retail, Hospitality & Leisure', + ]; + /** CTypes that never become content elements. */ private const SKIP_CTYPES = [ 'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu', @@ -88,6 +107,7 @@ final class ImportSuccessStoriesCommand extends Command $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the story whose slug tail matches'); $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse and report only, write nothing'); $this->addOption('force', null, InputOption::VALUE_NONE, 'Delete + recreate stories that already exist (matched by slug)'); + $this->addOption('markets-only', null, InputOption::VALUE_NONE, 'Repair mode: only rewrite the markets relation of EXISTING records, touch nothing else'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -131,6 +151,10 @@ final class ImportSuccessStoriesCommand extends Command $dryRun ? 'DRY-RUN' : 'LIVE' )); + if ((bool)$input->getOption('markets-only')) { + return $this->relinkMarkets($stories, $only, $dryRun, $output); + } + if (!$dryRun) { $this->ensureMarkets($output, $storagePid); } @@ -158,6 +182,110 @@ final class ImportSuccessStoriesCommand extends Command return Command::SUCCESS; } + /** + * --markets-only repair mode. Re-derives each story's industries from the + * export exactly like the full import, translates them onto the current + * taxonomy (INDUSTRY_TO_MARKET) and rewrites ONLY the `markets` MM + * relation of the existing usecase record - no other field is touched, + * no record is created or deleted anywhere (unlike the full import this + * mode never creates market records either). Stories whose target market + * does not exist keep their current relations and are reported. + * + * @param array> $stories + */ + private function relinkMarkets(array $stories, string $only, bool $dryRun, OutputInterface $output): int + { + $marketConn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market'); + foreach ($marketConn->select(['uid', 'title'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative() as $marketRow) { + $this->marketUidByTitle[$this->normTitle((string)$marketRow['title'])] = (int)$marketRow['uid']; + } + + $conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); + $datamap = []; + $missing = 0; + $unresolved = 0; + + foreach ($stories as $page) { + $slugTail = basename((string)$page['slug']); + if ($only !== '' && $slugTail !== $only) { + continue; + } + $slug = '/' . $slugTail; + $card = $this->cardByPageUid[(int)$page['uid']] ?? null; + $useCase = $this->useCaseByKey[$this->normTitle((string)$page['title'])] ?? null; + $industries = $this->industriesForStory($page, $card, $useCase); + + $existing = $conn->select(['uid'], self::TABLE, ['slug' => $slug, 'deleted' => 0])->fetchAssociative() + ?: $conn->select(['uid'], self::TABLE, ['slug' => $slugTail, 'deleted' => 0])->fetchAssociative(); + + if (!$existing) { + $missing++; + $output->writeln(sprintf(' %-52s no record for slug %s', $slugTail, $slug)); + continue; + } + + $targets = array_values(array_unique(array_map( + static fn(string $industry): string => self::INDUSTRY_TO_MARKET[$industry] ?? $industry, + $industries + ))); + $uids = array_values(array_unique(array_filter(array_map( + fn(string $title): int => $this->marketUidByTitle[$this->normTitle($title)] ?? 0, + $targets + )))); + + if ($uids === []) { + $unresolved++; + $output->writeln(sprintf( + ' %-52s uid %-5d [%s] => [%s] -> unresolved, left untouched', + $slugTail, + (int)$existing['uid'], + implode(',', $industries), + implode(',', $targets) + )); + continue; + } + + $output->writeln(sprintf( + ' %-54s uid %-5d [%s] => [%s] -> markets %s', + $slugTail, + (int)$existing['uid'], + implode(',', $industries), + implode(',', $targets), + implode(',', $uids) + )); + $datamap[self::TABLE][(int)$existing['uid']] = ['markets' => implode(',', $uids)]; + } + + $relinked = count($datamap[self::TABLE] ?? []); + + if ($dryRun) { + $output->writeln(sprintf( + 'DRY-RUN: %d stories would be relinked, %d without record, %d unresolved.', + $relinked, + $missing, + $unresolved + )); + return Command::SUCCESS; + } + + if ($datamap !== []) { + $dh = GeneralUtility::makeInstance(DataHandler::class); + $dh->start($datamap, []); + $dh->process_datamap(); + foreach ($dh->errorLog as $error) { + $output->writeln("$error"); + } + } + $output->writeln(sprintf( + '%d stories relinked, %d without record, %d unresolved.', + $relinked, + $missing, + $unresolved + )); + + return Command::SUCCESS; + } + // =========================================================== index building private function buildIndexes(): void @@ -268,7 +396,7 @@ final class ImportSuccessStoriesCommand extends Command } /** Ensure one market record per canonical industry; fill title->uid map. */ - private function ensureMarkets(OutputInterface $output, int $fallbackPid): void + private function ensureMarkets(OutputInterface $output, int $fallbackPid, bool $create = true): void { $conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market'); $rows = $conn->select(['uid', 'title', 'pid'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative(); @@ -285,6 +413,12 @@ final class ImportSuccessStoriesCommand extends Command } } if ($datamap !== []) { + if (!$create) { + foreach ($datamap['tx_vitec_domain_model_market'] as $fields) { + $output->writeln(" would create market: {$fields['title']}"); + } + return; + } $dh = GeneralUtility::makeInstance(DataHandler::class); $dh->start($datamap, []); $dh->process_datamap(); diff --git a/packages/vitec/Classes/Controller/Backend/ImportController.php b/packages/vitec/Classes/Controller/Backend/ImportController.php index 857feee..aa213bd 100644 --- a/packages/vitec/Classes/Controller/Backend/ImportController.php +++ b/packages/vitec/Classes/Controller/Backend/ImportController.php @@ -173,6 +173,47 @@ final class ImportController return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303); } + /** + * Persist hand-made record links from the SEO structure check. Shares the + * per-model alias store with the import tabs (MappingRepository), so a + * link made in either place serves both - and vitec:create-markets stops + * proposing linked rows as new records. Key convention is the import + * tab's: lowercased, trimmed CSV name. uid 0 removes a link. + */ + public function seoAliasesAction(ServerRequestInterface $request): ResponseInterface + { + $body = (array)$request->getParsedBody(); + $modelKey = (string)($body['model'] ?? ''); + if ($this->registry->has($modelKey)) { + $aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? []; + $names = is_array($body['aliaskey'] ?? null) ? $body['aliaskey'] : []; + $selected = is_array($body['alias'] ?? null) ? $body['alias'] : []; + $changed = 0; + foreach ($selected as $index => $selectedUid) { + $key = mb_strtolower(trim((string)($names[$index] ?? ''))); + if ($key === '') { + continue; + } + $uid = (int)$selectedUid; + $current = (int)($aliases[$key] ?? 0); + if ($uid > 0 && $uid !== $current) { + $aliases[$key] = $uid; + $changed++; + } elseif ($uid === 0 && $current > 0) { + unset($aliases[$key]); + $changed++; + } + } + $this->mappingRepository->saveAliases($modelKey, $aliases); + $this->flash( + sprintf('%d record link(s) changed for %s.', $changed, $modelKey), + 'Record links saved', + true + ); + } + return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303); + } + // ------------------------------------------------------------ rendering /** diff --git a/packages/vitec/Classes/Import/SeoResearchService.php b/packages/vitec/Classes/Import/SeoResearchService.php index aef601c..cdcf6f9 100644 --- a/packages/vitec/Classes/Import/SeoResearchService.php +++ b/packages/vitec/Classes/Import/SeoResearchService.php @@ -18,7 +18,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; * products -> level-3.x.y+ rows against tx_vitec_domain_model_product * (3.x rows are categories, not products - skipped) * Record matching: slug == last URL segment, falling back to a - * normalized title comparison (parentheses stripped, & -> and). + * normalized title comparison (parentheses stripped, & -> and), and + * finally the hand-made record links (record_aliases per model in + * tx_vitec_import_mapping - the same store the import tabs use, so + * one link serves both views and vitec:create-markets). * - diff against the previous delivery, keyed by URL * * Works on the normalized row schema produced by normalizeRows(). Read-only: @@ -141,7 +144,8 @@ final class SeoResearchService $depth = substr_count($r['ref'], '.') + 1; return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][1]; })); - $out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table']); + $aliases = GeneralUtility::makeInstance(MappingRepository::class)->load($cfg['key'])['aliases'] ?? []; + $out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table'], $aliases); } return $out; } @@ -174,9 +178,10 @@ final class SeoResearchService /** * @param array> $candidates - * @return array{total:int,matched:int,missing:array>,extra:array>} + * @param array $aliases lowercased CSV name -> record uid + * @return array{total:int,matched:int,both:array>,missing:array>,extra:array>,linkable:array>} */ - private function recordsCheck(array $candidates, string $table): array + private function recordsCheck(array $candidates, string $table, array $aliases): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); $records = $qb->select('uid', 'title', 'slug')->from($table) @@ -185,28 +190,43 @@ final class SeoResearchService $bySlug = []; $byTitle = []; + $byUid = []; foreach ($records as $rec) { $slug = mb_strtolower(trim((string)($rec['slug'] ?? ''))); if ($slug !== '') { $bySlug[$slug] = $rec; } $byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec; + $byUid[(int)$rec['uid']] = $rec; } $missing = []; $both = []; $matchedUids = []; + $directUids = []; foreach ($candidates as $r) { $segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/')))); $rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null; + $via = ''; + if ($rec === null) { + $aliasUid = (int)($aliases[mb_strtolower(trim($r['name']))] ?? 0); + if ($aliasUid > 0 && isset($byUid[$aliasUid])) { + $rec = $byUid[$aliasUid]; + $via = 'link'; + } + } if ($rec !== null) { $matchedUids[(int)$rec['uid']] = true; + if ($via === '') { + $directUids[(int)$rec['uid']] = true; + } $both[] = [ 'ref' => $r['ref'], 'name' => $r['name'], 'url' => $r['url'], 'uid' => (string)$rec['uid'], 'title' => (string)$rec['title'], + 'via' => $via, ]; } else { $missing[] = $r; @@ -214,11 +234,19 @@ final class SeoResearchService } $extra = []; + $linkable = []; foreach ($records as $rec) { if (!isset($matchedUids[(int)$rec['uid']])) { $extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']]; } + // Select options for the hand-made links: everything the direct + // match (slug/title) did not claim - alias-linked records stay in + // here so a linked row can be re-pointed or unlinked. + if (!isset($directUids[(int)$rec['uid']])) { + $linkable[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']]; + } } + usort($linkable, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title'])); return [ 'total' => count($candidates), @@ -226,6 +254,7 @@ final class SeoResearchService 'both' => $both, 'missing' => $missing, 'extra' => $extra, + 'linkable' => $linkable, ]; } diff --git a/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php b/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php new file mode 100644 index 0000000..4b87cde --- /dev/null +++ b/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php @@ -0,0 +1,33 @@ +seotitle = $seotitle; + } + + public function getTitle(): string + { + return $this->seotitle; + } +} diff --git a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php index 40d322b..cc6b26a 100755 --- a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php @@ -134,6 +134,15 @@ class UsecaseShowJsonRenderer : ''; } + $pageTitle = trim((string)($usecase['seo_title'] ?? '')); + if ($pageTitle === '') { + $pageTitle = trim((string)($usecase['title'] ?? '')); + } + if ($pageTitle !== '') { + GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\UsecasePageTitleProvider::class) + ->setSeoTitle($pageTitle); + } + $serializer = GeneralUtility::makeInstance(UsecaseSerializer::class); $backPid = (int)($settings['backPid'] ?? 0); diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index a1a0fe9..2105deb 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -35,6 +35,10 @@ return [ 'target' => ImportController::class . '::seoUploadAction', 'methods' => ['POST'], ], + 'seo_aliases' => [ + 'target' => ImportController::class . '::seoAliasesAction', + 'methods' => ['POST'], + ], 'products' => [ 'target' => ProductTextImportController::class . '::productsAction', ], diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 37a1dfa..a1f0047 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -7,6 +7,11 @@ config { before = record before = seo } + vitecUsecase { + provider = Evomedien\Vitec\PageTitle\UsecasePageTitleProvider + before = record + before = seo + } } } diff --git a/packages/vitec/Resources/Private/Templates/Import/Seo.html b/packages/vitec/Resources/Private/Templates/Import/Seo.html index 751b775..4b1f4e1 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Seo.html +++ b/packages/vitec/Resources/Private/Templates/Import/Seo.html @@ -91,6 +91,10 @@ sub-solutions. Products: level x.y and deeper (the top product rows are categories). Matching: record slug against the last URL segment, falling back to a normalized title comparison. The Page Ref column shows the hierarchy. + A row the matcher misses can be linked by hand via its select + — the link is stored per model (shared with the import tabs) and re-applied + on every future delivery. Linked rows show up under "Both" with a badge + and can be re-pointed or removed there.

@@ -98,22 +102,52 @@

{area}

+
+

Both in CSV and TYPO3 ({check.both -> f:count()})

    - -
  • {pair.ref} {pair.name} → uid {pair.uid} ({pair.title})
  • + +
  • + {pair.ref} {pair.name} + → uid {pair.uid} ({pair.title}) + + linked + + + +

Missing in TYPO3 ({check.missing -> f:count()})

    - -
  • {row.ref} {row.name}
  • + +
  • + {row.ref} {row.name} + + + + +
+ +

+
+

Only in TYPO3 ({check.extra -> f:count()})