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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
||||||
|
|
||||||
|
private final PersonService personService;
|
||||||
|
|
||||||
|
public int load(File artifact) {
|
||||||
|
List<CanonicalSheetReader.Row> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PersonUpsertCommand> 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<PersonUpsertCommand> 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<String, String> row(String personId, String lastName, String firstName,
|
||||||
|
String maidenName, String notes, String provisional) {
|
||||||
|
Map<String, String> 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<String, String>... rows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("canonical-persons.xlsx");
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user