Market record taxonomy aligned with the agency structure

- new vitec:align-market-records: the visible /markets page tree is the
  target (it implements the agreed agency taxonomy). RENAME_MAP carries
  16 records - story relations included - onto the new names, exact
  matches get their detail_page wired, 10 records are created (CNI,
  Government, Public Safety and seven sub-markets). Records the agency
  dropped are never touched: they are reported with their attached
  stories as the editorial decision list - which turned out empty, all
  nine carried zero stories
- INDUSTRY_TO_MARKET of the story importer moved to the new names. The
  old combined defense market split cleanly: the Government industry now
  maps to its own market, Military stays on Defense and Security, so the
  --markets-only re-run redistributed those stories by their original
  industry. Healthcare deliberately lost its target (market dropped) and
  surfaces as unresolved from now on
- re-run applied: 48/48 stories relinked, badges verified live (no old
  names left), Solr reindex queued - the market facet now speaks the new
  taxonomy

Open: Overlake Medical Center still points at the dropped Healthcare
record (title-match fallback), the nine orphaned old records await
deletion, Public Safety has no stories yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-26 10:40:42 +01:00
1 parent a9a25e4b1e
commit 8adbe8cc90
2 files changed
+296 -10

No files matched your search

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Aligns the market RECORD taxonomy (badges on the success stories, the
* Solr `market=` facet, marketlist cards) with the agreed agency structure
* that the /markets PAGE TREE already implements (vitec:align-market-pages,
* 2026-10-23). The page tree is the source of truth here: one record per
* visible market page and per visible sub-market page, detail_page wired.
*
* What it does (dry-run by default):
* - RENAME_MAP renames the old records onto the new names - a rename
* KEEPS the story relations, which is the whole point
* - remaining records and targets match by normalised title
* - targets without a record are CREATED (title, slug, detail_page)
* - records without a target are NEVER touched - they are reported with
* their attached stories as the editorial decision list (re-attach the
* stories, then retire the record by hand)
* - the two SPLIT sources keep their stories on the renamed record; their
* story lists are reported for review (some may belong to the new
* sibling markets)
*
* Afterwards (separate steps): editorial story re-mapping, the
* INDUSTRY_TO_MARKET table of the story importer, and a Solr reindex.
*
* vendor/bin/typo3 vitec:align-market-records
* vendor/bin/typo3 vitec:align-market-records --apply
*/
#[AsCommand(
name: 'vitec:align-market-records',
description: 'Align the market records with the /markets page tree (dry-run by default)'
)]
final class AlignMarketRecordsCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_market';
private const MM = 'tx_vitec_usecase_market_mm';
/** old record title => new name (page title). Renames keep the story relations. */
private const RENAME_MAP = [
// main markets (agency mapping, 2026-10-23)
'Corporate & Enterprise' => 'Corporation',
'Media Broadcast & Telecom' => 'Broadcast and Telecoms',
'Retail Hospitality & Leisure' => 'Hospitality',
'Transport & Infrastructure' => 'Transport',
'Sports Venues & Entertainment' => 'Sports & Entertainment',
'Healthcare & Education' => 'Education',
'Energy Industrial & Resources' => 'Industrial',
'Defense Government & Public Sector' => 'Defense and Security',
// sub-markets
'Aviation' => 'Airports',
'Casinos & Sports Books' => 'Casinos',
'Education' => 'Education Communications',
'Government' => 'Government & Agencies',
'Defense/ Military' => 'Defense & Security',
'Defense Military' => 'Defense & Security',
'Emergency Services' => 'Emergency Operation Centres',
'Sports Clubs' => 'Sports',
'Sports (Clubs)' => 'Sports',
'Utilities' => 'Energy & Utilities',
];
/** Renamed records whose stories need an editorial review (market splits). */
private const SPLIT_REVIEW = [
'Defense Government & Public Sector',
'Energy Industrial & Resources',
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$pages = $pool->getConnectionForTable('pages');
$markets = $pool->getConnectionForTable(self::TABLE);
// --- targets: the visible /markets tree ---------------------------------
$root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
if (!$root) {
$output->writeln('<error>Page /markets not found.</error>');
return Command::FAILURE;
}
$targets = [];
foreach ($this->childPages($pages, (int)$root['uid']) as $main) {
$targets[] = ['title' => (string)$main['title'], 'pageUid' => (int)$main['uid'], 'level' => 1];
foreach ($this->childPages($pages, (int)$main['uid']) as $sub) {
$targets[] = ['title' => (string)$sub['title'], 'pageUid' => (int)$sub['uid'], 'level' => 2];
}
}
// --- records --------------------------------------------------------------
$records = $markets->fetchAllAssociative(
'SELECT uid, pid, title, slug, detail_page, hidden FROM ' . self::TABLE . ' WHERE deleted = 0 AND sys_language_uid IN (0,-1)'
);
$pidCounts = array_count_values(array_map(static fn(array $r): int => (int)$r['pid'], $records));
arsort($pidCounts);
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($targets) . ' target(s) from the page tree · ' . count($records) . ' record(s)');
$datamap = [];
$claimedRecords = [];
$claimedTargets = [];
$newIndex = 0;
// pass 1: explicit renames keep the story relations
foreach (self::RENAME_MAP as $oldTitle => $newName) {
$record = $this->findRecord($records, $claimedRecords, $oldTitle);
if ($record === null) {
continue;
}
$targetIndex = $this->findTarget($targets, $claimedTargets, $newName);
if ($targetIndex === null) {
$output->writeln(' <comment>? rename "' . $oldTitle . '" -> "' . $newName . '": no page target - skipped</comment>');
continue;
}
$claimedRecords[(int)$record['uid']] = true;
$claimedTargets[$targetIndex] = true;
$target = $targets[$targetIndex];
$fields = [];
if ((string)$record['title'] !== $target['title']) {
$fields['title'] = $target['title'];
$fields['slug'] = $this->slugify($target['title']);
}
if ((int)$record['detail_page'] !== $target['pageUid']) {
$fields['detail_page'] = $target['pageUid'];
}
$stories = $this->stories($pool, (int)$record['uid']);
if ($fields !== []) {
$datamap[self::TABLE][(int)$record['uid']] = $fields;
$output->writeln(sprintf(' ~ RENAME #%d "%s" -> "%s" (detail_page %d, %d stories travel along)',
(int)$record['uid'], (string)$record['title'], $target['title'], $target['pageUid'], count($stories)));
} else {
$output->writeln(sprintf(' = #%d "%s" already aligned', (int)$record['uid'], $target['title']));
}
if (in_array($oldTitle, self::SPLIT_REVIEW, true) && $stories !== []) {
$output->writeln(' <comment>SPLIT REVIEW - these stories stay on "' . $target['title'] . '", some may belong to the new sibling markets:</comment>');
foreach ($stories as $story) {
$output->writeln(' - ' . $story);
}
}
}
// pass 2: exact title matches
foreach ($targets as $i => $target) {
if (isset($claimedTargets[$i])) {
continue;
}
$record = $this->findRecord($records, $claimedRecords, $target['title']);
if ($record === null) {
continue;
}
$claimedRecords[(int)$record['uid']] = true;
$claimedTargets[$i] = true;
if ((int)$record['detail_page'] !== $target['pageUid']) {
$datamap[self::TABLE][(int)$record['uid']]['detail_page'] = $target['pageUid'];
$output->writeln(sprintf(' ~ #%d "%s": detail_page -> %d', (int)$record['uid'], $target['title'], $target['pageUid']));
} else {
$output->writeln(sprintf(' = #%d "%s" already aligned', (int)$record['uid'], $target['title']));
}
}
// pass 3: targets without a record
foreach ($targets as $i => $target) {
if (isset($claimedTargets[$i])) {
continue;
}
$datamap[self::TABLE]['NEWMKT' . (++$newIndex)] = [
'pid' => $storagePid,
'title' => $target['title'],
'slug' => $this->slugify($target['title']),
'detail_page' => $target['pageUid'],
];
$output->writeln(sprintf(' + CREATE "%s" (level %d, detail_page %d)', $target['title'], $target['level'], $target['pageUid']));
}
// pass 4: records without a target - the editorial decision list
foreach ($records as $record) {
if (isset($claimedRecords[(int)$record['uid']])) {
continue;
}
$stories = $this->stories($pool, (int)$record['uid']);
$output->writeln(sprintf(' <comment>! DROPPED by the agency: #%d "%s"%s - untouched, %d story relation(s)%s</comment>',
(int)$record['uid'], (string)$record['title'],
((int)$record['hidden'] === 1) ? ' (hidden)' : '',
count($stories), $stories === [] ? '' : ':'));
foreach ($stories as $story) {
$output->writeln(' - ' . $story);
}
}
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
if ($datamap === []) {
$output->writeln('Nothing to change.');
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>Done. Reindex Solr and flush the cache afterwards.</info>');
return Command::SUCCESS;
}
/** @return array<int,array<string,mixed>> visible child pages, sorted */
private function childPages($pages, int $parentUid): array
{
return $pages->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
[$parentUid]
);
}
/** @param array<int,array<string,mixed>> $records */
private function findRecord(array $records, array $claimed, string $title): ?array
{
foreach ($records as $record) {
if (!isset($claimed[(int)$record['uid']]) && $this->norm((string)$record['title']) === $this->norm($title)) {
return $record;
}
}
return null;
}
/** @param array<int,array{title:string,pageUid:int,level:int}> $targets */
private function findTarget(array $targets, array $claimed, string $title): ?int
{
foreach ($targets as $i => $target) {
if (!isset($claimed[$i]) && $this->norm($target['title']) === $this->norm($title)) {
return $i;
}
}
return null;
}
/** @return string[] story titles attached to one market record */
private function stories(ConnectionPool $pool, int $marketUid): array
{
$connection = $pool->getConnectionForTable(self::MM);
return array_map('strval', $connection->fetchFirstColumn(
'SELECT u.title FROM ' . self::MM . ' mm
JOIN tx_vitec_domain_model_usecase u ON u.uid = mm.uid_local AND u.deleted = 0
WHERE mm.uid_foreign = ? ORDER BY u.title',
[$marketUid]
));
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
}
private function slugify(string $value): string
{
$value = str_replace('&', ' ', mb_strtolower(trim($value)));
return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-');
}
}
@@ -65,16 +65,22 @@ final class ImportSuccessStoriesCommand extends Command
* 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',
// Targets follow the agency taxonomy since vitec:align-market-records
// (2026-10-26). The old combined market split: the Government industry
// now has its own market, Military stays on Defense and Security - a
// --markets-only re-run therefore redistributes those stories by their
// original industry.
'Sports' => 'Sports & Entertainment',
'Venues' => 'Sports & Entertainment',
'Government' => 'Government',
'Military' => 'Defense and Security',
'Corporate' => 'Corporation',
'Broadcast' => 'Broadcast and Telecoms',
'Education' => 'Education',
// 'Healthcare' was dropped by the agency (2026-10-23): no target -
// such stories surface as unresolved for an editorial decision.
'Hospitality & Leisure' => 'Hospitality',
'Accommodation' => 'Hospitality',
];
/** CTypes that never become content elements. */