Product text import from agency XLSX workbooks
VITEC Import module: Products tab is now a searchable/sortable product overview. "Edit Product" opens an XLSX upload with per-field source mapping (component + cell), old/new preview and checkbox apply via DataHandler; the mapping and manual matches are persisted and preselect the next workbook. Category workbooks are detected and rejected. - new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController, Products/ProductTexts templates, 4 module routes - related products: per-pair card text in new side table tx_vitec_product_related_text (survives MM rewrites), emitted as `cardtext` in the product JSON; missing MM relations added add-only - card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) - the workbooks fill either depending on row type - product detail page <title> now uses seotitle with title fallback (provider made singleton and fed from the JSON renderer) - per-user recent-search badges; last loaded workbook stored per product (tx_vitec_product_workbook), Edit Product reopens on it - composer: add phpoffice/phpspreadsheet ^5.9 - diagnostics: migrations/check_workbook.php
This commit is contained in:
1 parent
12eb40614b
commit
7bd1161abf
28 files changed
+2594
-39
No files matched your search
@@ -31,6 +31,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* used for matching) is persisted per model via MappingRepository.
|
||||
* 3. A unified list shows CSV rows matched against the DB records:
|
||||
* new / update (with the differing fields) / unchanged / db-only.
|
||||
* Rows the identity match misses can be linked by hand to an existing
|
||||
* record (alias select on every "new" row); the links persist per
|
||||
* model next to the column mapping and are re-applied on upload.
|
||||
* Each importable row carries an editable JSON payload textarea -
|
||||
* what is applied is the textarea, not the raw CSV.
|
||||
* 4. Checked rows are written through DataHandler (BE user permissions
|
||||
@@ -113,6 +116,16 @@ final class ImportController
|
||||
$this->flash('Mapping stored for this model - it will be preselected on the next upload.', 'Mapping saved', true);
|
||||
}
|
||||
|
||||
if ($op === 'saveAliases' && $csv !== null && $mapping !== null && ($identity ?? '') !== '') {
|
||||
$this->saveAliases(
|
||||
$modelKey,
|
||||
$csv,
|
||||
$mapping,
|
||||
(string)$identity,
|
||||
is_array($body['alias'] ?? null) ? $body['alias'] : []
|
||||
);
|
||||
}
|
||||
|
||||
if ($op === 'apply') {
|
||||
$this->apply($modelKey, $body);
|
||||
}
|
||||
@@ -187,7 +200,8 @@ final class ImportController
|
||||
$identity = $saved['identity'] ?? (isset($fields['slug']) ? 'slug' : 'title');
|
||||
}
|
||||
|
||||
$union = $this->buildUnion($model['table'], $fields, $csv, $mapping, $identity);
|
||||
['rows' => $union, 'candidates' => $aliasCandidates] =
|
||||
$this->buildUnion($model['table'], $fields, $csv, $mapping, $identity, $saved['aliases'] ?? []);
|
||||
|
||||
$counts = ['new' => 0, 'update' => 0, 'unchanged' => 0, 'dbonly' => 0];
|
||||
foreach ($union as $row) {
|
||||
@@ -215,7 +229,8 @@ final class ImportController
|
||||
'csvRowCount' => count($csv['rows'] ?? []),
|
||||
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
|
||||
'identity' => $identity,
|
||||
'mappingSaved' => $saved !== null,
|
||||
'mappingSaved' => ($saved['mapping'] ?? []) !== [],
|
||||
'aliasCandidates' => $aliasCandidates,
|
||||
'union' => $union,
|
||||
'counts' => $counts,
|
||||
'pid' => $this->registry->detectPid($model['table']),
|
||||
@@ -246,12 +261,17 @@ final class ImportController
|
||||
/**
|
||||
* Merge CSV rows and DB records into one status-annotated list.
|
||||
*
|
||||
* Matching order per CSV row: identity field first, then the stored
|
||||
* aliases. Alias candidates are all records the identity match did not
|
||||
* claim - those are what the per-row select in the template offers.
|
||||
*
|
||||
* @param array<string,string> $fields
|
||||
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
|
||||
* @param array<string,string> $mapping
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @param array<string,int> $aliases
|
||||
* @return array{rows: array<int,array<string,mixed>>, candidates: array<int,array{uid:int,title:string}>}
|
||||
*/
|
||||
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity): array
|
||||
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity, array $aliases): array
|
||||
{
|
||||
$mappedFields = array_values(array_unique(array_values($mapping)));
|
||||
$records = $this->registry->loadRecords($table, array_keys($fields));
|
||||
@@ -266,6 +286,7 @@ final class ImportController
|
||||
|
||||
$union = [];
|
||||
$matchedUids = [];
|
||||
$directUids = [];
|
||||
$index = 0;
|
||||
|
||||
foreach ($csv['rows'] ?? [] as $raw) {
|
||||
@@ -275,9 +296,20 @@ final class ImportController
|
||||
}
|
||||
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
||||
$uid = $identityValue !== '' ? ($byIdentity[$identityValue] ?? 0) : 0;
|
||||
$viaAlias = false;
|
||||
if ($uid === 0 && $identityValue !== '') {
|
||||
$aliasUid = (int)($aliases[$identityValue] ?? 0);
|
||||
if ($aliasUid > 0 && isset($records[$aliasUid])) {
|
||||
$uid = $aliasUid;
|
||||
$viaAlias = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uid > 0) {
|
||||
$matchedUids[$uid] = true;
|
||||
if (!$viaAlias) {
|
||||
$directUids[$uid] = true;
|
||||
}
|
||||
$record = $records[$uid];
|
||||
$diff = [];
|
||||
foreach ($payload as $field => $value) {
|
||||
@@ -299,6 +331,9 @@ final class ImportController
|
||||
'diff' => implode(', ', $diff),
|
||||
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'checked' => $status === 'new' || $status === 'update',
|
||||
'viaAlias' => $viaAlias,
|
||||
'aliasable' => $viaAlias || $status === 'new',
|
||||
'aliasUid' => $viaAlias ? $uid : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -317,7 +352,61 @@ final class ImportController
|
||||
];
|
||||
}
|
||||
|
||||
return $union;
|
||||
$candidates = [];
|
||||
foreach ($records as $uid => $record) {
|
||||
if (isset($directUids[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = ['uid' => $uid, 'title' => (string)($record['title'] ?? $uid)];
|
||||
}
|
||||
usort($candidates, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title']));
|
||||
|
||||
return ['rows' => $union, 'candidates' => $candidates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the alias selects into the stored aliases. Key is the CSV
|
||||
* identity value (lowercased, like the matcher sees it), value the
|
||||
* record uid; uid 0 removes a link. Untouched keys stay - a CSV that
|
||||
* lacks a row must not lose that row's link.
|
||||
*
|
||||
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>} $csv
|
||||
* @param array<string,string> $mapping
|
||||
* @param array<int|string,mixed> $input row index => uid, from the form
|
||||
*/
|
||||
private function saveAliases(string $modelKey, array $csv, array $mapping, string $identity, array $input): void
|
||||
{
|
||||
$aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? [];
|
||||
$changed = 0;
|
||||
foreach ($input as $rowIndex => $selectedUid) {
|
||||
$raw = $csv['rows'][(int)$rowIndex] ?? null;
|
||||
if ($raw === null) {
|
||||
continue;
|
||||
}
|
||||
$payload = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$payload[$field] = (string)($raw[$column] ?? '');
|
||||
}
|
||||
$key = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$uid = (int)$selectedUid;
|
||||
$current = (int)($aliases[$key] ?? 0);
|
||||
if ($uid > 0 && $uid !== $current) {
|
||||
$aliases[$key] = $uid;
|
||||
$changed++;
|
||||
} elseif ($uid === 0 && $current > 0) {
|
||||
unset($aliases[$key]);
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
$this->mappingRepository->saveAliases($modelKey, $aliases);
|
||||
$this->flash(
|
||||
sprintf('%d record link(s) changed, %d stored for this model in total.', $changed, count($aliases)),
|
||||
'Record links saved',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apply
|
||||
|
||||
Reference in new issue
Block a user