OWNERSHIP: residency classification (Bestand vs external ancestors)

Per hive/agents/god/OWNERSHIP-residency.md (Julian): "Clan kleine Chaoten"
is the wife's own Zucht. Classify each animal:
 (a) RESIDENT if zuchtCanon contains 'kleinechaote' (separator/declension-
     insensitive: "v.d. Kleinen Chaoten", "Clan-Kleine-Chaoten", …); OR
 (b) PARENT EXCEPTION — a parent of a Clan offspring is resident even if its
     own Zuchtname is foreign (you can only breed a Clan litter if the parents
     were in your care). Runs AFTER the parentRefs→litter linking.
Otherwise EXTERNAL (pedigree ancestor, not living stock — kept, not deleted).

Stored as a dedicated Gerbil.IsResident bool (distinct from Status/Abgabe —
origin/identity, not current location), defaulting true (own stock unless
marked external; DB HasDefaultValue(true)). Additive migration
AddGerbilResidency; has-pending-model-changes clean. Round-trips in
GerbilDto/GerbilInput and is Gridify-filterable (isResident==true) so Kevin
can build a "nur Bestand" filter. ImportService computes it at (re-)import
and refreshes existing rows on the idempotent sweep. ResidencySummary added
to the import report.

Projected on real data: 306 loadable → 227 resident (188 rule a, +39 via
parent exception), 79 external ancestors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:58:29 +02:00
parent ea79703dbf
commit 6775707387
10 changed files with 1504 additions and 11 deletions

View File

@@ -192,6 +192,17 @@ namespace GerbilManagerWebAPI.Import
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.
if (execute)
{
@@ -200,7 +211,10 @@ namespace GerbilManagerWebAPI.Import
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
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())
{
@@ -226,11 +240,31 @@ namespace GerbilManagerWebAPI.Import
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)
{
Guid? litterId = p.WurfLitterId
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
if (litterId is not null) linked++;
bool isResident = resident.Contains(p.Gid);
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
@@ -242,11 +276,15 @@ namespace GerbilManagerWebAPI.Import
if (p.Exists)
{
animalsExisting++;
// re-link an existing animal that just became linkable (sweep idempotency).
if (execute && p.CurrentLitterId is null && litterId is not null)
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
if (execute)
{
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;
}
@@ -266,6 +304,7 @@ namespace GerbilManagerWebAPI.Import
ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag,
ExternalRef = p.A.Id,
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.");
if (parentLinksAdded > 0)
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.");
return new ImportReport(
@@ -339,7 +379,8 @@ namespace GerbilManagerWebAPI.Import
parentLinksAdded),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes);
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
}
private T? Load<T>(string file)