feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL)
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m15s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m37s
CI / Docker Build & Push (push) Successful in 7m36s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s

Im Tier-Formular gibt es jetzt den Block „Abstammung" mit Wurf-Auswahl und
Vater-/Mutter-Picker — die Eltern müssen nicht mehr über die Wurf-Seite
gesucht und dort editiert werden. Das Datenmodell bleibt unverändert: Eltern
hängen weiterhin am Geburtswurf.

- Backend: PUT /gerbils/{id}/parents schreibt Litter.FatherId/MotherId des
  Geburtswurfs. Ohne Wurf wird ein bestehender mit gleichem Elternpaar +
  gleichem Datum verknüpft, sonst ein Träger-Wurf angelegt
  ("Wurf von X + Y", ShowInChronicle=false, IsManual=true).
  Geschlechts-Regel wiederverwendet LitterEndpoints.ValidateParents,
  Selbstbezug (Tier als eigener Elternteil) wird abgewiesen.
- UI: Vorbelegung aus dem gewählten Wurf, Warnung mit Anzahl der Geschwister
  (Eltern gehören dem Wurf → Änderung gilt für alle), Hinweis wenn ein
  Wurf-Eintrag angelegt wird. Texte in de.ts.
- Nebenbei: Speichern nutzt im Edit-Modus die Route-Id (PUT /gerbils/{id}
  antwortet 204 ohne Body) und der vorher schon rote Spec-Locator
  „Würfe als Elternteil" ist auf den Abschnitt eingegrenzt.
- Tests: GerbilParentsTests (9), e2e tiere.spec (2 neu) + Mock-Route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 20:43:28 +02:00
parent 7a2feac04d
commit 54e873c115
11 changed files with 653 additions and 29 deletions

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
@@ -90,6 +90,65 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.NoContent();
});
// PUT /gerbils/{id}/parents — QOL (Wunsch der Züchterin): Eltern DIREKT in der
// Tier-Akte pflegen, ohne vorher den Wurf suchen zu müssen. Das Datenmodell bleibt
// unverändert — Eltern hängen weiterhin am GEBURTSWURF (Litter.FatherId/MotherId),
// dieser Endpoint schreibt nur genau dorthin:
// • Tier hat einen Wurf → dessen Eltern werden gesetzt. Das gilt zwangsläufig für
// ALLE Jungtiere des Wurfs; die UI warnt vorher mit SiblingCount.
// • Tier hat keinen Wurf → gibt es einen Wurf mit genau diesem Elternpaar UND
// demselben Datum wie das Geburtsdatum (= dieselbe Geburt), wird das Tier dort
// eingehängt (LitterAttached). Sonst wird ein Wurf angelegt (LitterCreated) mit
// ShowInChronicle=false (nicht in der Wurfchronik, nur zur Abstammung) und
// IsManual=true (überlebt den Re-Ingest).
group.MapPut("/{id:guid}/parents", async Task<Results<Ok<GerbilParentsResult>, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (
Guid id, GerbilParentsInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
if (input.FatherId == id || input.MotherId == id)
return TypedResults.BadRequest("Ein Tier kann nicht sein eigener Elternteil sein.");
var genderErr = await LitterEndpoints.ValidateParents(db, input.FatherId, input.MotherId);
if (genderErr is not null) return TypedResults.BadRequest(genderErr);
var litter = g.LitterId is Guid lid
? await db.Litters.FirstOrDefaultAsync(l => l.Id == lid)
: null;
// Nichts zu tun: kein Wurf und keine Eltern angegeben.
if (litter is null && input.FatherId is null && input.MotherId is null)
return TypedResults.Ok(new GerbilParentsResult(null, null, false, false, 0, null, null));
var created = false;
var attached = false;
if (litter is null)
{
// Gleiches Elternpaar + gleiches Datum ⇒ dieselbe Geburt: an den bestehenden
// Wurf hängen (macht das Tier korrekt zum Geschwister), statt zu duplizieren.
if (g.DateOfBirth is DateOnly dob)
{
litter = await db.Litters.FirstOrDefaultAsync(l =>
l.FatherId == input.FatherId && l.MotherId == input.MotherId && l.Date == dob);
attached = litter is not null;
}
litter ??= NewParentLitter(g, await NameOfAsync(db, input.FatherId), await NameOfAsync(db, input.MotherId));
if (!attached)
{
db.Litters.Add(litter);
created = true;
}
g.LitterId = litter.Id;
}
litter.FatherId = input.FatherId;
litter.MotherId = input.MotherId;
await db.SaveChangesAsync();
var siblingCount = await db.Gerbils.CountAsync(x => x.LitterId == litter.Id && x.Id != id);
return TypedResults.Ok(new GerbilParentsResult(
litter.Id, litter.Name, created, attached, siblingCount, litter.FatherId, litter.MotherId));
});
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
@@ -110,6 +169,24 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static async Task<string?> NameOfAsync(ApplicationContext db, Guid? gerbilId) =>
gerbilId is Guid gid
? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync()
: null;
/// <summary>Träger-Wurf für die Eltern eines Tiers ohne Geburtswurf. Namensschema wie die
/// vom Import erzeugten Stammbaum-Würfe ("Wurf von X + Y"); ShowInChronicle=false, damit die
/// Wurfchronik nicht mit Hilfs-Würfen zuwächst; IsManual=true, damit der Re-Ingest ihn nicht wegräumt.</summary>
private static Litter NewParentLitter(Gerbil g, string? fatherName, string? motherName) => new()
{
Id = Guid.NewGuid(),
Name = $"Wurf von {fatherName ?? ""} + {motherName ?? ""}",
Date = g.DateOfBirth,
ShowInChronicle = false,
IsManual = true,
Notes = $"Automatisch angelegt, um die Eltern von {g.Name} zu tragen.",
};
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults