Deployment: - custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App" (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090) - scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check - ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye) - compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090 - .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch) Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage): - GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests - EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO) - Frontend VerifizierteTierePage + verified-API + e2e-Spec - diverse Import-/Triage-Skripte und -Tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
16 KiB
C#
308 lines
16 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManagerWebAPI.Import
|
|
{
|
|
// ── Canonical, human-readable "Akte" snapshot ────────────────────────────
|
|
// Names (not ids) are stored so the golden state stays human-verifiable and robust to
|
|
// deterministic-id reseeds. Used for the drift report, the export fixture and the
|
|
// regression test. Lists are sorted canonically by the builder so equality is stable.
|
|
|
|
public sealed record GerbilSnapshot(
|
|
SnapshotSelf Self,
|
|
SnapshotParent? Father,
|
|
SnapshotParent? Mother,
|
|
List<SnapshotLitter> Litters);
|
|
|
|
public sealed record SnapshotSelf(
|
|
string Name, string Gender, string? DateOfBirth, string? DateOfDeath, string? CauseOfDeath,
|
|
string? GoHomeDate, string? Genotype, string? SpottingType, string? ColorName,
|
|
bool IsResident, bool? IsDeaf, bool IsCastrated, string? OriginBreeder,
|
|
string? OriginContactName, string? ReceiverContactName, string? Notes,
|
|
List<string> CharacterTraits, string? CharacterNote, string? BirthLitterName);
|
|
|
|
public sealed record SnapshotParent(string Name, string? DateOfBirth, string? Genotype);
|
|
|
|
public sealed record SnapshotLitter(
|
|
string Name, string? Date, string Role, int? TotalBorn, bool ShowInChronicle,
|
|
List<SnapshotChild> Children);
|
|
|
|
public sealed record SnapshotChild(string Name, string? DateOfBirth, string Gender);
|
|
|
|
/// <summary>One field-level difference between two snapshots (golden ↔ current/raw import).</summary>
|
|
public sealed record SnapshotDiff(string Path, string? GoldenValue, string? OtherValue);
|
|
|
|
/// <summary>
|
|
/// Builds the canonical Akte snapshot for a gerbil, the machine-readable freeze map (own fields
|
|
/// that "vollständig korrekt" pins), applies an override onto a gerbil, and diffs two snapshots.
|
|
/// SHARED by the app (endpoints + ingest freeze) and the regression test so the comparison logic
|
|
/// can never drift between the two.
|
|
/// </summary>
|
|
public static class GerbilSnapshotService
|
|
{
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
|
|
/// <summary>Own-field keys that the "vollständig korrekt" freeze pins. Deliberately excludes
|
|
/// Status (lifecycle, derived) and EnclosureId (current location) and LitterId/lineage
|
|
/// (abstammung is detect-only). Keys match the JSON serialised in <see cref="BuildFreezeObject"/>.</summary>
|
|
public static readonly IReadOnlyList<string> FreezeFields = new[]
|
|
{
|
|
"name", "gender", "dateOfBirth", "dateOfDeath", "causeOfDeath", "goHomeDate",
|
|
"genotype", "spottingType", "colorVarietyId", "isResident", "isDeaf", "isCastrated",
|
|
"originBreeder", "originContactId", "receiverContactId", "notes",
|
|
"characterTraits", "characterNote",
|
|
};
|
|
|
|
// ── Snapshot (human-readable Akte) ───────────────────────────────────
|
|
|
|
public static async Task<GerbilSnapshot?> BuildSnapshotAsync(ApplicationContext db, Guid gerbilId)
|
|
{
|
|
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == gerbilId);
|
|
if (g is null) return null;
|
|
|
|
var colorName = g.ColorVarietyId is { } cv
|
|
? await db.ColorVarieties.AsNoTracking().Where(c => c.Id == cv).Select(c => c.Name).FirstOrDefaultAsync()
|
|
: null;
|
|
var originName = g.OriginContactId is { } oc
|
|
? await db.Contacts.AsNoTracking().Where(c => c.Id == oc).Select(c => c.Name).FirstOrDefaultAsync()
|
|
: null;
|
|
var receiverName = g.ReceiverContactId is { } rc
|
|
? await db.Contacts.AsNoTracking().Where(c => c.Id == rc).Select(c => c.Name).FirstOrDefaultAsync()
|
|
: null;
|
|
|
|
// Birth litter → parents.
|
|
Litter? birthLitter = g.LitterId is { } lid
|
|
? await db.Litters.AsNoTracking().FirstOrDefaultAsync(l => l.Id == lid)
|
|
: null;
|
|
var father = await BuildParentAsync(db, birthLitter?.FatherId);
|
|
var mother = await BuildParentAsync(db, birthLitter?.MotherId);
|
|
|
|
// Own litters (as parent) + children.
|
|
var ownLitters = await db.Litters.AsNoTracking()
|
|
.Where(l => l.FatherId == gerbilId || l.MotherId == gerbilId)
|
|
.ToListAsync();
|
|
var ownLitterIds = ownLitters.Select(l => l.Id).ToList();
|
|
var children = ownLitterIds.Count == 0
|
|
? new List<Gerbil>()
|
|
: await db.Gerbils.AsNoTracking().Where(c => c.LitterId != null && ownLitterIds.Contains(c.LitterId.Value)).ToListAsync();
|
|
|
|
var litterSnaps = ownLitters
|
|
.Select(l => new SnapshotLitter(
|
|
l.Name,
|
|
Iso(l.Date),
|
|
l.FatherId == gerbilId ? "father" : "mother",
|
|
l.TotalBorn,
|
|
l.ShowInChronicle,
|
|
children.Where(c => c.LitterId == l.Id)
|
|
.Select(c => new SnapshotChild(c.Name, Iso(c.DateOfBirth), c.Gender.ToString()))
|
|
.OrderBy(c => c.DateOfBirth ?? "", StringComparer.Ordinal)
|
|
.ThenBy(c => c.Name, StringComparer.Ordinal)
|
|
.ThenBy(c => c.Gender, StringComparer.Ordinal)
|
|
.ToList()))
|
|
.OrderBy(l => l.Date ?? "", StringComparer.Ordinal)
|
|
.ThenBy(l => l.Name, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
var self = new SnapshotSelf(
|
|
g.Name, g.Gender.ToString(), Iso(g.DateOfBirth), Iso(g.DateOfDeath), g.CauseOfDeath,
|
|
Iso(g.GoHomeDate), g.Genotype, g.SpottingType, colorName,
|
|
g.IsResident, g.IsDeaf, g.IsCastrated, g.OriginBreeder,
|
|
originName, receiverName, g.Notes,
|
|
(g.CharacterTraits ?? new List<string>()).OrderBy(t => t, StringComparer.Ordinal).ToList(),
|
|
g.CharacterNote, birthLitter?.Name);
|
|
|
|
return new GerbilSnapshot(self, father, mother, litterSnaps);
|
|
}
|
|
|
|
private static async Task<SnapshotParent?> BuildParentAsync(ApplicationContext db, Guid? parentId)
|
|
{
|
|
if (parentId is not { } pid) return null;
|
|
var p = await db.Gerbils.AsNoTracking().Where(x => x.Id == pid)
|
|
.Select(x => new { x.Name, x.DateOfBirth, x.Genotype }).FirstOrDefaultAsync();
|
|
return p is null ? null : new SnapshotParent(p.Name, Iso(p.DateOfBirth), p.Genotype);
|
|
}
|
|
|
|
public static string SerializeSnapshot(GerbilSnapshot snap) => JsonSerializer.Serialize(snap, Json);
|
|
|
|
public static GerbilSnapshot? DeserializeSnapshot(string? json) =>
|
|
string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<GerbilSnapshot>(json, Json);
|
|
|
|
// ── Diff (golden ↔ other) ────────────────────────────────────────────
|
|
|
|
/// <summary>Flat, human-readable field diff between a golden snapshot and another (current
|
|
/// DB or raw import) snapshot. Empty = identical.</summary>
|
|
public static List<SnapshotDiff> Diff(GerbilSnapshot? golden, GerbilSnapshot? other)
|
|
{
|
|
var g = golden is null ? new Dictionary<string, string?>() : Flatten(golden);
|
|
var o = other is null ? new Dictionary<string, string?>() : Flatten(other);
|
|
var diffs = new List<SnapshotDiff>();
|
|
foreach (var key in g.Keys.Union(o.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
|
{
|
|
var gv = g.GetValueOrDefault(key);
|
|
var ov = o.GetValueOrDefault(key);
|
|
if (!string.Equals(gv, ov, StringComparison.Ordinal))
|
|
diffs.Add(new SnapshotDiff(key, gv, ov));
|
|
}
|
|
return diffs;
|
|
}
|
|
|
|
private static Dictionary<string, string?> Flatten(GerbilSnapshot s)
|
|
{
|
|
var d = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["name"] = s.Self.Name,
|
|
["gender"] = s.Self.Gender,
|
|
["dateOfBirth"] = s.Self.DateOfBirth,
|
|
["dateOfDeath"] = s.Self.DateOfDeath,
|
|
["causeOfDeath"] = s.Self.CauseOfDeath,
|
|
["goHomeDate"] = s.Self.GoHomeDate,
|
|
["genotype"] = s.Self.Genotype,
|
|
["spottingType"] = s.Self.SpottingType,
|
|
["colorName"] = s.Self.ColorName,
|
|
["isResident"] = s.Self.IsResident.ToString(),
|
|
["isDeaf"] = s.Self.IsDeaf?.ToString(),
|
|
["isCastrated"] = s.Self.IsCastrated.ToString(),
|
|
["originBreeder"] = s.Self.OriginBreeder,
|
|
["originContact"] = s.Self.OriginContactName,
|
|
["receiverContact"] = s.Self.ReceiverContactName,
|
|
["notes"] = s.Self.Notes,
|
|
["characterTraits"] = string.Join(", ", s.Self.CharacterTraits),
|
|
["characterNote"] = s.Self.CharacterNote,
|
|
["birthLitter"] = s.Self.BirthLitterName,
|
|
["father"] = FormatParent(s.Father),
|
|
["mother"] = FormatParent(s.Mother),
|
|
};
|
|
foreach (var l in s.Litters)
|
|
{
|
|
var lk = $"wurf[{l.Name} {l.Date}]";
|
|
d[$"{lk}.rolle"] = l.Role;
|
|
d[$"{lk}.geboren"] = l.TotalBorn?.ToString();
|
|
d[$"{lk}.inChronik"] = l.ShowInChronicle.ToString();
|
|
foreach (var c in l.Children)
|
|
d[$"{lk}.kind[{c.Name} {c.DateOfBirth}]"] = c.Gender;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static string? FormatParent(SnapshotParent? p) =>
|
|
p is null ? null : $"{p.Name} (*{p.DateOfBirth}) {p.Genotype}".Trim();
|
|
|
|
// ── Freeze map (machine-readable own-field override) ─────────────────
|
|
|
|
/// <summary>Builds the full own-field freeze object (all <see cref="FreezeFields"/>) from a
|
|
/// gerbil's current values. Used by verify (store as OverrideJson) and by the per-field
|
|
/// edit-protection diff.</summary>
|
|
public static JsonObject BuildFreezeObject(Gerbil g)
|
|
{
|
|
var o = new JsonObject
|
|
{
|
|
["name"] = g.Name,
|
|
["gender"] = g.Gender.ToString(),
|
|
["dateOfBirth"] = Iso(g.DateOfBirth),
|
|
["dateOfDeath"] = Iso(g.DateOfDeath),
|
|
["causeOfDeath"] = g.CauseOfDeath,
|
|
["goHomeDate"] = Iso(g.GoHomeDate),
|
|
["genotype"] = g.Genotype,
|
|
["spottingType"] = g.SpottingType,
|
|
["colorVarietyId"] = g.ColorVarietyId?.ToString(),
|
|
["isResident"] = g.IsResident,
|
|
["isDeaf"] = g.IsDeaf,
|
|
["isCastrated"] = g.IsCastrated,
|
|
["originBreeder"] = g.OriginBreeder,
|
|
["originContactId"] = g.OriginContactId?.ToString(),
|
|
["receiverContactId"] = g.ReceiverContactId?.ToString(),
|
|
["notes"] = g.Notes,
|
|
["characterTraits"] = new JsonArray((g.CharacterTraits ?? new List<string>()).Select(t => (JsonNode?)JsonValue.Create(t)).ToArray()),
|
|
["characterNote"] = g.CharacterNote,
|
|
};
|
|
return o;
|
|
}
|
|
|
|
public static string BuildFreezeJson(Gerbil g) => BuildFreezeObject(g).ToJsonString(Json);
|
|
|
|
/// <summary>Per-field change detection for the PUT edit-protection path. Returns the subset of
|
|
/// freeze keys whose value differs between <paramref name="before"/> and <paramref name="after"/>,
|
|
/// as a JsonObject carrying the AFTER values (the ones to pin). Empty if nothing relevant changed.</summary>
|
|
public static JsonObject ChangedFields(JsonObject before, JsonObject after)
|
|
{
|
|
var changed = new JsonObject();
|
|
foreach (var key in FreezeFields)
|
|
{
|
|
var b = before[key]?.ToJsonString() ?? "null";
|
|
var a = after[key]?.ToJsonString() ?? "null";
|
|
if (!string.Equals(b, a, StringComparison.Ordinal))
|
|
changed[key] = after[key] is { } node ? node.DeepClone() : null;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/// <summary>Merge <paramref name="incoming"/> field values into an existing override JSON
|
|
/// object (incoming wins). Returns the merged JSON string.</summary>
|
|
public static string MergeOverrideJson(string? existingJson, JsonObject incoming)
|
|
{
|
|
var baseObj = ParseObject(existingJson) ?? new JsonObject();
|
|
foreach (var kv in incoming)
|
|
baseObj[kv.Key] = kv.Value is { } n ? n.DeepClone() : null;
|
|
return baseObj.ToJsonString(Json);
|
|
}
|
|
|
|
/// <summary>Applies a stored override JSON object onto a gerbil, writing only the keys present
|
|
/// (the "freeze"). Unknown keys are ignored.</summary>
|
|
public static void ApplyOverride(Gerbil g, string? overrideJson)
|
|
{
|
|
var o = ParseObject(overrideJson);
|
|
if (o is null) return;
|
|
foreach (var kv in o)
|
|
{
|
|
var v = kv.Value;
|
|
switch (kv.Key)
|
|
{
|
|
case "name": if (Str(v) is { Length: > 0 } nm) g.Name = nm; break;
|
|
case "gender": if (Str(v) is { } gd && Enum.TryParse<Gender>(gd, out var gender)) g.Gender = gender; break;
|
|
case "dateOfBirth": g.DateOfBirth = Date(v); break;
|
|
case "dateOfDeath": g.DateOfDeath = Date(v); break;
|
|
case "causeOfDeath": g.CauseOfDeath = Str(v); break;
|
|
case "goHomeDate": g.GoHomeDate = Date(v); break;
|
|
case "genotype": g.Genotype = Str(v); break;
|
|
case "spottingType": g.SpottingType = Str(v); break;
|
|
case "colorVarietyId": g.ColorVarietyId = Guid_(v); break;
|
|
case "isResident": g.IsResident = Bool(v) ?? g.IsResident; break;
|
|
case "isDeaf": g.IsDeaf = Bool(v); break;
|
|
case "isCastrated": g.IsCastrated = Bool(v) ?? g.IsCastrated; break;
|
|
case "originBreeder": g.OriginBreeder = Str(v); break;
|
|
case "originContactId": g.OriginContactId = Guid_(v); break;
|
|
case "receiverContactId": g.ReceiverContactId = Guid_(v); break;
|
|
case "notes": g.Notes = Str(v); break;
|
|
case "characterTraits": g.CharacterTraits = StrList(v); break;
|
|
case "characterNote": g.CharacterNote = Str(v); break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── JSON helpers ─────────────────────────────────────────────────────
|
|
|
|
private static JsonObject? ParseObject(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
|
try { return JsonNode.Parse(json) as JsonObject; }
|
|
catch (JsonException) { return null; }
|
|
}
|
|
|
|
private static string? Str(JsonNode? n) => n?.GetValueKind() == JsonValueKind.String ? n.GetValue<string>() : null;
|
|
private static bool? Bool(JsonNode? n) => n?.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? n!.GetValue<bool>() : null;
|
|
private static Guid? Guid_(JsonNode? n) => Guid.TryParse(Str(n), out var id) ? id : null;
|
|
private static DateOnly? Date(JsonNode? n) => DateOnly.TryParse(Str(n), out var d) ? d : null;
|
|
|
|
private static List<string> StrList(JsonNode? n)
|
|
{
|
|
if (n is JsonArray arr)
|
|
return arr.Where(x => x is not null).Select(x => x!.GetValue<string>()).ToList();
|
|
return new List<string>();
|
|
}
|
|
|
|
private static string? Iso(DateOnly? d) => d?.ToString("yyyy-MM-dd");
|
|
}
|
|
}
|