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)