Files
GerbilManager/GerbilManagerWebAPI/Import/ImportModels.cs
Gulum fd48f7fc09 Fix import FK fault: derived/back-linked litter referenced a not-yet-inserted parent
BUG (blocked the supervised live re-import): /import/execute threw
Npgsql 23503 FK_Litters_Gerbils_FatherId and rolled back. Root cause was
INSERT ORDERING — synthesized derived litters were SaveChanges()'d BEFORE
the parent gerbils (created later in the animal loop), so the litter's
Father/MotherId pointed at rows that didn't exist yet. The dry-run and the
EF in-memory test provider don't enforce FKs, so it slipped through.

Fix:
- Stage synthesized litters in the context but DON'T save them early; the
  single SaveChanges after the animal loop lets EF order parents → litters →
  offspring (all FKs nullable). Saving litters first was the fault.
- FK-integrity guard (god's spec): compute the persisted set (existing DB +
  this run's loadable) and null out any litter parent FK not in it; SKIP a
  derived litter whose BOTH parents are unresolvable (offspring loads with
  LitterId=null). Quarantined parents already resolve to null via
  ResolveParentGid; this is defense-in-depth + makes the invariant explicit.
- Report litterParentFksDropped + derivedLittersSkipped (LitterSummary) +
  a German note — so a green dry-run (0/0) GUARANTEES execute won't FK-fault.

Tests: two SQLite-backed regressions (SQLite enforces FKs, unlike the
in-memory provider) — a derived litter with NEW chart parents executes
without throwing, and a quarantined parent leaves that FK null. 120 C#
tests + python green; no schema change (has-pending clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:32:44 +02:00

106 lines
4.1 KiB
C#

using System.Text.Json.Serialization;
namespace GerbilManagerWebAPI.Import
{
// ---- Source shapes (tools/import/output JSON, produced by extract.py) ----
public sealed class SourceAnimal
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public List<string> NameVariants { get; set; } = new();
public string Dob { get; set; } = "";
public string Death { get; set; } = "";
public string? Gender { get; set; }
public string Farbschlag { get; set; } = "";
public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = "";
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
public List<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new();
public bool Conflict { get; set; }
public SourceLitterRef? LitterRef { get; set; }
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
public bool? Deaf { get; set; }
public List<string> Tags { get; set; } = new();
}
public sealed class SourceGenotype
{
public Dictionary<string, List<string>> Mapped8locus { get; set; } = new();
public string RawGenotype { get; set; } = "";
public List<string> UnmappedTokens { get; set; } = new();
}
public sealed class SourceParentRef
{
public string Name { get; set; } = "";
public string Dob { get; set; } = "";
public string RoleGuess { get; set; } = ""; // "father" | "mother"
public string Method { get; set; } = ""; // e.g. "chart-position"
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
}
public sealed class SourceLitterRef
{
public string LitterId { get; set; } = "";
public string Method { get; set; } = "";
public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates)
public List<string>? Candidates { get; set; }
}
public sealed class SourceLitter
{
public string Id { get; set; } = "";
public string LitterId { get; set; } = "";
public string Date { get; set; } = "";
public string DamName { get; set; } = "";
public string SireName { get; set; } = "";
public int? TotalBorn { get; set; }
public string Zuchtnummer { get; set; } = "";
public string Note { get; set; } = "";
public List<string> Warnings { get; set; } = new();
}
// ---- Report shapes (Julian-readable: counts per category + samples) ----
public sealed record ImportReport(
bool Executed,
LitterSummary Litters,
AnimalSummary Animals,
PhotoSummary Photos,
IReadOnlyList<string> Samples,
IReadOnlyList<string> Notes,
ResidencySummary? Residency = null);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
public sealed record AnimalSummary(
int InSource,
int Created,
int LinkedToLitter,
int FarbschlagMatched,
int FarbschlagUnmatched,
int AlreadyImported,
QuarantineSummary Quarantined,
int ParentLinksFromChart = 0);
public sealed record QuarantineSummary(
int Conflicts,
int Stubs,
int DateOnlyLinks,
int AmbiguousLinks,
int Total);
public sealed record PhotoSummary(int Attached, int SourceFilesMissing);
}