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:
@@ -32,6 +32,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
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
|
// Exact first+last name match, used for filename-based sender lookup
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@Transactional
|
||||||
public Person createPerson(String firstName, String lastName, String alias) {
|
public Person createPerson(String firstName, String lastName, String alias) {
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user