diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 0608639b..e7a9ea1f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -301,8 +301,11 @@ public class PersonService { } private Person fromCanonical(PersonUpsertCommand cmd) { - DatePrecisionPair birth = yearPair(cmd.birthYear()); - DatePrecisionPair death = yearPair(cmd.deathYear()); + DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN); + LifeDates dates = degradeIfConflicting( + yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef()); + DatePrecisionPair birth = dates.birth(); + DatePrecisionPair death = dates.death(); Person person = personRepository.save(Person.builder() .sourceRef(cmd.sourceRef()) .firstName(blankToNull(cmd.firstName())) @@ -334,14 +337,16 @@ public class PersonService { existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); - DatePrecisionPair birth = preferHumanDate( - existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()); - existing.setBirthDate(birth.date()); - existing.setBirthDatePrecision(birth.precision()); - DatePrecisionPair death = preferHumanDate( - existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()); - existing.setDeathDate(death.date()); - existing.setDeathDatePrecision(death.precision()); + LifeDates dates = degradeIfConflicting( + preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()), + preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()), + new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()), + new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()), + cmd.sourceRef()); + existing.setBirthDate(dates.birth().date()); + existing.setBirthDatePrecision(dates.birth().precision()); + existing.setDeathDate(dates.death().date()); + existing.setDeathDatePrecision(dates.death().precision()); existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation())); if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { existing.setPersonType(cmd.personType()); @@ -371,6 +376,26 @@ public class PersonService { // Date + precision travel as one value so they can never go out of sync (ADR-039). record DatePrecisionPair(LocalDate date, DatePrecision precision) {} + record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {} + + // The canonical path skips validateLifeDates (the form-only guard), so a conflicting + // resolved pair would hit chk_person_birth_before_death at flush time and abort the + // whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the + // batch): keep the person's stored life dates — empty for a new person — and drop the + // conflicting canonical refresh. A hand-entered side is preserved by construction, + // since preferHumanDate returned it verbatim and it equals the stored value; two + // stored values can never conflict with each other (they already satisfied the CHECK). + static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death, + DatePrecisionPair existingBirth, DatePrecisionPair existingDeath, + String sourceRef) { + if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) { + return new LifeDates(birth, death); + } + log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values", + sourceRef, birth.date(), death.date()); + return new LifeDates(existingBirth, existingDeath); + } + // preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than // the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a // YEAR-precision or absent date is refreshed from the canonical year. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java index b14b2691..198c3ce6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java @@ -297,4 +297,70 @@ class PersonImportUpsertTest { assertThat(result.getGeneration()).isEqualTo(3); } + + // ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ── + // (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001) + + @Test + void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() { + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .birthYear(1950).deathYear(1949) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isNull(); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + } + + @Test + void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() { + // A human entered an exact birthday; the spreadsheet's death year lies before it. + // The hand-entered side must survive, the conflicting canonical refresh is dropped. + Person handDated = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .deathYear(1949) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + } + + @Test + void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() { + Person existing = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR) + .deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).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").lastName("Cram") + .birthYear(1990).deathYear(1985) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR); + } }