refactor(document): share skip-null date-field resolution between save and projection (#726)

Extract effectivePrecision/effectiveMetaDateEnd/effectiveMetaDateRaw, used by both
applyDatePrecision (the real setters) and projectedState (the title projection), so the two
can no longer drift — addresses review feedback (Markus/Felix/Sara). Writing a stored value
back when the DTO omits a field is a harmless no-op, so behaviour is unchanged (185 existing
DocumentServiceTest cases stay green). Also documents the file-replace "treat as manual" path
inline at the reassignment site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 17:08:51 +02:00
parent cf457cb96f
commit 7316c51d4a

View File

@@ -431,7 +431,11 @@ public class DocumentService {
doc.setScriptType(dto.getScriptType()); doc.setScriptType(dto.getScriptType());
} }
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
// segment is originalFilename, so after a replace the stored title no longer matches
// build(currentState) and the row is treated as manual — neither save-time nor backfill
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
boolean fileReplaced = newFile != null && !newFile.isEmpty(); boolean fileReplaced = newFile != null && !newFile.isEmpty();
if (fileReplaced) { if (fileReplaced) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
@@ -479,44 +483,49 @@ public class DocumentService {
} }
/** /**
* The document state the regenerated title is built from, mirroring {@code updateDocument}'s * The document state the regenerated title is built from. It is composed from the SAME
* setter asymmetry exactly: {@code documentDate}/{@code location} are overwritten from the DTO * resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
* (a null value clears the field), while precision/end/raw are taken from the DTO only when * DTO (a null value clears the field), precision/end/raw resolved skip-null via
* non-null and otherwise kept from the stored entity (see {@link #applyDatePrecision}). The * {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
* index ({@code originalFilename}) is never touched by a metadata edit. A mismatch here would * the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
* silently produce a wrong label, so it is kept lock-step with the real setters. * is never touched by a metadata edit.
*/ */
private Document projectedState(Document doc, DocumentUpdateDTO dto) { private Document projectedState(Document doc, DocumentUpdateDTO dto) {
return Document.builder() return Document.builder()
.originalFilename(doc.getOriginalFilename()) .originalFilename(doc.getOriginalFilename())
.documentDate(dto.getDocumentDate()) .documentDate(dto.getDocumentDate())
.location(dto.getLocation()) .location(dto.getLocation())
.metaDatePrecision(dto.getMetaDatePrecision() != null .metaDatePrecision(effectivePrecision(doc, dto))
? dto.getMetaDatePrecision() : doc.getMetaDatePrecision()) .metaDateEnd(effectiveMetaDateEnd(doc, dto))
.metaDateEnd(dto.getMetaDateEnd() != null .metaDateRaw(effectiveMetaDateRaw(doc, dto))
? dto.getMetaDateEnd() : doc.getMetaDateEnd())
.metaDateRaw(dto.getMetaDateRaw() != null
? dto.getMetaDateRaw() : doc.getMetaDateRaw())
.build(); .build();
} }
/** /**
* Applies the three date-precision fields only when the DTO carries them. * Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
* A null field means "not submitted" — overwriting the stored value with null * so the stored value is kept rather than overwritten with null — which would fabricate a
* would fabricate a precision the user never chose, the exact dishonesty #666 * precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
* exists to prevent. A row with a genuinely-unknown precision must keep it when * the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
* an unrelated edit (e.g. a location typo) is saved. * the stored value back when the DTO omits a field is a harmless no-op).
*/ */
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) { private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
if (dto.getMetaDatePrecision() != null) { doc.setMetaDatePrecision(effectivePrecision(doc, dto));
doc.setMetaDatePrecision(dto.getMetaDatePrecision()); doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
} doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
if (dto.getMetaDateEnd() != null) { }
doc.setMetaDateEnd(dto.getMetaDateEnd());
} // Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
if (dto.getMetaDateRaw() != null) { // projectedState (the title projection) — the single rule keeps them from diverging (#726).
doc.setMetaDateRaw(dto.getMetaDateRaw()); private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
} return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
}
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
}
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
} }
/** /**