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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user