new ID) * - Per-entity import via EntityImportService * * Part of Issue #151. */ class BundleImportService { private EntityImportService $entityImportService; private EntityExportService $exportService; private LoggerInterface $logger; /** * Entity dependency graph for topological sort. * Key = entity type, Value = array of entity types it depends on. */ private const DEPENDENCY_GRAPH = [ 'stufen' => [], 'familien' => [], 'mitglieder' => ['stufen', 'familien'], 'adressen' => ['mitglieder'], 'telefonnummern' => ['mitglieder'], 'emails' => ['mitglieder'], 'stufenverlauf' => ['mitglieder', 'stufen'], 'beitragsregeln' => [], 'beitraege' => ['mitglieder'], 'lager' => [], 'lagerteilnehmer' => ['lager', 'mitglieder'], 'verletzungen' => ['mitglieder', 'lager'], 'gespeicherte_abfragen' => [], ]; /** * Filename -> entity type mapping (from EntityExportService::ENTITY_TYPES). */ private array $filenameMap = []; public function __construct( EntityImportService $entityImportService, EntityExportService $exportService, LoggerInterface $logger ) { $this->entityImportService = $entityImportService; $this->exportService = $exportService; $this->logger = $logger; // Build filename lookup foreach (EntityExportService::ENTITY_TYPES as $type => $meta) { $this->filenameMap[strtolower($meta['filename'])] = $type; } } /** * Analyze a ZIP file: extract CSVs, detect entity types, determine import order. * * @param string $zipContent Raw ZIP binary content * @return array{entities: array[], importOrder: string[], unknown: string[]} */ public function analyzeBundle(string $zipContent): array { $csvFiles = $this->extractCsvFiles($zipContent); $entities = []; $unknown = []; foreach ($csvFiles as $filename => $content) { $type = $this->detectTypeByFilename($filename); if ($type === null) { // Fall back to header-based detection try { $parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8'); $detection = $this->entityImportService->detectEntityType($parsed['columns']); $type = $detection['type']; } catch (\Exception $e) { // Cannot parse this file $type = null; } } if ($type !== null) { // Count rows try { $parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8'); $rowCount = $parsed['totalRows']; } catch (\Exception $e) { $rowCount = 0; } $entities[] = [ 'filename' => $filename, 'type' => $type, 'label' => EntityExportService::ENTITY_TYPES[$type]['label'] ?? $type, 'rowCount' => $rowCount, ]; } else { $unknown[] = $filename; } } // Determine import order based on dependency graph $detectedTypes = array_map(fn($e) => $e['type'], $entities); $importOrder = $this->topologicalSort($detectedTypes); // Sort entities array by import order $orderIndex = array_flip($importOrder); usort($entities, function ($a, $b) use ($orderIndex) { $ia = $orderIndex[$a['type']] ?? PHP_INT_MAX; $ib = $orderIndex[$b['type']] ?? PHP_INT_MAX; return $ia <=> $ib; }); return [ 'entities' => $entities, 'importOrder' => $importOrder, 'unknown' => $unknown, ]; } /** * Execute bundle import: import all entities in dependency order with ID remapping. * * @param string $zipContent Raw ZIP binary content * @param array $entityOverrides Optional type overrides: {filename: type} * @return array{results: array[], idMap: array} */ public function executeBundle(string $zipContent, array $entityOverrides = [], array $corrections = []): array { $csvFiles = $this->extractCsvFiles($zipContent); $analysis = $this->analyzeBundle($zipContent); // Apply overrides foreach ($analysis['entities'] as &$entity) { if (isset($entityOverrides[$entity['filename']])) { $entity['type'] = $entityOverrides[$entity['filename']]; } } // ID remapping table: type => {oldId => newId} $idMap = []; $results = []; foreach ($analysis['importOrder'] as $type) { // Find the entity entry for this type $entityEntry = null; foreach ($analysis['entities'] as $e) { if ($e['type'] === $type) { $entityEntry = $e; break; } } if ($entityEntry === null) { continue; // This type is not in the ZIP } $filename = $entityEntry['filename']; $content = $csvFiles[$filename] ?? null; if ($content === null) { $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], 'status' => 'error', 'error' => 'CSV-Datei nicht gefunden', 'created' => 0, 'skipped' => 0, 'errors' => 1, ]; continue; } try { // Apply ID remapping to the CSV content before import $remappedContent = $this->applyIdRemapping($type, $content, $idMap); // Parse, auto-map, and execute $parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8'); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); $entityCorrections = $corrections[$type] ?? []; $result = $this->entityImportService->execute( $type, $remappedContent, $mapping, ';', 'UTF-8', false, $entityCorrections ); // Collect ID mappings from created entities $this->collectIdMappings($type, $content, $result, $idMap); $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], 'status' => 'success', 'created' => $result['created'], 'skipped' => $result['skipped'], 'errors' => $result['errors'], 'details' => $result['details'], ]; } catch (\Exception $e) { $this->logger->error('Bundle import failed for entity type', [ 'type' => $type, 'exception' => $e, 'app' => 'mitgliederverwaltung', ]); $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], 'status' => 'error', 'error' => $e->getMessage(), 'created' => 0, 'skipped' => 0, 'errors' => 1, ]; } } return [ 'results' => $results, 'idMap' => $idMap, ]; } /** * Preview bundle import (dry-run for all entities). * * @param string $zipContent Raw ZIP binary content * @param array $corrections Per-entity corrections: { entityType: { rowNum: { fieldKey: value } } } * @return array{results: array[]} */ public function previewBundle(string $zipContent, array $corrections = []): array { $csvFiles = $this->extractCsvFiles($zipContent); $analysis = $this->analyzeBundle($zipContent); $results = []; foreach ($analysis['importOrder'] as $type) { $entityEntry = null; foreach ($analysis['entities'] as $e) { if ($e['type'] === $type) { $entityEntry = $e; break; } } if ($entityEntry === null) continue; $filename = $entityEntry['filename']; $content = $csvFiles[$filename] ?? null; if ($content === null) continue; try { $parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8'); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); $entityCorrections = $corrections[$type] ?? []; $preview = $this->entityImportService->preview( $type, $content, $mapping, ';', 'UTF-8', false, $entityCorrections ); // Include target fields so the frontend can render editable inputs $schema = $this->entityImportService->getImportSchema($type); $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], 'summary' => $preview['summary'], 'fkWarnings' => $preview['fkWarnings'], 'errorCount' => count($preview['errors']), 'errors' => array_slice($preview['errors'], 0, 10), 'targetFields' => $schema['targetFields'], ]; } catch (\Exception $e) { $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], 'summary' => ['total' => 0, 'toCreate' => 0, 'duplicates' => 0, 'errors' => 1], 'fkWarnings' => [], 'errorCount' => 1, 'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]], 'targetFields' => [], ]; } } return ['results' => $results]; } // ── ZIP extraction ───────────────────────────────────────────── /** * Extract all CSV files from a ZIP archive. * * @param string $zipContent Raw ZIP binary * @return array Filename => CSV content */ private function extractCsvFiles(string $zipContent): array { if (!class_exists(\ZipArchive::class)) { throw new \RuntimeException('ZipArchive ist nicht verfuegbar.'); } $tmpFile = tempnam(sys_get_temp_dir(), 'mv_import_'); if ($tmpFile === false) { throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.'); } // Restrict permissions to owner-only before writing content chmod($tmpFile, 0600); try { file_put_contents($tmpFile, $zipContent); $zip = new \ZipArchive(); $result = $zip->open($tmpFile); if ($result !== true) { throw new ValidationException('ZIP-Datei konnte nicht geoeffnet werden. Fehlercode: ' . $result); } $csvFiles = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name === false) { continue; } // Reject path traversal: entries containing '..' or absolute paths if (str_contains($name, '..') || str_starts_with($name, '/') || str_starts_with($name, '\\')) { $this->logger->warning('Skipping ZIP entry with suspicious path', [ 'entry' => $name, 'app' => 'mitgliederverwaltung', ]); continue; } $baseName = basename($name); // Only include CSV files (skip directories, hidden files) if ( !str_starts_with($baseName, '.') && !str_starts_with($baseName, '__') && (str_ends_with(strtolower($baseName), '.csv') || str_ends_with(strtolower($baseName), '.txt')) ) { $content = $zip->getFromIndex($i); if ($content !== false) { $csvFiles[$baseName] = $content; } } } $zip->close(); if (empty($csvFiles)) { throw new ValidationException('ZIP-Archiv enthaelt keine CSV-Dateien.'); } return $csvFiles; } finally { if (file_exists($tmpFile)) { unlink($tmpFile); } } } // ── Entity type detection ────────────────────────────────────── /** * Detect entity type by filename matching against known export filenames. */ private function detectTypeByFilename(string $filename): ?string { return $this->filenameMap[strtolower($filename)] ?? null; } // ── Topological sort ─────────────────────────────────────────── /** * Topological sort of entity types based on dependency graph. * Only includes types present in the $types array. * * @param string[] $types Entity types to sort * @return string[] Sorted import order */ private function topologicalSort(array $types): array { $typeSet = array_flip($types); $sorted = []; $visited = []; foreach ($types as $type) { $this->visit($type, $typeSet, $visited, $sorted); } return $sorted; } private function visit(string $type, array $typeSet, array &$visited, array &$sorted): void { if (isset($visited[$type])) { return; } $visited[$type] = true; $deps = self::DEPENDENCY_GRAPH[$type] ?? []; foreach ($deps as $dep) { if (isset($typeSet[$dep])) { $this->visit($dep, $typeSet, $visited, $sorted); } } $sorted[] = $type; } // ── ID remapping ─────────────────────────────────────────────── /** * Apply ID remapping to CSV content based on previously imported entities. * * Replaces old IDs in FK columns with new IDs from the idMap. */ private function applyIdRemapping(string $type, string $content, array $idMap): string { // Determine which columns need remapping for this type $fkColumns = $this->getFkColumnsForRemapping($type); if (empty($fkColumns)) { return $content; } // Parse the CSV $lines = explode("\n", str_replace("\r\n", "\n", $content)); if (count($lines) < 2) { return $content; } // Check for BOM $bom = ''; if (substr($lines[0], 0, 3) === "\xEF\xBB\xBF") { $bom = "\xEF\xBB\xBF"; $lines[0] = substr($lines[0], 3); } // Parse header to find column indices $headers = str_getcsv($lines[0], ';'); $headers = array_map('strtolower', array_map('trim', $headers)); $remapIndices = []; foreach ($fkColumns as $colInfo) { $headerIdx = array_search(strtolower($colInfo['header']), $headers); if ($headerIdx !== false) { $remapIndices[$headerIdx] = $colInfo['source_type']; } } if (empty($remapIndices)) { return $content; } // Rebuild the CSV with remapped IDs $output = $bom . $lines[0] . "\r\n"; for ($i = 1; $i < count($lines); $i++) { $line = trim($lines[$i]); if ($line === '') continue; $row = str_getcsv($line, ';'); foreach ($remapIndices as $colIdx => $sourceType) { if (isset($row[$colIdx]) && $row[$colIdx] !== '' && isset($idMap[$sourceType])) { $oldId = $row[$colIdx]; if (isset($idMap[$sourceType][$oldId])) { $row[$colIdx] = (string)$idMap[$sourceType][$oldId]; } } } // Re-encode as CSV line $escaped = array_map(function ($field) { if (str_contains($field, ';') || str_contains($field, '"') || str_contains($field, "\n")) { return '"' . str_replace('"', '""', $field) . '"'; } return $field; }, $row); $output .= implode(';', $escaped) . "\r\n"; } return $output; } /** * Get FK columns that need ID remapping for a given entity type. * * @return array[] Each: {header, source_type} */ private function getFkColumnsForRemapping(string $type): array { return match ($type) { 'mitglieder' => [ ['header' => 'stufe-id', 'source_type' => 'stufen'], ['header' => 'familien-id', 'source_type' => 'familien'], ], 'adressen', 'telefonnummern', 'emails' => [ ['header' => 'mitglied-id', 'source_type' => 'mitglieder'], ], 'stufenverlauf' => [ ['header' => 'mitglied-id', 'source_type' => 'mitglieder'], ['header' => 'stufe-id', 'source_type' => 'stufen'], ], 'beitraege' => [ ['header' => 'mitglied-id', 'source_type' => 'mitglieder'], ], 'lagerteilnehmer' => [ ['header' => 'lager-id', 'source_type' => 'lager'], ['header' => 'mitglied-id', 'source_type' => 'mitglieder'], ], 'verletzungen' => [ ['header' => 'mitglied-id', 'source_type' => 'mitglieder'], ['header' => 'lager-id', 'source_type' => 'lager'], ], default => [], }; } /** * Collect ID mappings from import results. * Maps old CSV IDs to new database IDs. */ private function collectIdMappings(string $type, string $originalContent, array $result, array &$idMap): void { if (!isset($idMap[$type])) { $idMap[$type] = []; } // Parse original content to get old IDs $lines = explode("\n", str_replace("\r\n", "\n", $originalContent)); if (count($lines) < 2) return; // Remove BOM if (substr($lines[0], 0, 3) === "\xEF\xBB\xBF") { $lines[0] = substr($lines[0], 3); } $headers = str_getcsv($lines[0], ';'); $headers = array_map('strtolower', array_map('trim', $headers)); $idColIdx = array_search('id', $headers); if ($idColIdx === false) return; // Build row-to-oldId mapping $oldIds = []; for ($i = 1; $i < count($lines); $i++) { $line = trim($lines[$i]); if ($line === '') continue; $row = str_getcsv($line, ';'); if (isset($row[$idColIdx]) && $row[$idColIdx] !== '') { $oldIds[$i - 1] = $row[$idColIdx]; // 0-indexed row } } // Match with created results $createdIdx = 0; foreach ($result['details'] as $detail) { if ($detail['status'] === 'created' && isset($detail['id'])) { $rowNum = $detail['row'] - 2; // Convert to 0-indexed if (isset($oldIds[$rowNum])) { $idMap[$type][$oldIds[$rowNum]] = $detail['id']; } } } } }