From f6bfb8f030d5dbc337c697189a789fbec7435f82 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 10:27:12 +0200 Subject: [PATCH] feat(importing): add PersonRegisterImporter loader Second canonical loader. Reads canonical-persons.xlsx by header name and upserts each register person via PersonService.upsertBySourceRef keyed on the normalizer person_id. provisional is driven by the sheet's clean value; Boolean.parseBoolean handles the capitalised Python "True"/"False". ISO birth/death dates are reduced to the year the Person entity stores. Refs #669 Co-Authored-By: Claude Opus 4.7 --- .../importing/PersonRegisterImporter.java | 69 ++++++++++ .../importing/PersonRegisterImporterTest.java | 130 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/importing/PersonRegisterImporter.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/importing/PersonRegisterImporterTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonRegisterImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonRegisterImporter.java new file mode 100644 index 00000000..edad55d2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonRegisterImporter.java @@ -0,0 +1,69 @@ +package org.raddatz.familienarchiv.importing; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.person.PersonType; +import org.raddatz.familienarchiv.person.PersonUpsertCommand; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.List; + +/** + * Loads {@code canonical-persons.xlsx} (the register) into the person domain via + * {@link PersonService}, upserting each person by the normalizer {@code person_id} + * (source_ref). Register persons are confident identities, so {@code provisional} is + * driven by the sheet's already-clean value (normally {@code False}). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PersonRegisterImporter { + + static final List REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional"); + + private final PersonService personService; + + public int load(File artifact) { + List rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS); + int processed = 0; + for (CanonicalSheetReader.Row row : rows) { + String personId = row.get("person_id"); + if (personId.isBlank()) continue; + personService.upsertBySourceRef(toCommand(row, personId)); + processed++; + } + log.info("Imported {} register persons from {}", processed, artifact.getName()); + return processed; + } + + private PersonUpsertCommand toCommand(CanonicalSheetReader.Row row, String personId) { + return PersonUpsertCommand.builder() + .sourceRef(personId) + .lastName(blankToNull(row.get("last_name"))) + .firstName(blankToNull(row.get("first_name"))) + .maidenName(blankToNull(row.get("maiden_name"))) + .notes(blankToNull(row.get("notes"))) + .birthYear(yearOf(row.get("birth_date"))) + .deathYear(yearOf(row.get("death_date"))) + .personType(PersonType.PERSON) + .provisional(Boolean.parseBoolean(row.get("provisional"))) + .build(); + } + + private static Integer yearOf(String isoDate) { + if (isoDate == null || isoDate.isBlank()) return null; + try { + return LocalDate.parse(isoDate.trim()).getYear(); + } catch (DateTimeParseException e) { + return null; + } + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonRegisterImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonRegisterImporterTest.java new file mode 100644 index 00000000..af5740c0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonRegisterImporterTest.java @@ -0,0 +1,130 @@ +package org.raddatz.familienarchiv.importing; + +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.person.PersonUpsertCommand; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PersonRegisterImporterTest { + + @Test + void load_upsertsPersonBySourceRef_withProvisionalFalse(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0))); + Path xlsx = writePersons(tempDir, row( + "allemeyer-elsgard", "Allemeyer", "Elsgard", "Wöhler", "Nichte von Herbert", "False")); + + new PersonRegisterImporter(personService).load(xlsx.toFile()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PersonUpsertCommand.class); + verify(personService).upsertBySourceRef(captor.capture()); + PersonUpsertCommand cmd = captor.getValue(); + assertThat(cmd.sourceRef()).isEqualTo("allemeyer-elsgard"); + assertThat(cmd.lastName()).isEqualTo("Allemeyer"); + assertThat(cmd.firstName()).isEqualTo("Elsgard"); + assertThat(cmd.maidenName()).isEqualTo("Wöhler"); + assertThat(cmd.notes()).isEqualTo("Nichte von Herbert"); + assertThat(cmd.provisional()).isFalse(); + } + + @Test + void load_parsesCapitalisedPythonBool_True(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0))); + Path xlsx = writePersons(tempDir, row( + "noise-geschirr", "Geschirr", "", "", "", "True")); + + new PersonRegisterImporter(personService).load(xlsx.toFile()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PersonUpsertCommand.class); + verify(personService).upsertBySourceRef(captor.capture()); + assertThat(captor.getValue().provisional()).isTrue(); + } + + @Test + void load_skipsRowWithBlankPersonId(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + Path xlsx = writePersons(tempDir, row("", "NoId", "", "", "", "False")); + + new PersonRegisterImporter(personService).load(xlsx.toFile()); + + verify(personService, times(0)).upsertBySourceRef(any()); + } + + @Test + void load_returnsCountOfProcessedRows(@TempDir Path tempDir) throws Exception { + PersonService personService = mock(PersonService.class); + when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0))); + Path xlsx = writePersons(tempDir, + row("a-one", "One", "A", "", "", "False"), + row("a-two", "Two", "B", "", "", "False")); + + int processed = new PersonRegisterImporter(personService).load(xlsx.toFile()); + + assertThat(processed).isEqualTo(2); + } + + private static Person personOf(PersonUpsertCommand cmd) { + return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()) + .firstName(cmd.firstName()).lastName(cmd.lastName()) + .provisional(cmd.provisional()).build(); + } + + private Map row(String personId, String lastName, String firstName, + String maidenName, String notes, String provisional) { + Map r = new LinkedHashMap<>(); + r.put("person_id", personId); + r.put("last_name", lastName); + r.put("first_name", firstName); + r.put("maiden_name", maidenName); + r.put("notes", notes); + r.put("provisional", provisional); + return r; + } + + @SafeVarargs + private Path writePersons(Path dir, Map... rows) throws Exception { + Path xlsx = dir.resolve("canonical-persons.xlsx"); + List headers = List.of("person_id", "last_name", "first_name", "maiden_name", "notes", "provisional"); + try (XSSFWorkbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet1"); + Row header = sheet.createRow(0); + for (int i = 0; i < headers.size(); i++) { + header.createCell(i).setCellValue(headers.get(i)); + } + for (int r = 0; r < rows.length; r++) { + Row row = sheet.createRow(r + 1); + for (int c = 0; c < headers.size(); c++) { + row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), "")); + } + } + try (OutputStream out = Files.newOutputStream(xlsx)) { + wb.write(out); + } + } + return xlsx; + } +}