feat(person): add upsertBySourceRef with human-edit-preserve precedence

Idempotent person upsert keyed on the normalizer person_id (source_ref),
for the Phase-3 canonical importer. Re-import precedence (Resolved
decision #1): a non-blank existing field is never overwritten, blank
fields are filled from canonical, and provisional is monotonic — once a
human confirms a person (false) it never reverts to true. New
importer-created persons carry provisional=true; register persons false.

Maiden name is stored as a MAIDEN_NAME PersonNameAlias, matching the
existing findOrCreateByAlias behaviour.

Refs #669

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 10:23:28 +02:00
parent aa6de48a71
commit 05dd824283
4 changed files with 221 additions and 0 deletions

View File

@@ -32,6 +32,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
Optional<Person> findBySourceRef(String sourceRef);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);

View File

@@ -115,6 +115,69 @@ public class PersonService {
});
}
/**
* Idempotent upsert keyed on {@code sourceRef} (the normalizer person_id) for the
* canonical importer (Phase 3, ADR-025). On first import the canonical fields are
* written verbatim. On re-import the human-edit-preserve precedence applies:
* a non-blank existing field is never overwritten, and {@code provisional} never
* flips back to true once a human has confirmed the person.
*/
@Transactional
public Person upsertBySourceRef(PersonUpsertCommand cmd) {
return personRepository.findBySourceRef(cmd.sourceRef())
.map(existing -> personRepository.save(mergeCanonical(existing, cmd)))
.orElseGet(() -> fromCanonical(cmd));
}
private Person fromCanonical(PersonUpsertCommand cmd) {
Person person = personRepository.save(Person.builder()
.sourceRef(cmd.sourceRef())
.firstName(blankToNull(cmd.firstName()))
.lastName(cmd.lastName())
.notes(blankToNull(cmd.notes()))
.birthYear(cmd.birthYear())
.deathYear(cmd.deathYear())
.familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
.provisional(cmd.provisional())
.build());
String maiden = blankToNull(cmd.maidenName());
if (maiden != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(maiden)
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
}
private Person mergeCanonical(Person existing, PersonUpsertCommand cmd) {
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
if (existing.getBirthYear() == null) existing.setBirthYear(cmd.birthYear());
if (existing.getDeathYear() == null) existing.setDeathYear(cmd.deathYear());
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType());
}
// provisional is monotonic: once a human confirms a person (false) it never reverts.
if (existing.isProvisional()) {
existing.setProvisional(cmd.provisional());
}
return existing;
}
private static String preferHuman(String existing, String canonical) {
return (existing == null || existing.isBlank()) ? blankToNull(canonical) : existing;
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
@Transactional
public Person createPerson(String firstName, String lastName, String alias) {
Person person = Person.builder()

View File

@@ -0,0 +1,24 @@
package org.raddatz.familienarchiv.person;
import lombok.Builder;
/**
* Importer → {@link PersonService} command for an idempotent upsert keyed on
* {@code sourceRef} (the normalizer's stable person_id). Carries only the canonical
* fields the importer owns; the service applies the human-edit-preserve precedence
* (see ADR-025): non-blank existing fields are never overwritten, and {@code provisional}
* never flips back to true once a human has confirmed a person.
*/
@Builder
public record PersonUpsertCommand(
String sourceRef,
String firstName,
String lastName,
String maidenName,
String notes,
Integer birthYear,
Integer deathYear,
boolean familyMember,
PersonType personType,
boolean provisional
) {}

View File

@@ -0,0 +1,131 @@
package org.raddatz.familienarchiv.person;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersonImportUpsertTest {
@Mock PersonRepository personRepository;
@Mock PersonNameAliasRepository aliasRepository;
@InjectMocks PersonService personService;
@Test
void upsertBySourceRef_insertsNewPerson_whenSourceRefUnknown() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getSourceRef()).isEqualTo("clara-cram");
assertThat(result.getFirstName()).isEqualTo("Clara");
assertThat(result.getLastName()).isEqualTo("Cram");
assertThat(result.isProvisional()).isFalse();
}
@Test
void upsertBySourceRef_updatesInPlace_whenSourceRefExists() {
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.firstName("Clara").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
.notes("Updated note").personType(PersonType.PERSON).provisional(false).build();
personService.upsertBySourceRef(cmd);
verify(personRepository).save(argThat(p -> p.getId().equals(existing.getId())));
verify(personRepository, never()).save(argThat(p -> p.getId() == null));
}
@Test
void upsertBySourceRef_preservesHumanEditedNonBlankFields() {
// A human renamed the maiden-name register person and added notes in-app.
Person humanEdited = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.firstName("Klara").lastName("Cram-Müller").notes("Verified by Marcel").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(humanEdited));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
.notes("Auto note").personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
// Human edits survive the re-import.
assertThat(result.getFirstName()).isEqualTo("Klara");
assertThat(result.getLastName()).isEqualTo("Cram-Müller");
assertThat(result.getNotes()).isEqualTo("Verified by Marcel");
}
@Test
void upsertBySourceRef_fillsOnlyBlankFields_onReimport() {
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.firstName("Clara").lastName("Cram").notes(null).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
.notes("Nichte von Herbert").personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
// Blank field gets filled by canonical value.
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
}
@Test
void upsertBySourceRef_neverFlipsProvisionalBackToTrue_onceHumanConfirmed() {
// A human confirmed this provisional importer-created person (provisional -> false).
Person confirmed = Person.builder()
.id(UUID.randomUUID()).sourceRef("schwester-hanni")
.firstName(null).lastName("Schwester Hanni").provisional(false).build();
when(personRepository.findBySourceRef("schwester-hanni")).thenReturn(Optional.of(confirmed));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("schwester-hanni").lastName("Schwester Hanni")
.personType(PersonType.PERSON).provisional(true).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.isProvisional()).isFalse();
}
@Test
void upsertBySourceRef_setsProvisionalTrue_forNewProvisionalPerson() {
when(personRepository.findBySourceRef("noise-geschirr")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("noise-geschirr").lastName("Tante Tüten")
.personType(PersonType.PERSON).provisional(true).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.isProvisional()).isTrue();
}
}