REVIEW-FIXES-BACKEND: CR-9 + CR-11 + CR-10 + DB-1
CR-9 (major): gidByNameDob TryGetValue + ExternalRef-Fallback Verhindert KeyNotFoundException wenn Name/DOB zwischen zwei Laeufen driftet (z.B. correctDob-Remap oder UI-Umbenennung). Fallback: ExternalRef-Dict-Lookup; bei Miss: sauberes Ueberspringen + Note statt 500. +Test CR9_NameDOB_drift. CR-11 (major): Farbschlag aus Genotyp ableiten (fill-NULL-only) Deep-Band-Tiere (gen>=2, kein Farbschlag-Feld) landen nicht laenger mit null ColorVariety. GenotypePotentiallyMatches() vergleicht locus-pair-weise (??=wildcard, case-insensitive). Nur vollstaendige Genotypen (8 Loci, kein ??) loesen Ableitung aus. Plan-Loop: fuellt colorVarietyId bei null + vollstaendigem Genotyp. Post-Sweep: bestehende DB-Tiere mit null ColorVarietyId werden nachgefuellt. AnimalSummary.FarbschlagDerivedFromGenotype = Zaehler. +Test CR11_ColorVariety_from_geno. CR-10 (major, Python): malformed Override-Genotyp wird nicht angewendet apply_conflict_decisions validiert mapped8locus nach gt.parse(). Leeres Ergebnis = Genotyp unveraendert + decisionWarning statt stillem Blanken. Konflikt wird trotzdem aufgeloest (Entscheidung gilt, nur Genotyp-Override ausgelassen). +5 Python-Tests (CR-10-Block in test_extract.py). DB-1 (high): filtered unique index auf Gerbil.ExternalRef WHERE ExternalRef IS NOT NULL — verhindert doppelten Import bei Race-Conditions oder Lauf-Ueberschneidungen. Migration UniqueExternalRef. SQLite-Testhost: HasFilter() wird via EnsureCreated appliziert (SQLite unterstuetzt Partial-Indexes). PartialUpdateTests externalRef-Assertion auf NotNull geaendert (name-hash unique). GATE: 139/139 C# + Python ALL PASS, ef has-pending=No.
This commit is contained in:
@@ -580,6 +580,74 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(new DateOnly(y, m, d), date);
|
Assert.Equal(new DateOnly(y, m, d), date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||||
|
{
|
||||||
|
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||||
|
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||||
|
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||||
|
// stable ExternalRef without throwing.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
// Run 1: load the animal normally
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||||
|
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||||
|
g.Name = "Umbenannt Tier";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||||
|
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||||
|
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||||
|
{
|
||||||
|
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||||
|
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||||
|
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
// Exact Agouti genotype, no explicit Farbschlag name
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||||
|
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||||
|
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||||
|
Assert.NotNull(tier.ColorVarietyId);
|
||||||
|
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||||
|
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||||
|
Assert.Equal("Agouti", variety!.Name);
|
||||||
|
// Report counter should reflect the genotype derivation
|
||||||
|
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
// ---- fixtures ----
|
// ---- fixtures ----
|
||||||
private const string LittersJson = """
|
private const string LittersJson = """
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||||
{
|
{
|
||||||
|
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||||
|
// when multiple tests in the same fixture share the SQLite connection.
|
||||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
@@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
characterNote = "Liebling der Familie",
|
characterNote = "Liebling der Familie",
|
||||||
notes = "Eine Notiz",
|
notes = "Eine Notiz",
|
||||||
externalRef = "ext-001",
|
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||||
}));
|
}));
|
||||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
@@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(g => g.ColorVariety).WithMany()
|
e.HasOne(g => g.ColorVariety).WithMany()
|
||||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||||
|
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||||
|
e.HasIndex(g => g.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Litter>(e =>
|
modelBuilder.Entity<Litter>(e =>
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int AlreadyImported,
|
int AlreadyImported,
|
||||||
QuarantineSummary Quarantined,
|
QuarantineSummary Quarantined,
|
||||||
int ParentLinksFromChart = 0,
|
int ParentLinksFromChart = 0,
|
||||||
int ConflictsResolvedByDecision = 0);
|
int ConflictsResolvedByDecision = 0,
|
||||||
|
int FarbschlagDerivedFromGenotype = 0);
|
||||||
|
|
||||||
public sealed record QuarantineSummary(
|
public sealed record QuarantineSummary(
|
||||||
int Conflicts,
|
int Conflicts,
|
||||||
|
|||||||
@@ -145,25 +145,63 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||||
|
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||||
|
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||||
|
|
||||||
|
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||||
|
var varietiesWithGeno = await _db.ColorVarieties
|
||||||
|
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||||
|
|
||||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
|
int fbDerivedFromGenotype = 0;
|
||||||
foreach (var a in loadable)
|
foreach (var a in loadable)
|
||||||
{
|
{
|
||||||
bool exists = existingGerbilSet.Contains(a.Id);
|
bool exists = existingGerbilSet.Contains(a.Id);
|
||||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||||
|
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||||
|
Guid gid;
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||||
|
{
|
||||||
|
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||||
|
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else gid = Guid.NewGuid();
|
||||||
|
|
||||||
Guid? wurfLitterId = null;
|
Guid? wurfLitterId = null;
|
||||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||||
wurfLitterId = lid;
|
wurfLitterId = lid;
|
||||||
|
|
||||||
|
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||||
|
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||||
Guid? colorVarietyId = null;
|
Guid? colorVarietyId = null;
|
||||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||||
foreach (var fb in fbCandidates)
|
foreach (var fb in fbCandidates)
|
||||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||||
{ colorVarietyId = vid; break; }
|
{ colorVarietyId = vid; break; }
|
||||||
|
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||||
|
{
|
||||||
|
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||||
|
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||||
|
// would match any catalog entry via wildcards and produce false positives.
|
||||||
|
var composed = ComposeGenotype(a.Genotype);
|
||||||
|
if (!composed.Contains("??"))
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||||
|
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||||
|
}
|
||||||
|
|
||||||
var gender = InferGender(a, sireNames, damNames);
|
var gender = InferGender(a, sireNames, damNames);
|
||||||
var norm = Normalize(StripZucht(a.Name));
|
var norm = Normalize(StripZucht(a.Name));
|
||||||
@@ -469,6 +507,33 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||||
|
|
||||||
|
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||||
|
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||||
|
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||||
|
{
|
||||||
|
var noColor = await _db.Gerbils
|
||||||
|
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||||
|
.Select(g => new { g.Id, g.Genotype })
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var g in noColor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||||
|
Guid? derivedVid = null;
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||||
|
{ derivedVid = v.Id; break; }
|
||||||
|
if (derivedVid is null) continue;
|
||||||
|
fbDerivedFromGenotype++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
// 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'.
|
// 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").
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
@@ -524,6 +589,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
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).");
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
|
if (fbDerivedFromGenotype > 0)
|
||||||
|
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
@@ -532,7 +599,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
parentLinksAdded, conflictsResolvedByDecision),
|
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
@@ -570,6 +637,23 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||||
|
|
||||||
|
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||||
|
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||||
|
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||||
|
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||||
|
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||||
|
{
|
||||||
|
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (a.Length < 8 || c.Length < 8) return false;
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if (a[i] == "??" || c[i] == "??") continue;
|
||||||
|
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||||
{
|
{
|
||||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||||
|
|||||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -807,6 +807,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EnclosureId");
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("LitterId");
|
b.HasIndex("LitterId");
|
||||||
|
|
||||||
b.HasIndex("OriginContactId");
|
b.HasIndex("OriginContactId");
|
||||||
|
|||||||
@@ -973,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
if d.get("genotype"):
|
if d.get("genotype"):
|
||||||
a["genotype"] = gt.parse(d["genotype"])
|
# CR-10: validate the parsed genotype — a typo'd decision string yields empty
|
||||||
|
# mapped8locus and would silently blank the animal's genotype while marking it
|
||||||
|
# 'resolved'. Only apply if the parse produces non-empty loci.
|
||||||
|
parsed = gt.parse(d["genotype"])
|
||||||
|
if parsed.get("mapped8locus"):
|
||||||
|
a["genotype"] = parsed
|
||||||
|
else:
|
||||||
|
# Keep the existing genotype; flag as a warning in the report.
|
||||||
|
a.setdefault("decisionWarnings", []).append(
|
||||||
|
f"Ungültiger Override-Genotyp '{d['genotype']}' — "
|
||||||
|
"konnte nicht geparst werden (mapped8locus leer). "
|
||||||
|
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
|
||||||
|
)
|
||||||
if d.get("farbschlag"):
|
if d.get("farbschlag"):
|
||||||
a["farbschlag"] = d["farbschlag"]
|
a["farbschlag"] = d["farbschlag"]
|
||||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||||
|
|||||||
@@ -280,6 +280,36 @@ 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", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||||
|
|
||||||
|
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
|
||||||
|
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
# Valid decision (genotype parses OK) -> should be applied
|
||||||
|
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
|
||||||
|
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
|
||||||
|
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
|
||||||
|
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
|
||||||
|
"genotype": "BLÖDSINN!!!", "source": "test"},
|
||||||
|
]}, open(dec_cr10, "w", encoding="utf-8"))
|
||||||
|
merged_cr10 = [
|
||||||
|
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
|
||||||
|
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
|
||||||
|
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
|
||||||
|
check("CR-10: valid decision genotype is applied (A-locus updated)",
|
||||||
|
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
|
||||||
|
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
|
||||||
|
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
|
||||||
|
check("CR-10: malformed decision still un-quarantines the animal",
|
||||||
|
merged_cr10[1].get("conflict") is False)
|
||||||
|
check("CR-10: malformed decision adds a decisionWarning",
|
||||||
|
bool(merged_cr10[1].get("decisionWarnings")))
|
||||||
|
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
|
||||||
|
try: os.remove(dec_cr10)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||||
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
# 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
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||||
|
|||||||
Reference in New Issue
Block a user