diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java index d6453e19..e09aaa76 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.importing; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.*; @@ -31,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -53,9 +56,33 @@ public class MassImportService { public enum State { IDLE, RUNNING, DONE, FAILED } - public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {} + public record SkippedFile( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason + ) {} - private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); + public record ImportStatus( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode, + @JsonIgnore String message, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List skippedFiles, + LocalDateTime startedAt + ) { + // Note: @Schema on a record accessor method is not picked up by SpringDoc; the + // "skipped" count is a computed convenience field derived from skippedFiles.size(). + @JsonProperty("skipped") + public int skipped() { return skippedFiles.size(); } + + /** Defensive-copy constructor — callers cannot mutate the stored list after construction. */ + public ImportStatus { + skippedFiles = List.copyOf(skippedFiles); + } + } + + record ProcessResult(int processed, List skippedFiles) {} + + private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); public ImportStatus getStatus() { return currentStatus; @@ -117,22 +144,22 @@ public class MassImportService { if (currentStatus.state() == State.RUNNING) { throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); } - currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now()); + currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now()); try { File spreadsheet = findSpreadsheetFile(); log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath()); - int processed = processRows(readSpreadsheet(spreadsheet)); + ProcessResult result = processRows(readSpreadsheet(spreadsheet)); currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE", - "Import abgeschlossen. " + processed + " Dokumente verarbeitet.", - processed, currentStatus.startedAt()); + "Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.", + result.processed(), result.skippedFiles(), currentStatus.startedAt()); } catch (NoSpreadsheetException e) { log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e); currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET", - "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); + "Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt()); } catch (Exception e) { log.error("Massenimport fehlgeschlagen", e); currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", - "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); + "Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt()); } } @@ -254,8 +281,10 @@ public class MassImportService { // --- Import logic (works on neutral List rows) --- - private int processRows(List> rows) { - int count = 0; + private ProcessResult processRows(List> rows) { + int processed = 0; + List skippedFiles = new ArrayList<>(); + for (int i = 1; i < rows.size(); i++) { // skip header row List cells = rows.get(i); String index = getCell(cells, colIndex); @@ -266,18 +295,58 @@ public class MassImportService { if (fileOnDisk.isEmpty()) { log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); } - importSingleDocument(cells, fileOnDisk, filename, index); - count++; + + if (fileOnDisk.isPresent()) { + try { + if (!isPdfMagicBytes(fileOnDisk.get())) { + log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename); + skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE")); + continue; + } + } catch (IOException e) { + log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e); + skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR")); + continue; + } + } + + Optional skipReason = importSingleDocument(cells, fileOnDisk, filename, index); + if (skipReason.isPresent()) { + skippedFiles.add(new SkippedFile(filename, skipReason.get())); + } else { + processed++; + } } - return count; + return new ProcessResult(processed, skippedFiles); } + // package-private: Mockito spy in tests can override to inject IOException + InputStream openFileStream(File file) throws IOException { + return new FileInputStream(file); + } + + private boolean isPdfMagicBytes(File file) throws IOException { + try (InputStream is = openFileStream(file)) { + byte[] header = is.readNBytes(4); + return header.length == 4 + && header[0] == 0x25 // % + && header[1] == 0x50 // P + && header[2] == 0x44 // D + && header[3] == 0x46; // F + } + } + + /** + * Imports a single document row. + * + * @return empty Optional on success; an Optional containing the skip reason on failure/skip. + */ @Transactional - protected void importSingleDocument(List cells, Optional file, String originalFilename, String index) { + protected Optional importSingleDocument(List cells, Optional file, String originalFilename, String index) { Optional existing = documentService.findByOriginalFilename(originalFilename); if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { log.info("Dokument {} existiert bereits, überspringe.", originalFilename); - return; + return Optional.of("ALREADY_EXISTS"); } String archiveBox = getCell(cells, colBox); @@ -313,7 +382,7 @@ public class MassImportService { status = DocumentStatus.UPLOADED; } catch (Exception e) { log.error("S3 Upload Fehler für {}", file.get().getName(), e); - return; + return Optional.of("S3_UPLOAD_FAILED"); } } @@ -355,6 +424,7 @@ public class MassImportService { thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); } log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); + return Optional.empty(); } // --- Helpers --- diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java index dcd7c707..126a6d74 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java @@ -135,7 +135,7 @@ class MassImportServiceTest { @Test void runImportAsync_throwsConflict_whenAlreadyRunning() { MassImportService.ImportStatus running = new MassImportService.ImportStatus( - MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now()); + MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now()); ReflectionTestUtils.setField(service, "currentStatus", running); assertThatThrownBy(() -> service.runImportAsync()) @@ -154,9 +154,76 @@ class MassImportServiceTest { .build(); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing)); - service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); + Optional result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); verify(documentService, never()).save(any()); + assertThat(result).isPresent().contains("ALREADY_EXISTS"); + } + + // ─── importSingleDocument — already-exists guard fires before file I/O ───── + + @Test + void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception { + // Document already exists with status UPLOADED (not PLACEHOLDER). + // A physical PDF file is also present on disk (valid magic bytes). + // Expected: ALREADY_EXISTS is returned and no S3 upload is attempted — + // the guard fires before any file I/O, so no partial processing occurs. + Document existing = Document.builder() + .id(UUID.randomUUID()) + .originalFilename("present.pdf") + .status(DocumentStatus.UPLOADED) + .build(); + when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing)); + + Path physicalFile = tempDir.resolve("present.pdf"); + byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF- + Files.write(physicalFile, pdfHeader); + + Optional result = service.importSingleDocument( + minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present"); + + assertThat(result).isPresent().contains("ALREADY_EXISTS"); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + verify(documentService, never()).save(any()); + } + + // ─── importSingleDocument — S3 failure surfaced in skippedFiles ────────── + + @Test + void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception { + byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF- + Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader); + buildMinimalImportXlsx(tempDir, "upload_fail.pdf"); + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty()); + doThrow(new RuntimeException("S3 unavailable")) + .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + service.runImportAsync(); + + assertThat(service.getStatus().skipped()).isEqualTo(1); + assertThat(service.getStatus().skippedFiles()) + .extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason) + .containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED")); + } + + @Test + void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception { + buildMinimalImportXlsx(tempDir, "existing.pdf"); + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + Document existing = Document.builder() + .id(UUID.randomUUID()) + .originalFilename("existing.pdf") + .status(DocumentStatus.UPLOADED) + .build(); + when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing)); + + service.runImportAsync(); + + assertThat(service.getStatus().skipped()).isEqualTo(1); + assertThat(service.getStatus().skippedFiles()) + .extracting(MassImportService.SkippedFile::reason) + .containsExactly("ALREADY_EXISTS"); } // ─── importSingleDocument — create new document (metadata only) ─────────── @@ -208,7 +275,7 @@ class MassImportServiceTest { } @Test - void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception { + void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception { Path tempFile = tempDir.resolve("fail.pdf"); Files.write(tempFile, "data".getBytes()); @@ -216,10 +283,11 @@ class MassImportServiceTest { doThrow(new RuntimeException("S3 error")) .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - service.importSingleDocument( + Optional result = service.importSingleDocument( minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail"); verify(documentService, never()).save(any()); + assertThat(result).isPresent().contains("S3_UPLOAD_FAILED"); } // ─── importSingleDocument — sender handling ─────────────────────────────── @@ -325,8 +393,8 @@ class MassImportServiceTest { @Test void processRows_returnsZero_whenOnlyHeaderRow() { List> rows = List.of(List.of("header", "col1")); - Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); - assertThat(result).isEqualTo(0); + MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); + assertThat(result.processed()).isEqualTo(0); } @Test @@ -335,8 +403,8 @@ class MassImportServiceTest { List.of("header"), minimalCells("") // blank index ); - Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); - assertThat(result).isEqualTo(0); + MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); + assertThat(result.processed()).isEqualTo(0); verify(documentService, never()).findByOriginalFilename(any()); } @@ -349,9 +417,9 @@ class MassImportServiceTest { List.of("header"), minimalCells("doc001") // no dot → appends ".pdf" ); - Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); + MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); - assertThat(result).isEqualTo(1); + assertThat(result.processed()).isEqualTo(1); verify(documentService).findByOriginalFilename("doc001.pdf"); } @@ -364,9 +432,9 @@ class MassImportServiceTest { List.of("header"), minimalCells("doc002.pdf") // has dot → used as-is ); - Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); + MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); - assertThat(result).isEqualTo(1); + assertThat(result.processed()).isEqualTo(1); verify(documentService).findByOriginalFilename("doc002.pdf"); } @@ -525,6 +593,67 @@ class MassImportServiceTest { assertThat(result).isEqualTo("hello"); } + // ─── PDF magic byte validation regression ───────────────────────────────── + + @Test + void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception { + setupOneValidOneFakeImport(tempDir); + + service.runImportAsync(); + + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception { + setupOneValidOneFakeImport(tempDir); + + service.runImportAsync(); + + assertThat(service.getStatus().skipped()).isEqualTo(1); + } + + @Test + void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception { + setupOneValidOneFakeImport(tempDir); + + service.runImportAsync(); + + assertThat(service.getStatus().skippedFiles()) + .extracting(MassImportService.SkippedFile::filename) + .contains("fake.pdf"); + } + + @Test + void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception { + Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes + buildMinimalImportXlsx(tempDir, "tiny.pdf"); + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty()); + + service.runImportAsync(); + + assertThat(service.getStatus().skipped()).isEqualTo(1); + } + + @Test + void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("unreadable.pdf"), "some content"); + buildMinimalImportXlsx(tempDir, "unreadable.pdf"); + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty()); + + MassImportService spyService = spy(service); + doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class)); + + spyService.runImportAsync(); + + assertThat(spyService.getStatus().skipped()).isEqualTo(1); + assertThat(spyService.getStatus().skippedFiles()) + .extracting(MassImportService.SkippedFile::reason) + .containsExactly("FILE_READ_ERROR"); + } + // ─── readOds — XXE security regression ─────────────────────────────────── // Security regression — do not remove. @@ -621,4 +750,28 @@ class MassImportServiceTest { } return destination.toFile(); } + + private void setupOneValidOneFakeImport(Path tempDir) throws Exception { + byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF- + Files.write(tempDir.resolve("real.pdf"), pdfHeader); + Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf"); + buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf"); + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception { + Path xlsx = dir.resolve("import.xlsx"); + try (XSSFWorkbook wb = new XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1"); + sheet.createRow(0).createCell(0).setCellValue("Index"); + for (int i = 0; i < filenames.length; i++) { + sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]); + } + try (OutputStream out = Files.newOutputStream(xlsx)) { + wb.write(out); + } + } + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java index 533aa7b3..9b7f84b2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java @@ -46,7 +46,7 @@ class AdminControllerTest { @WithMockUser(authorities = "ADMIN") void importStatus_returns200_withStatusCode_whenAdmin() throws Exception { MassImportService.ImportStatus status = new MassImportService.ImportStatus( - MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); + MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); when(massImportService.getStatus()).thenReturn(status); mockMvc.perform(get("/api/admin/import-status")) @@ -60,7 +60,7 @@ class AdminControllerTest { @WithMockUser(authorities = "ADMIN") void importStatus_messageField_notPresentInApiResponse() throws Exception { MassImportService.ImportStatus status = new MassImportService.ImportStatus( - MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); + MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); when(massImportService.getStatus()).thenReturn(status); mockMvc.perform(get("/api/admin/import-status")) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 55ffca93..21addb4c 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -57,6 +57,10 @@ _See also [Annotation](#annotation-documentannotation)._ **Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently). +**SkippedFile** (`MassImportService.SkippedFile`) — a file that was presented for import but not processed, recorded with a `filename` and a `reason` code. Possible reasons: `INVALID_PDF_SIGNATURE` (magic-byte validation failed), `S3_UPLOAD_FAILED` (file upload to MinIO/S3 threw an exception), `FILE_READ_ERROR` (the file could not be opened for reading), or `ALREADY_EXISTS` (a document with the same filename already exists in the archive with a status other than `PLACEHOLDER`). + +**skipped count** — the total number of `SkippedFile` entries accumulated during a single import run (`ImportStatus.skipped()`). Shown in the amber warning section of the Import Status Card in the admin UI; a value of zero suppresses the section entirely. + **Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists. _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._ diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 03d63f48..92b405b8 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -350,6 +350,11 @@ "admin_system_import_status_running": "Import läuft…", "admin_system_import_status_done": "Import abgeschlossen", "admin_system_import_status_done_label": "Dokumente verarbeitet", + "admin_system_import_skipped_label": "übersprungen", + "import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur", + "import_reason_file_read_error": "Fehler beim Lesen der Datei", + "import_reason_s3_upload_failed": "Upload-Fehler (S3)", + "import_reason_already_exists": "Bereits importiert", "admin_system_import_status_failed": "Import fehlgeschlagen", "admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.", "admin_system_import_failed_internal": "Interner Fehler beim Import.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d52ddbf5..cf8b3f01 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -350,6 +350,11 @@ "admin_system_import_status_running": "Import running…", "admin_system_import_status_done": "Import complete", "admin_system_import_status_done_label": "Documents processed", + "admin_system_import_skipped_label": "skipped", + "import_reason_invalid_pdf_signature": "Invalid PDF signature", + "import_reason_file_read_error": "File read error", + "import_reason_s3_upload_failed": "Upload error (S3)", + "import_reason_already_exists": "Already imported", "admin_system_import_status_failed": "Import failed", "admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.", "admin_system_import_failed_internal": "Import failed due to an internal error.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 177dcdff..d0423397 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -350,6 +350,11 @@ "admin_system_import_status_running": "Importación en curso…", "admin_system_import_status_done": "Importación completada", "admin_system_import_status_done_label": "Documentos procesados", + "admin_system_import_skipped_label": "omitidos", + "import_reason_invalid_pdf_signature": "Firma PDF no válida", + "import_reason_file_read_error": "Error al leer el archivo", + "import_reason_s3_upload_failed": "Error de carga (S3)", + "import_reason_already_exists": "Ya importado", "admin_system_import_status_failed": "Importación fallida", "admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.", "admin_system_import_failed_internal": "Error interno durante la importación.", diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte index 01b565c4..bb9bce72 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte @@ -15,6 +15,14 @@ const failureMessage = $derived( ? m.admin_system_import_failed_no_spreadsheet() : m.admin_system_import_failed_internal() ); + +function reasonLabel(code: string): string { + if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature(); + if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error(); + if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed(); + if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists(); + return code; +}
@@ -48,6 +56,41 @@ const failureMessage = $derived(

{m.admin_system_import_status_done()}

+
+ {#if importStatus.skipped > 0} +
+ + +
+ {importStatus.skipped} + + {m.admin_system_import_skipped_label()} + +
+
+
    + {#each importStatus.skippedFiles as skipped (skipped.filename)} +
  • + {skipped.filename} — {reasonLabel(skipped.reason)} +
  • + {/each} +
+
+ {/if} +