diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs index fd1c8f2..3c7a510 100644 --- a/GerbilManagerWebAPI/Import/ImportModels.cs +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import ResidencySummary? Residency = null); /// Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign- - /// Zuchtname animals made resident because they parented a Clan offspring (rule b). - public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule); + /// Zuchtname animals made resident because they parented a Clan offspring (rule b). + /// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite). + /// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the + /// current extract would update (no-op counter until the safety mechanism is ratified). + public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule, + int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0); public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0, diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index fc6c9ae..af9e2c3 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -137,12 +137,14 @@ namespace GerbilManagerWebAPI.Import // name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that // chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id. var existingRows = await _db.Gerbils - .Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync(); + .Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync(); var gidByNameDob = new Dictionary(); foreach (var g in existingRows) gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id; var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null) .ToDictionary(g => g.ExternalRef!, g => g.LitterId); + var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null) + .ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId); // PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet). var plan = new List(); @@ -169,7 +171,8 @@ namespace GerbilManagerWebAPI.Import if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid); var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null; - plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender)); + var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null; + plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId)); } // PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for @@ -460,6 +463,51 @@ namespace GerbilManagerWebAPI.Import if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync(); } + // FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off). + // Counts already-imported animals where the current extract matched a DIFFERENT + // ColorVariety than what is currently stored in the DB. Does NOT update any row. + int farbschlagWouldRebackfill = plan.Count(p => + p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId); + + // HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose + // OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'. + // NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen"). + const string DefaultClanBreeder = "Zucht der Kleinen Chaoten"; + int herkunftBackfilled = 0; + { + var needsHerkunft = await _db.Gerbils + .Where(g => g.OriginBreeder == null && g.IsResident) + .Select(g => new { g.Id, g.LitterId }) + .ToListAsync(); + + foreach (var g in needsHerkunft) + { + herkunftBackfilled++; + if (execute) + { + // Prefer the OriginBreeder of an existing parent (father first, then mother). + string? derived = null; + if (g.LitterId is not null) + { + var parentIds = await _db.Litters + .Where(l => l.Id == g.LitterId) + .Select(l => new { l.FatherId, l.MotherId }) + .FirstOrDefaultAsync(); + if (parentIds?.FatherId is Guid fid) + derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null) + .Select(gb => gb.OriginBreeder).FirstOrDefaultAsync(); + if (derived is null && parentIds?.MotherId is Guid mid) + derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null) + .Select(gb => gb.OriginBreeder).FirstOrDefaultAsync(); + } + var row = await _db.Gerbils.FindAsync(g.Id); + if (row is not null && row.OriginBreeder is null) + row.OriginBreeder = derived ?? DefaultClanBreeder; + } + } + if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync(); + } + if (littersWithoutDate > 0) notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft)."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); @@ -469,6 +517,10 @@ namespace GerbilManagerWebAPI.Import if (parentFksBackfilled > 0) notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen)."); notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt)."); + if (herkunftBackfilled > 0) + notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben."); + if (farbschlagWouldRebackfill > 0) + notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe)."); int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision); if (conflictsResolvedByDecision > 0) notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich)."); @@ -484,7 +536,8 @@ namespace GerbilManagerWebAPI.Import Photos: new PhotoSummary(photosAttached, photosMissing), Samples: samples, Notes: notes, - Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule)); + Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule, + HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill)); } private T? Load(string file) @@ -574,7 +627,8 @@ namespace GerbilManagerWebAPI.Import /// Per-animal plan computed before any write so synthesis can run in dry-run too. private sealed record AnimalPlan( SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId, - Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender); + Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender, + Guid? CurrentColorVarietyId = null); /// A litter synthesized from chart-position parentRefs (PEDIGREE-LINK). private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence); diff --git a/tools/import/extract.py b/tools/import/extract.py index f8c53b3..ece9b9e 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -129,7 +129,9 @@ def norm_zucht(z): if not z: return "" n = z.lower() - n = re.sub(r"\bv\.\s?d\.\b", " ", n) + # TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' ' + # (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b. + n = re.sub(r"\bv\.\s?d\.", " ", n) n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n) n = re.sub(r"[^a-z0-9äöüß ]", " ", n) words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()] @@ -137,6 +139,15 @@ def norm_zucht(z): return ZUCHT_ALIASES.get(key, key) +def is_clan_zucht(z): + """True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery. + + Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten', + 'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc. + """ + return norm_zucht(z) == "kleinechaote" + + def canon_pair(raw): """Full raw name -> (normalised call-name, canonical zucht).""" name, zucht = split_name_zucht(raw) diff --git a/tools/import/test_extract.py b/tools/import/test_extract.py index 2c8d9f3..3de2ed3 100644 --- a/tools/import/test_extract.py +++ b/tools/import/test_extract.py @@ -280,6 +280,33 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel")) check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell")) +# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' --- +# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen. +# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so +# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b. +check("KC-matcher: 'Zucht der Kleinen Chaoten'", + e.is_clan_zucht("Zucht der Kleinen Chaoten")) +check("KC-matcher: 'kleinen Chaoten' (no prefix)", + e.is_clan_zucht("kleinen Chaoten")) +check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)", + e.is_clan_zucht("v.d. Kleinen Chaoten")) +check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)", + e.is_clan_zucht("ZdkC")) +check("KC-matcher: 'von den Kleinen Chaoten' (full long form)", + e.is_clan_zucht("von den Kleinen Chaoten")) +check("KC-matcher: empty string -> NOT clan", + not e.is_clan_zucht("")) +check("KC-matcher: 'Black Forest' -> NOT clan", + not e.is_clan_zucht("Black Forest")) +check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)", + e.norm_zucht("Kleine Chaoten") == "kleinechaote") +check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'", + e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote") +# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record +# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.: +check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)", + e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote") + if failed: print(f"\n{failed} test(s) FAILED") sys.exit(1)