Sub-market workbook import as a backend module tab

- Extract the complete import logic (label-scan reading, page
  find-or-create, slot filling, content-idempotent image attach) from
  ImportSubmarketWorkbooksCommand into the shared service
  SubmarketWorkbookImporter; the CLI command and the new controller are
  thin shells around importFile(), so both entry points cannot drift.
  CLI regression verified: identical dry-run report for the reference
  workbook (12 elements, page 443)
- New "Sub-Markets" tab in the VITEC Import module
  (SubmarketImportController + Import/Submarkets.html): upload multiple
  filled workbooks into the SAME inbox the CLI uses
  (migrations/submarkets_xlsx_in/), per-file checkboxes, dry-run and
  apply buttons, result cards with the familiar report and a direct
  link into the page module; backend dry-run verified against the CLI
- Submitted filenames are whitelisted against the actual directory
  listing (no path traversal); uploads restricted to sanitized .xlsx
  basenames
- Routes submarkets/submarket_upload/submarket_process in Modules.php,
  Sub-Markets tab link in the sibling module views, ok/danger badge
  colors in backend-import.css
- Docs section 5y (incl. operations note: apply in small batches in the
  module, mass runs via CLI - image downloads run inside the web
  request)
This commit is contained in:
orasche committed 2026-10-05 15:35:40 +02:00
1 parent d496877a53
commit 3d5459b8d2
9 files changed
+980 -585

No files matched your search

@@ -26,6 +26,9 @@
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
@@ -23,6 +23,9 @@
<li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
@@ -18,6 +18,9 @@
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
</li>
</f:for>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
@@ -0,0 +1,141 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Backend/Default" />
<f:section name="main">
<div class="module-docheader-bar module-docheader-bar-navigation">
<div class="module-docheader-bar-column-left">
<h2 class="t3js-title-inlineedit">VITEC Import &mdash; Sub-Markets</h2>
</div>
</div>
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport')}">CSV Import</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
</ul>
<!-- Upload: drops Joe's filled workbooks into the shared inbox directory -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-header"><strong>Upload filled workbooks</strong></div>
<div class="card-body">
<form action="{f:be.uri(route: 'web_vitecimport.submarket_upload')}" method="post" enctype="multipart/form-data" class="row row-cols-auto align-items-center g-2">
<div class="col">
<input type="file" name="workbooks[]" accept=".xlsx" multiple="multiple" class="form-control" required="required" />
</div>
<div class="col">
<button type="submit" class="btn btn-primary">Upload</button>
</div>
<div class="col form-text">
Multiple files allowed. A file with the same name replaces the stored one ({inbox}/).
</div>
</form>
</div>
</div>
<!-- Inbox + dry-run / apply -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-header"><strong>Workbooks in the inbox</strong></div>
<div class="card-body">
<f:if condition="{workbooks}">
<f:then>
<form action="{f:be.uri(route: 'web_vitecimport.submarket_process')}" method="post">
<table class="table table-striped table-sm" style="max-width:900px;">
<thead>
<tr>
<th style="width:30px;"></th>
<th>File</th>
<th>Size</th>
<th>Uploaded / changed</th>
</tr>
</thead>
<tbody>
<f:for each="{workbooks}" as="w">
<tr>
<td><input type="checkbox" name="files[]" value="{w.name}" checked="checked" /></td>
<td>{w.name}</td>
<td>{w.size}</td>
<td>{w.modified}</td>
</tr>
</f:for>
</tbody>
</table>
<button type="submit" name="op" value="dry" class="btn btn-default">Dry run (no changes)</button>
<button type="submit" name="op" value="apply" class="btn btn-warning">Apply &mdash; write pages</button>
<span class="form-text" style="display:inline-block;margin-left:.75rem;">
Always dry-run first. Apply updates existing elements in place, creates missing ones, never deletes.
New pages are created HIDDEN for editorial review.
</span>
</form>
</f:then>
<f:else>
<p>No workbooks yet &mdash; upload Joe's filled .xlsx files above.</p>
</f:else>
</f:if>
</div>
</div>
<!-- Results -->
<f:if condition="{hasResults}">
<f:for each="{results}" as="r">
<div class="card" style="margin-bottom:1rem;">
<div class="card-header">
<strong>{r.file}</strong>
<f:if condition="{r.error}">
<f:then>
<span class="vitec-badge vitec-badge-danger">ERROR</span>
</f:then>
<f:else>
&mdash; {r.group} / {r.submarket}
<f:if condition="{r.applied}">
<f:then><span class="vitec-badge vitec-badge-ok">written</span></f:then>
<f:else><span class="vitec-badge vitec-badge-info">dry run</span></f:else>
</f:if>
</f:else>
</f:if>
</div>
<div class="card-body">
<f:if condition="{r.error}">
<f:then>
<p class="text-danger">{r.error}</p>
</f:then>
<f:else>
<p>
Page:
<f:if condition="{r.pageUid}">
<f:then>
<a href="{f:be.uri(route: 'web_layout', parameters: {id: r.pageUid})}">#{r.pageUid} &mdash; open in page module</a>
</f:then>
<f:else>
NEW (would be created hidden)
</f:else>
</f:if>
&middot; {r.elements} element(s) touched
</p>
<f:if condition="{r.report}">
<ul style="margin-bottom:0;">
<f:for each="{r.report}" as="line">
<li>{line}</li>
</f:for>
</ul>
</f:if>
</f:else>
</f:if>
</div>
</div>
</f:for>
</f:if>
</f:section>
</html>