diff --git a/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php b/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php
new file mode 100644
index 0000000..938c6b4
--- /dev/null
+++ b/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php
@@ -0,0 +1,280 @@
+ 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('Page /markets not found.');
+ 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(' ? rename "' . $oldTitle . '" -> "' . $newName . '": no page target - skipped');
+ 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(' SPLIT REVIEW - these stories stay on "' . $target['title'] . '", some may belong to the new sibling markets:');
+ 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(' ! DROPPED by the agency: #%d "%s"%s - untouched, %d story relation(s)%s',
+ (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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
+ return Command::FAILURE;
+ }
+ $output->writeln('Done. Reindex Solr and flush the cache afterwards.');
+ return Command::SUCCESS;
+ }
+
+ /** @return array> 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> $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 $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), '-');
+ }
+}
diff --git a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
index 1cc4378..6908025 100755
--- a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
+++ b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
@@ -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. */