Files
VITEC-website/packages/vitec/Classes/Controller/Backend/SubsolutionImportController.php
T
orasche 8e67f5db23 Sub-solution workbook pipeline on layout 5, fix element order in both importers
- New, deliberately separate pipeline for sub-solutions (markets and
  solutions must never break each other): generator
  vitec:generate-subsolution-workbooks (list from the page tree below
  /solutions, 39 workbooks, every sub-solution matched to its record),
  SubsolutionWorkbookImporter + CLI vitec:import-subsolution-workbooks,
  and a "Sub-Solutions" tab in the VITEC Import module with its own
  inbox migrations/subsolutions_xlsx_in/
- Targets the existing template 5 "Solution Detail" without changing
  it: hero element in colPos 1, the remaining sections in fixed order in
  colPos 0, found again by CType on re-import; existing empty sub-solution
  pages are switched to layout 5, missing ones created hidden;
  detail_page set on the solution record
- Workbook per the Figma "07 Solution Detail page": hero without video
  teaser, intro with headline and without learn-more/portfolio, extended
  icon list, own page-layout reference image
- Shared content elements: market hero/intro/insights relabelled
  "Market/Solution", market intro gains the optional intro_headline
  field (new column, requires extension:setup)
- Fix: DataHandler puts every NEW record with a positive pid at the top
  of its column, so pages built in one run came out reversed. Both
  importers now track the page order per colPos and write
  tt_content.sorting explicitly after the DataHandler run; re-applying
  repairs pages imported earlier (verified on the control-room
  sub-solution and sub-market page 443)
- migrations/fill_test_subsolution.php: realistic test content from
  vitec.com copy and images for the control-room sub-solution
- Docs section 5z
2026-10-07 15:39:18 +02:00

180 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Import\SubsolutionWorkbookImporter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sub-Solutions tab of the "VITEC Import" module - the deliberate sibling
* of SubmarketImportController (decision 2026-10-05: dedicated importers,
* markets and solutions must never break each other).
*
* Files live in migrations/subsolutions_xlsx_in/ - the SAME directory the
* CLI vitec:import-subsolution-workbooks uses.
*/
#[AsController]
final class SubsolutionImportController
{
private const INBOX = 'migrations/subsolutions_xlsx_in';
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SubsolutionWorkbookImporter $importer,
private readonly FlashMessageService $flashMessageService,
private readonly PageRenderer $pageRenderer,
private readonly UriBuilder $uriBuilder,
) {}
public function subsolutionsAction(ServerRequestInterface $request): ResponseInterface
{
return $this->render($request, []);
}
public function subsolutionUploadAction(ServerRequestInterface $request): ResponseInterface
{
$uploads = $request->getUploadedFiles()['workbooks'] ?? [];
if (!is_array($uploads)) {
$uploads = [$uploads];
}
$dir = $this->inboxPath();
if (!is_dir($dir)) {
GeneralUtility::mkdir_deep($dir);
}
$stored = [];
foreach ($uploads as $upload) {
if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) {
continue;
}
$name = $this->safeName((string)$upload->getClientFilename());
if ($name === '') {
$this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING);
continue;
}
$upload->moveTo($dir . '/' . $name);
GeneralUtility::fixPermissions($dir . '/' . $name);
$stored[] = $name;
}
if ($stored !== []) {
$this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK);
} else {
$this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING);
}
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.subsolutions'), 303);
}
public function subsolutionProcessAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$apply = (string)($body['op'] ?? 'dry') === 'apply';
$selected = array_map('strval', (array)($body['files'] ?? []));
if ($selected === []) {
$this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING);
return $this->render($request, []);
}
$available = array_flip(array_column($this->workbooks(), 'name'));
$results = [];
foreach ($selected as $name) {
// Whitelist against the actual directory listing - the submitted
// name never touches the filesystem on its own (no traversal).
if (!isset($available[$name])) {
$results[] = ['file' => $name, 'error' => 'File not found in the inbox.'];
continue;
}
try {
$results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply);
} catch (\Throwable $e) {
$results[] = ['file' => $name, 'error' => $e->getMessage()];
}
}
$errors = count(array_filter($results, static fn(array $r): bool => isset($r['error'])));
$this->message(
sprintf('%d workbook(s) processed (%s)%s.',
count($results),
$apply ? 'APPLY' : 'dry run',
$errors > 0 ? ', ' . $errors . ' with errors' : ''
),
$errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK
);
return $this->render($request, $results);
}
// ------------------------------------------------------------ internals
/** @param array<int,array<string,mixed>> $results */
private function render(ServerRequestInterface $request, array $results): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
$view->assignMultiple([
'workbooks' => $this->workbooks(),
'inbox' => self::INBOX,
'results' => $results,
'hasResults' => $results !== [],
]);
return $view->renderResponse('Import/Subsolutions');
}
/** @return array<int,array{name:string,size:string,modified:string}> */
private function workbooks(): array
{
$dir = $this->inboxPath();
if (!is_dir($dir)) {
return [];
}
$rows = [];
foreach (scandir($dir) ?: [] as $name) {
if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) {
continue;
}
$path = $dir . '/' . $name;
if (!is_file($path)) {
continue;
}
$rows[] = [
'name' => $name,
'size' => sprintf('%.0f KB', filesize($path) / 1024),
'modified' => date('Y-m-d H:i', (int)filemtime($path)),
];
}
usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $rows;
}
private function inboxPath(): string
{
return Environment::getProjectPath() . '/' . self::INBOX;
}
/** Client filename reduced to a safe basename; '' when it is no .xlsx. */
private function safeName(string $clientFilename): string
{
$name = basename(str_replace('\\', '/', trim($clientFilename)));
if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) {
return '';
}
return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name);
}
private function message(string $text, ContextualFeedbackSeverity $severity): void
{
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
}
}