Merge feature/pedigree-link residency: Gerbil.IsResident (Bestand vs external ancestors, parent-exception), AddGerbilResidency migration [god-QA pending]
This commit is contained in:
@@ -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 { } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user