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>
269 lines
15 KiB
C#
269 lines
15 KiB
C#
using GerbilManagerWebAPI.Common;
|
|
using GerbilManagerWebAPI.Dtos;
|
|
using GerbilManagerWebAPI.Import;
|
|
using GerbilManagerWebAPI.Models;
|
|
using GerbilManagerWebAPI.Services;
|
|
using Gridify;
|
|
using Gridify.EntityFramework;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManagerWebAPI.Endpoints
|
|
{
|
|
public static class GerbilEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapGerbilEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/gerbils").WithTags("Gerbils");
|
|
|
|
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
|
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
|
{
|
|
var q = query.ToQuery();
|
|
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
|
var ids = paging.Data.Select(g => g.Id).ToList();
|
|
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
|
await db.GerbilPhotos.AsNoTracking()
|
|
.Where(p => ids.Contains(p.GerbilId))
|
|
.GroupBy(p => p.GerbilId)
|
|
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
|
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
|
var items = paging.Data
|
|
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
|
.ToList();
|
|
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
|
});
|
|
|
|
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
|
group.MapGet("/breeders", async (ApplicationContext db) =>
|
|
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
|
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
|
|
.Select(g => g.OriginBreeder!)
|
|
.Distinct().OrderBy(b => b).ToListAsync()));
|
|
|
|
// GET /gerbils/{id}
|
|
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
|
{
|
|
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
|
if (g is null) return TypedResults.NotFound();
|
|
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
|
.Where(p => p.GerbilId == id)
|
|
.OrderBy(p => p.SortOrder)
|
|
.Select(p => p.FileName)
|
|
.FirstOrDefaultAsync();
|
|
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
|
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
|
});
|
|
|
|
// POST /gerbils
|
|
group.MapPost("/", async Task<Results<Created<GerbilDto>, ValidationProblem>> (GerbilInput input, ApplicationContext db) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input.Name))
|
|
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
|
|
|
// Manually created in the UI → IsManual=true, so the import re-ingest never wipes or
|
|
// overwrites this animal (and its sub-records).
|
|
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name!, IsManual = true };
|
|
Apply(g, input, isCreate: true);
|
|
db.Gerbils.Add(g);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
|
});
|
|
|
|
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
|
{
|
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
|
if (g is null) return TypedResults.NotFound();
|
|
|
|
// Capture the own-field values BEFORE the edit so manual changes to an IMPORTED animal
|
|
// can be pinned per-field (survive the next ingest). Manual animals need no override —
|
|
// they survive the ingest wholesale.
|
|
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
|
Apply(g, input, isCreate: false);
|
|
await db.SaveChangesAsync();
|
|
|
|
if (!g.IsManual)
|
|
await RecordEditOverrideAsync(db, g, before);
|
|
|
|
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) =>
|
|
{
|
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
|
if (g is null) return TypedResults.NotFound();
|
|
db.Gerbils.Remove(g);
|
|
try
|
|
{
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.NoContent();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return TypedResults.Conflict("Gerbil is referenced as a litter parent and cannot be deleted.");
|
|
}
|
|
});
|
|
|
|
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
|
|
// for fields the frontend omits. A non-null input value always wins (including explicit
|
|
// nulls — callers that want to clear a nullable field must send a full object; a
|
|
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
|
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
|
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
|
g.LitterId = i.LitterId ?? g.LitterId;
|
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
|
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
|
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
|
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
|
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
|
g.Genotype = i.Genotype ?? g.Genotype;
|
|
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
|
g.Notes = i.Notes ?? g.Notes;
|
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
|
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
|
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
|
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
|
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
|
}
|
|
|
|
// EDIT PROTECTION: after a manual edit to an IMPORTED animal, pin the change so the next
|
|
// ingest can't overwrite it. A VERIFIED animal keeps its certification and its whole golden
|
|
// is refreshed to the edited state (Frage 5). An unverified animal gets/updates a "geschützt"
|
|
// override carrying ONLY the changed fields (per-field) — untouched fields keep receiving
|
|
// import improvements. Called after the edit is already persisted.
|
|
private static async Task RecordEditOverrideAsync(ApplicationContext db, Gerbil g, System.Text.Json.Nodes.JsonObject before)
|
|
{
|
|
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == g.Id);
|
|
if (ov is { IsVerified: true })
|
|
{
|
|
ov.EntityName = g.Name;
|
|
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
|
var snap = await GerbilSnapshotService.BuildSnapshotAsync(db, g.Id);
|
|
if (snap is not null) ov.SnapshotJson = GerbilSnapshotService.SerializeSnapshot(snap);
|
|
ov.LastImportSnapshotJson = null;
|
|
ov.LastImportDiffJson = null;
|
|
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return;
|
|
}
|
|
|
|
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
|
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
|
if (changed.Count == 0) return;
|
|
|
|
if (ov is null)
|
|
{
|
|
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = g.Id, IsVerified = false, OverrideJson = "{}" };
|
|
db.GerbilOverrides.Add(ov);
|
|
}
|
|
ov.EntityName = g.Name;
|
|
ov.OverrideJson = GerbilSnapshotService.MergeOverrideJson(ov.OverrideJson, changed);
|
|
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
|
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
|
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
|
g.Provenance, g.BirthOrder);
|
|
}
|
|
}
|