fix(import): degrade gracefully when canonical life dates conflict

The canonical upsert path skips validateLifeDates, so a spreadsheet row
with birth_year > death_year - or a preserved hand-entered birth date
conflicting with a canonical death year - violated the V76 CHECK
constraint at flush time and aborted the whole import batch with a raw
500. Resolve the pairs first and, on conflict, keep the person's stored
life dates (empty for a new person), drop the canonical refresh, and log
a WARN with the sourceRef (REQ-IMP-001: never abort the batch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 19:34:28 +02:00
committed by marcel
parent e712477d2b
commit fe4e2d97d0
2 changed files with 101 additions and 10 deletions

View File

@@ -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.

View File

@@ -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);
}
}