Merge feature/pedigree-link residency: Gerbil.IsResident (Bestand vs external ancestors, parent-exception), AddGerbilResidency migration [god-QA pending]

This commit is contained in:
2026-06-06 11:01:05 +02:00
10 changed files with 1504 additions and 11 deletions

View File

@@ -143,11 +143,13 @@ namespace GerbilManager.Tests
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """ File.WriteAllText(Path.Combine(dir, "animals.json"), """
[ [
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male", {"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}, "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female", {"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}, "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"", {"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false, "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[ "parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"}, {"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
@@ -174,6 +176,17 @@ namespace GerbilManager.Tests
Assert.Equal(papa.Id, litter.FatherId); Assert.Equal(papa.Id, litter.FatherId);
Assert.Equal(mama.Id, litter.MotherId); Assert.Equal(mama.Id, litter.MotherId);
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
// resident (rule b); the unrelated foreign animal stays external.
Assert.True(c.IsResident); // rule (a)
Assert.True(papa.IsResident); // rule (b) parent exception
Assert.True(mama.IsResident); // rule (b)
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
Assert.NotNull(report.Residency);
Assert.Equal(3, report.Residency!.Resident);
Assert.Equal(1, report.Residency.External);
Assert.Equal(2, report.Residency.FlippedByParentRule);
} }
finally { try { Directory.Delete(dir, recursive: true); } catch { } } finally { try { Directory.Delete(dir, recursive: true); } catch { } }
} }

View File

@@ -55,6 +55,9 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>(); e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>(); e.Property(g => g.Status).HasConversion<string>();
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// FEAT-14: character traits stored as a JSON text column (works on both // FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary). // Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>( var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(

View File

@@ -28,7 +28,8 @@ namespace GerbilManagerWebAPI.Dtos
string? OriginBreeder, string? OriginBreeder,
List<string> CharacterTraits, List<string> CharacterTraits,
string? CharacterNote, string? CharacterNote,
bool? IsDeaf); bool? IsDeaf,
bool IsResident);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
@@ -77,7 +78,8 @@ namespace GerbilManagerWebAPI.Dtos
string? OriginBreeder, string? OriginBreeder,
List<string>? CharacterTraits, List<string>? CharacterTraits,
string? CharacterNote, string? CharacterNote,
bool? IsDeaf); bool? IsDeaf,
bool? IsResident);
public record LitterInput( public record LitterInput(
string Name, string Name,

View File

@@ -98,12 +98,13 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterTraits = i.CharacterTraits ?? new List<string>(); g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote; g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf; g.IsDeaf = i.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
} }
internal static GerbilDto ToDto(Gerbil g) => new( internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf); g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
} }
} }

View File

@@ -16,6 +16,7 @@ namespace GerbilManagerWebAPI.Import
public List<string> FarbschlagVariants { get; set; } = new(); public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new(); public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = ""; 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<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new(); public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new(); public List<string> SourceFiles { get; set; } = new();
@@ -73,7 +74,12 @@ namespace GerbilManagerWebAPI.Import
AnimalSummary Animals, AnimalSummary Animals,
PhotoSummary Photos, PhotoSummary Photos,
IReadOnlyList<string> Samples, IReadOnlyList<string> Samples,
IReadOnlyList<string> Notes); 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); public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, int DerivedFromChart = 0);

View File

@@ -192,6 +192,17 @@ namespace GerbilManagerWebAPI.Import
parentLinksAdded++; parentLinksAdded++;
} }
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters.
// Used by the residency rule (b) below; augmented with existing DB litters under execute.
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
foreach (var sl in synthLitters.Values)
litterParents[sl.Id] = (sl.Father, sl.Mother);
foreach (var sl in litters)
if (litterIdMap.TryGetValue(sl.Id, out var lid))
litterParents[lid] = (
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) ? fid : (Guid?)null,
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) ? mid : (Guid?)null);
// PASS 2: write (litters synthesized first so offspring FK resolves), then animals + photos. // PASS 2: write (litters synthesized first so offspring FK resolves), then animals + photos.
if (execute) if (execute)
{ {
@@ -200,7 +211,10 @@ namespace GerbilManagerWebAPI.Import
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync(); .Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
var litterByParentsDate = new Dictionary<string, Guid>(); var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows) foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id; litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
litterParents[l.Id] = (l.FatherId, l.MotherId);
}
foreach (var sl in synthLitters.Values.ToList()) foreach (var sl in synthLitters.Values.ToList())
{ {
@@ -226,11 +240,31 @@ namespace GerbilManagerWebAPI.Import
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
static bool ClanCanon(string? zc) =>
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
var resident = new HashSet<Guid>();
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
int residentByA = resident.Count, flippedByParentRule = 0;
foreach (var p in plan)
{
if (!ClanCanon(p.A.ZuchtCanon)) continue;
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
}
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
int externalTotal = plan.Count - residentTotal;
foreach (var p in plan) foreach (var p in plan)
{ {
Guid? litterId = p.WurfLitterId Guid? litterId = p.WurfLitterId
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null); ?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
if (litterId is not null) linked++; if (litterId is not null) linked++;
bool isResident = resident.Contains(p.Gid);
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++; if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
@@ -242,11 +276,15 @@ namespace GerbilManagerWebAPI.Import
if (p.Exists) if (p.Exists)
{ {
animalsExisting++; animalsExisting++;
// re-link an existing animal that just became linkable (sweep idempotency). // sweep idempotency: re-link a now-linkable animal + refresh its residency.
if (execute && p.CurrentLitterId is null && litterId is not null) if (execute)
{ {
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid); var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
if (row is not null) row.LitterId = litterId; if (row is not null)
{
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
row.IsResident = isResident;
}
} }
continue; continue;
} }
@@ -266,6 +304,7 @@ namespace GerbilManagerWebAPI.Import
ColorVarietyId = p.ColorVarietyId, ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(p.A.Genotype), Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf, IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag, ImportSource = ImportSourceTag,
ExternalRef = p.A.Id, ExternalRef = p.A.Id,
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(), OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
@@ -328,6 +367,7 @@ namespace GerbilManagerWebAPI.Import
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
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(
@@ -339,7 +379,8 @@ namespace GerbilManagerWebAPI.Import
parentLinksAdded), parentLinksAdded),
Photos: new PhotoSummary(photosAttached, photosMissing), Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples, Samples: samples,
Notes: notes); Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
} }
private T? Load<T>(string file) private T? Load<T>(string file)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGerbilResidency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsResident",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsResident",
table: "Gerbils");
}
}
}

View File

@@ -784,6 +784,11 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<bool?>("IsDeaf") b.Property<bool?>("IsDeaf")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsResident")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<Guid?>("LitterId") b.Property<Guid?>("LitterId")
.HasColumnType("uuid"); .HasColumnType("uuid");

View File

@@ -73,6 +73,13 @@ namespace GerbilManagerWebAPI.Models
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the /// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary> /// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
public bool? IsDeaf { get; set; } public bool? IsDeaf { get; set; }
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true;
} }
/// <summary>Shared normalisation for the separator-insensitive name search.</summary> /// <summary>Shared normalisation for the separator-insensitive name search.</summary>