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:
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. */
|
||||
|
||||
Reference in new issue
Block a user