feat(person): store birth/death as LocalDate + DatePrecision
Entity swap mirroring Document.metaDatePrecision; PersonUpdateDTO takes date + precision; validateLifeDates (badRequest BIRTH_AFTER_DEATH / INVALID_DATE_PRECISION) replaces validateYears; preferHumanDate keeps DAY/MONTH/SEASON hand-entered dates on re-import and refreshes YEAR/UNKNOWN from the canonical year (ADR-025 extension); PersonUpsertCommand stays year-shaped. Native queries project EXTRACT(YEAR ...) so PersonSummaryDTO and PersonNodeDTO stay year-shaped, null-safe for undated persons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -49,8 +51,25 @@ public class Person {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
|
||||
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
|
||||
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
|
||||
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "birth_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
private LocalDate deathDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "death_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||
// Nullable for persons outside the curated family graph. Drives the
|
||||
|
||||
@@ -66,7 +66,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -79,7 +80,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -89,7 +91,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
@@ -100,7 +102,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -139,7 +142,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -16,6 +17,7 @@ import org.springframework.lang.Nullable;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -299,13 +301,17 @@ public class PersonService {
|
||||
}
|
||||
|
||||
private Person fromCanonical(PersonUpsertCommand cmd) {
|
||||
DatePrecisionPair birth = yearPair(cmd.birthYear());
|
||||
DatePrecisionPair death = yearPair(cmd.deathYear());
|
||||
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())
|
||||
.birthDate(birth.date())
|
||||
.birthDatePrecision(birth.precision())
|
||||
.deathDate(death.date())
|
||||
.deathDatePrecision(death.precision())
|
||||
.generation(cmd.generation())
|
||||
.familyMember(cmd.familyMember())
|
||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||
@@ -328,8 +334,14 @@ public class PersonService {
|
||||
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
||||
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||
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());
|
||||
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||
existing.setPersonType(cmd.personType());
|
||||
@@ -356,6 +368,28 @@ public class PersonService {
|
||||
return existing != null ? existing : canonical;
|
||||
}
|
||||
|
||||
// Date + precision travel as one value so they can never go out of sync (ADR-039).
|
||||
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
|
||||
|
||||
// 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.
|
||||
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
|
||||
Integer canonicalYear) {
|
||||
boolean handEntered = existingDate != null && existingPrecision != null
|
||||
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
|
||||
if (handEntered) {
|
||||
return new DatePrecisionPair(existingDate, existingPrecision);
|
||||
}
|
||||
return yearPair(canonicalYear);
|
||||
}
|
||||
|
||||
private static DatePrecisionPair yearPair(Integer year) {
|
||||
return year != null
|
||||
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
|
||||
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
@@ -375,7 +409,8 @@ public class PersonService {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
@@ -383,31 +418,49 @@ public class PersonService {
|
||||
.lastName(dto.getLastName())
|
||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||
.birthYear(dto.getBirthYear())
|
||||
.deathYear(dto.getDeathYear())
|
||||
.birthDate(dto.getBirthDate())
|
||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||
.deathDate(dto.getDeathDate())
|
||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||
.generation(dto.getGeneration())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
private void validateYears(Integer birthYear, Integer deathYear) {
|
||||
if (birthYear != null && birthYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||
}
|
||||
if (deathYear != null && deathYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
|
||||
}
|
||||
|
||||
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date is set but its precision is missing or UNKNOWN");
|
||||
}
|
||||
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date precision " + precision + " is set without a date");
|
||||
}
|
||||
}
|
||||
|
||||
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
@@ -416,8 +469,10 @@ public class PersonService {
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
person.setBirthDate(dto.getBirthDate());
|
||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||
person.setDeathDate(dto.getDeathDate());
|
||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||
// Form path: a human can clear generation back to null. Unlike the importer
|
||||
// which routes through preferHuman, we write the DTO value verbatim.
|
||||
person.setGeneration(dto.getGeneration());
|
||||
|
||||
@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
|
||||
private String alias;
|
||||
@Size(max = 5000)
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
private LocalDate birthDate;
|
||||
private DatePrecision birthDatePrecision;
|
||||
private LocalDate deathDate;
|
||||
private DatePrecision deathDatePrecision;
|
||||
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||
@Min(PersonGeneration.MIN_GENERATION)
|
||||
|
||||
@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
p.getBirthDate() != null ? p.getBirthDate().getYear() : null,
|
||||
p.getDeathDate() != null ? p.getDeathDate().getYear() : null,
|
||||
p.getGeneration(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -66,7 +67,8 @@ public class RelationshipService {
|
||||
for (Person p : familyMembers) {
|
||||
familyIds.add(p.getId());
|
||||
nodes.add(new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
|
||||
p.getGeneration(), true));
|
||||
}
|
||||
|
||||
@@ -155,6 +157,12 @@ public class RelationshipService {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
|
||||
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01).
|
||||
private static Integer yearOf(LocalDate date) {
|
||||
return date != null ? date.getYear() : null;
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
@@ -170,11 +178,11 @@ public class RelationshipService {
|
||||
p.getId(),
|
||||
rp.getId(),
|
||||
p.getDisplayName(),
|
||||
p.getBirthYear(),
|
||||
p.getDeathYear(),
|
||||
yearOf(p.getBirthDate()),
|
||||
yearOf(p.getDeathDate()),
|
||||
rp.getDisplayName(),
|
||||
rp.getBirthYear(),
|
||||
rp.getDeathYear(),
|
||||
yearOf(rp.getBirthDate()),
|
||||
yearOf(rp.getDeathDate()),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
|
||||
Reference in New Issue
Block a user