diff --git a/GerbilManager.Tests/Rpro3ImportTests.cs b/GerbilManager.Tests/Rpro3ImportTests.cs index c17cfbd..6b0a0d4 100644 --- a/GerbilManager.Tests/Rpro3ImportTests.cs +++ b/GerbilManager.Tests/Rpro3ImportTests.cs @@ -89,6 +89,73 @@ namespace GerbilManager.Tests Assert.Equal(2, r.Placeholders.Count); } + [Fact] + public void Dedup_force_merges_variants_declared_same_by_breeder() + { + // Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt … + var animals = new List + { + Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B + Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C + }; + // ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt) + Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters); + + // … werden per „Same"-Entscheidung der Züchterin zusammengelegt. + var decisions = new Rpro3Decisions + { + Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } } + }; + var r = Rpro3Dedup.Run(animals, decisions); + Assert.Single(r.MergeClusters); + Assert.Equal(2, r.MergeClusters.Values.First().Count); + Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]); + } + + [Fact] + public void Dedup_force_splits_variants_declared_different_by_breeder() + { + // Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) … + var animals = new List + { + Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"), + Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"), + }; + Assert.Single(Rpro3Dedup.Run(animals).MergeClusters); + + // … bleiben durch „Different" getrennt. + var decisions = new Rpro3Decisions + { + Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } } + }; + var r = Rpro3Dedup.Run(animals, decisions); + Assert.Empty(r.MergeClusters); + Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]); + } + + [Fact] + public void Rpro3Decisions_load_returns_empty_when_file_missing() + { + var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json")); + Assert.Empty(d.Decisions); + } + + [Fact] + public void Rpro3Decisions_roundtrips_through_json() + { + var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json"); + File.WriteAllText(path, + "{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}]}"); + try + { + var d = Rpro3Decisions.Load(path); + Assert.Single(d.Decisions); + Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin); + Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First()); + } + finally { File.Delete(path); } + } + // ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ──── [Fact] diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index 03de1b1..bc9d6fa 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -36,6 +36,12 @@ + + + + + diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs new file mode 100644 index 0000000..119f76a --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GerbilManagerWebAPI.Import.Rpro3 +{ + /// + /// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten + /// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische + /// ist konservativ und legt nur bei positiver Evidenz zusammen; + /// diese Overrides erlauben der Züchterin, gezielt zu korrigieren: + /// + /// • Same — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind + /// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt, + /// der ganze Cluster wird mitgezogen). + /// • Different— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches + /// Zusammenlegen über Gruppengrenzen). + /// • Fields — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/ + /// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters. + /// + /// Datenquelle: Import/Rpro3/rpro3-decisions.json (siehe ). + /// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben + /// verschieben sich, sobald sich die Clusterbildung ändert. + /// + public sealed class Rpro3Decisions + { + public List Decisions { get; set; } = new(); + + public static Rpro3Decisions Empty { get; } = new(); + + /// Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt, + /// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup). + public static Rpro3Decisions Load(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return Empty; + try + { + var json = File.ReadAllText(path); + var d = JsonSerializer.Deserialize(json, JsonOpts); + return d ?? Empty; + } + catch (JsonException) + { + return Empty; + } + } + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) + { + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// Alle „Same"-rid-Gruppen (für Force-Merge). + public IEnumerable> SameGroups() + { + foreach (var d in Decisions) + foreach (var g in d.Same) + if (g.Count >= 2) yield return g; + } + + /// rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher + /// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden. + public Dictionary BuildSplitGroups() + { + var map = new Dictionary(StringComparer.Ordinal); + int gid = 0; + foreach (var d in Decisions) + { + if (d.Different.Count < 2) continue; + foreach (var grp in d.Different) + { + gid++; + foreach (var rid in grp) + map[rid] = gid; + } + } + return map; + } + + /// rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override). + public Dictionary BuildFieldIndex() + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var d in Decisions) + foreach (var (rid, ov) in d.Fields) + map[rid] = ov; + return map; + } + } + + public sealed class Rpro3Decision + { + /// Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids). + public string? Name { get; set; } + /// Ticket-Id (Rückverfolgbarkeit). + public string? Ticket { get; set; } + public List> Same { get; set; } = new(); + public List> Different { get; set; } = new(); + public Dictionary Fields { get; set; } = new(); + } + + public sealed class Rpro3FieldOverride + { + public string? Color { get; set; } + public string? Dob { get; set; } // ISO yyyy-MM-dd + public string? Origin { get; set; } + public bool? Resident { get; set; } + /// Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB). + public string? Note { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs index 10fdb67..eb14ec7 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs @@ -65,9 +65,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3 return UnknownValues.Contains(v) ? "" : v; } - public static DedupResult Run(IReadOnlyList animals) + public static DedupResult Run(IReadOnlyList animals) => Run(animals, null); + + /// + /// Wie , berücksichtigt aber manuelle + /// Entscheidungen der Züchterin (): „Different"-Gruppen + /// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über + /// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit). + /// + public static DedupResult Run(IReadOnlyList animals, Rpro3Decisions? decisions) { var result = new DedupResult(); + decisions ??= Rpro3Decisions.Empty; + var splitGroup = decisions.BuildSplitGroups(); foreach (var a in animals) { @@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3 for (int j = i + 1; j < group.Count; j++) { var a = group[i]; var b = group[j]; + // Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen. + if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb) + continue; bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey); if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0) Union(a.Rid, b.Rid); } } + // Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen + // (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids, + // die es im Datensatz auch gibt, werden berücksichtigt. + var knownRids = new HashSet(animals.Select(a => a.Rid), StringComparer.Ordinal); + foreach (var grp in decisions.SameGroups()) + { + string? anchor = null; + foreach (var rid in grp) + { + if (!knownRids.Contains(rid)) continue; + parent.TryAdd(rid, rid); + if (anchor is null) anchor = rid; + else Union(anchor, rid); + } + } + // Cluster sammeln var clusters = new Dictionary>(); foreach (var a in animals) diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs index 61a70f5..e65cc05 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs @@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3 { private readonly ApplicationContext _db; private readonly string _photoRoot; + private readonly Rpro3Decisions _decisions; + private Dictionary? _fieldIndex; public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env) { _db = db; var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory(); _photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage"); + // Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad + // überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert). + var decPath = config["Rpro3:DecisionsPath"] + ?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json"); + _decisions = Rpro3Decisions.Load(decPath); } + private Dictionary FieldIndex => + _fieldIndex ??= _decisions.BuildFieldIndex(); + // ───────────────────────── ANALYZE ───────────────────────── public async Task AnalyzeAsync( Rpro3Data data, bool photosProvided, IReadOnlySet? availablePhotoFiles) { - var dedup = Rpro3Dedup.Run(data.Animals); + var dedup = Rpro3Dedup.Run(data.Animals, _decisions); var plan = BuildPlan(data, dedup); // Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz. @@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3 public async Task ExecuteAsync( Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary? photoSourcePaths) { - var dedup = Rpro3Dedup.Run(data.Animals); + var dedup = Rpro3Dedup.Run(data.Animals, _decisions); var plan = BuildPlan(data, dedup); // Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem @@ -407,6 +417,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3 var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin; var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1); + // Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE + // rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt. + Rpro3FieldOverride? ov = null; + foreach (var m in members) + if (FieldIndex.TryGetValue(m.Rid, out ov)) break; + bool? residentOverride = ov?.Resident; + if (ov is not null) + { + if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color; + if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin; + if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od; + } + var gp = new GerbilPlan { Id = id, @@ -419,7 +442,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3 Genotype = CleanGenotype(fcode), OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null), OriginContactId = HerkContactId(herkId), - IsResident = resident, + IsResident = residentOverride ?? resident, IsCastrated = members.Any(m => m.IsCastrated), MotherRid = rep.MidRaw, FatherRid = rep.PidRaw, @@ -440,6 +463,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3 } gp.Notes = BuildNotes(members); + if (!string.IsNullOrWhiteSpace(ov?.Note)) + gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}"; gp.Provenance = BuildProvenance(data, members, dedup); plan.Gerbils[id] = gp; } diff --git a/GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json b/GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json new file mode 100644 index 0000000..3985bc7 --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json @@ -0,0 +1,223 @@ +{ + "decisions": [ + { + "name": "Eiji", + "ticket": "6aad4527-0401-43de-bda9-f649042bd1db", + "same": [ + [ + "u735", + "u469" + ] + ] + }, + { + "name": "Momo", + "ticket": "7937b283-2056-435d-8821-c68ada8b1bd2", + "same": [ + [ + "u7453", + "u7345" + ] + ], + "different": [ + [ + "u1014" + ], + [ + "u7453" + ] + ] + }, + { + "name": "Samuel", + "ticket": "6b3f28c6-47a5-4752-be94-93cda5d6d29c", + "same": [ + [ + "u2207", + "u263" + ] + ], + "different": [ + [ + "u2207" + ], + [ + "u144" + ] + ] + }, + { + "name": "Female", + "ticket": "2d77eefe-f3a1-4be4-a8ee-7cb73cee14ab", + "different": [ + [ + "u649" + ], + [ + "u683" + ], + [ + "u647" + ], + [ + "u685" + ] + ] + }, + { + "name": "Kennedy", + "ticket": "e5fc2c01-d443-445b-8530-ff5b86be27b1", + "same": [ + [ + "354", + "u6929" + ] + ], + "fields": { + "354": { + "color": "Blau, meliert-Starkschecke" + } + } + }, + { + "name": "Fegur", + "ticket": "746a965b-a2e7-4d88-a497-640784b66818", + "same": [ + [ + "231", + "u6395" + ] + ] + }, + { + "name": "Tuli", + "ticket": "06dd2e3d-5bed-469a-b975-d698712fd8ac", + "same": [ + [ + "229", + "u2255" + ] + ] + }, + { + "name": "Bura", + "ticket": "7dbeb108-453d-4930-b615-a1054f0df8ad", + "same": [ + [ + "228", + "u2128" + ] + ], + "fields": { + "228": { + "origin": "Sarah Wörz" + } + } + }, + { + "name": "Mister X", + "ticket": "ebd80367-a80c-426e-9541-1325274ba206", + "same": [ + [ + "227", + "u7065" + ] + ], + "fields": { + "227": { + "origin": "Sarah Wörz" + } + } + }, + { + "name": "Zoey", + "ticket": "3ad458f7-645f-447f-b217-7b3b7382f380", + "different": [ + [ + "224" + ], + [ + "u2887" + ] + ], + "fields": { + "224": { + "origin": "Sarah Wörz" + } + } + }, + { + "name": "Max", + "ticket": "625a86ab-51d8-4612-8292-5dbd93be2a5c", + "different": [ + [ + "133" + ], + [ + "u7533" + ] + ] + }, + { + "name": "Akiro", + "ticket": "9216ab27-407d-4f4c-abb2-51160fa33683", + "same": [ + [ + "u1227", + "u1865" + ] + ], + "different": [ + [ + "u1339" + ], + [ + "u1227" + ] + ], + "fields": { + "u1227": { + "dob": "2008-02-05", + "color": "Polarfuchs, hell", + "note": "Abweichendes Geburtsdatum in RennmausPro: 2008-02-06 (laut Variante C)." + } + } + }, + { + "name": "Merlin", + "ticket": "d748b16a-18b5-48ec-b8a6-a4c8df66ad80", + "same": [ + [ + "u2859", + "u2626" + ], + [ + "u7537", + "u7536" + ] + ], + "fields": { + "u7537": { + "dob": "2021-02-10", + "note": "Abweichendes Geburtsdatum in RennmausPro: 2012-08-07 (laut Variante G)." + } + } + }, + { + "name": "Snickers", + "ticket": "f469352a-644b-43a1-9fb2-884600e4b9d3", + "same": [ + [ + "384", + "u6937" + ] + ], + "fields": { + "384": { + "resident": false, + "note": "Reiner Zuchtvorfahre (kein eigenes Zuchttier)." + } + } + } + ] +} \ No newline at end of file diff --git a/tools/import/rpro3_lookup.py b/tools/import/rpro3_lookup.py new file mode 100644 index 0000000..a55bdbb --- /dev/null +++ b/tools/import/rpro3_lookup.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Triage-Helfer: Beantwortet die Gegenfragen der Züchterin zu RPRO3-Namensdubletten. + +Liefert pro Variante (A/B/C … exakt wie im Rückfrage-Ticket) die Daten, die die +Züchterin typischerweise sehen will: Geburtsdatum, Farbe, **Gencode (Fcode)**, +Herkunft, Eltern (Vater/Mutter) sowie Nachzucht inkl. Co-Elternteil (Partner). + +Die Variantenbildung/Label-Vergabe spiegelt rpro3_tickets.py + compare_rpro3.dedup +exakt (Union-Find über gleiche Namen; Filter „informativ"; Sortierung +(not is_own, -count) → A,B,C…). Im Gegensatz zum Ticket wird hier über den GESAMTEN +Cluster aggregiert (rpro3_tickets kappt rids auf 4 – Nachzucht wäre sonst unvollständig). + +Aufruf: + python rpro3_lookup.py <_rpro3.db> "" [LETTERS] + LETTERS optional, z. B. "B,C" → nur diese Varianten. Default: alle. + python rpro3_lookup.py <_rpro3.db> "" --json → strukturiert (für Agenten) +""" +from __future__ import annotations +import sys, json +from collections import defaultdict +import compare_rpro3 as C + +LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + +def informative(recs): + return any(r["dob"] or r["farbe"] or r["origin"] for r in recs) + + +def build_clusters(animals): + """Spiegelt compare_rpro3.dedup: Union-Find je Name, gibt {name: [cluster_recs,...]}.""" + def compat(x, y): + return (not x) or (not y) or (x == y) + + for a in animals: + a["namek"] = C.norm_name(a["name"]) + a["dobk"] = a["dob"] + a["farbek"] = C.norm_farbe(a["farbe"]) + a["origink"] = C.norm_origin(a["origin"]) + + by_name = defaultdict(list) + for a in animals: + if a["namek"] in C.PLACEHOLDER_NAMES: + continue + by_name[a["namek"]].append(a) + + parent = {} + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + def union(x, y): + parent.setdefault(x, x); parent.setdefault(y, y) + parent[find(x)] = find(y) + + def positive(a, b): + agree = 0 + if a["dobk"] and b["dobk"] and a["dobk"] == b["dobk"]: + agree += 1 + if a["farbek"] and b["farbek"] and a["farbek"] == b["farbek"]: + agree += 1 + if a["origink"] and b["origink"] and a["origink"] == b["origink"]: + agree += 1 + return agree + + def conflict(a, b): + c = 0 + if a["dobk"] and b["dobk"] and a["dobk"] != b["dobk"]: + c += 1 + if a["farbek"] and b["farbek"] and a["farbek"] != b["farbek"]: + c += 1 + if a["origink"] and b["origink"] and a["origink"] != b["origink"]: + c += 1 + return c + + for name, group in by_name.items(): + for a in group: + parent.setdefault(a["rid"], a["rid"]) + n = len(group) + for i in range(n): + for j in range(i + 1, n): + a, b = group[i], group[j] + comp = (compat(a["dobk"], b["dobk"]) and compat(a["farbek"], b["farbek"]) + and compat(a["origink"], b["origink"])) + if comp and positive(a, b) >= 1 and conflict(a, b) == 0: + union(a["rid"], b["rid"]) + + # Cluster je Name sammeln + name_clusters = {} + by_rid = {a["rid"]: a for a in animals} + for name, group in by_name.items(): + roots = defaultdict(list) + for a in group: + roots[find(a["rid"])].append(a) + name_clusters[name] = list(roots.values()) + return name_clusters + + +def variants_for(animals, display_name): + """Liefert [(letter, recs)] für einen Namen – Reihenfolge wie im Ticket.""" + namek = C.norm_name(display_name) + clusters = build_clusters(animals).get(namek, []) + info = [recs for recs in clusters if informative(recs)] + # Sortierung exakt wie rpro3_tickets.py: eigene Tiere zuerst, dann größere Cluster + info.sort(key=lambda recs: (not any(r["src"] == "stamm" for r in recs), -len(recs))) + return [(LETTERS[i], recs) for i, recs in enumerate(info)] + + +def aggregate(recs, animals): + """Aggregiert Daten + Nachzucht über alle recs eines Clusters.""" + names = {r["name"] for r in recs} + dob = sorted({C.iso(r["dob"]) for r in recs if r["dob"]}) + farbe = sorted({r["farbe"] for r in recs if r["farbe"]}) + fcode = sorted({r["fcode"] for r in recs if r["fcode"]}) + origin = sorted({r["origin"] for r in recs if r["origin"]}) + fathers = sorted({r["father"] for r in recs if r["father"]}) + mothers = sorted({r["mother"] for r in recs if r["mother"]}) + is_own = any(r["src"] == "stamm" for r in recs) + rids = [r["rid"] for r in recs] + + # Nachzucht: alle Tiere, deren Eltern-rid auf eine rid dieses Clusters zeigt + rid_set = set(rids) + kids = [] + seen = set() + for a in animals: + if str(a.get("mid_raw")) in rid_set or str(a.get("pid_raw")) in rid_set: + co = a["father"] if a["mother"] in names else a["mother"] + key = (C.norm_name(a["name"]), C.iso(a["dob"]) if a["dob"] else "") + if key in seen: + continue + seen.add(key) + kids.append({"name": a["name"], "dob": C.iso(a["dob"]) if a["dob"] else None, + "farbe": a["farbe"], "fcode": a["fcode"], "partner": co}) + return {"names": sorted(names), "rids": rids, "count": len(recs), + "is_own": is_own, "dob": dob, "farbe": farbe, "fcode": fcode, + "origin": origin, "fathers": fathers, "mothers": mothers, "kids": kids} + + +def fmt_block(letter, agg): + src = "eigenes Tier" if agg["is_own"] else "externer Ahn" + L = [] + L.append(f"**{letter}** ({src}, {agg['count']}× in RennmausPro):") + L.append(f"• Geburtsdatum: {', '.join(agg['dob']) or 'unbekannt'}") + L.append(f"• Farbe: {', '.join(agg['farbe']) or 'unbekannt'}") + L.append(f"• Gencode: {', '.join(agg['fcode']) or 'unbekannt'}") + L.append(f"• Herkunft: {', '.join(agg['origin']) or 'unbekannt'}") + vat = ', '.join(agg['fathers']) or 'unbekannt' + mut = ', '.join(agg['mothers']) or 'unbekannt' + L.append(f"• Eltern: Vater {vat} · Mutter {mut}") + if agg["kids"]: + L.append("• Nachzucht:") + for k in agg["kids"]: + d = k["dob"] or "?" + f = k["farbe"] or "?" + gc = f" [{k['fcode']}]" if k["fcode"] else "" + p = f" — Partner: {k['partner']}" if k["partner"] else "" + L.append(f" – {k['name']} (geb. {d}, {f}{gc}){p}") + else: + L.append("• Nachzucht: keine in RennmausPro hinterlegt") + return "\n".join(L) + + +def variants_by_rids(animals, rid_groups): + """rid_groups: Liste von rid-Listen (eine je Variante/Buchstabe). Aggregiert pro Gruppe + über den GANZEN Auto-Cluster (eine rid zieht ihren Cluster mit). Für Platzhalter-Namen + („...") und Freitext-Tickets, wo der Name nicht greift, aber rids bekannt sind.""" + from collections import defaultdict + # Auto-Cluster wie build_clusters, aber global über rids indizieren + clusters = build_clusters(animals) + rid_to_recs = {} + for recs_list in clusters.values(): + for recs in recs_list: + for r in recs: + rid_to_recs[r["rid"]] = recs + out = [] + for i, grp in enumerate(rid_groups): + merged, seen = [], set() + for rid in grp: + for r in rid_to_recs.get(rid, []): + if r["rid"] not in seen: + seen.add(r["rid"]); merged.append(r) + if merged: + out.append((LETTERS[i], merged)) + return out + + +def main(): + db = sys.argv[1] + name = sys.argv[2] + # --rids "u84,u83;u1" → Variante A=u84,u83 Variante B=u1 (per Semikolon getrennt) + if name == "--rids": + rid_spec = sys.argv[3] + R = C.load_rpro3(db) + groups = [[x.strip() for x in g.split(",") if x.strip()] for g in rid_spec.split(";") if g.strip()] + vs = variants_by_rids(R["animals"], groups) + print("=== (per rids) ===") + for letter, recs in vs: + print(fmt_block(letter, aggregate(recs, R["animals"]))) + print() + return + as_json = "--json" in sys.argv[3:] + letters = None + for a in sys.argv[3:]: + if a != "--json": + letters = {x.strip().upper() for x in a.split(",") if x.strip()} + + R = C.load_rpro3(db) + animals = R["animals"] + vs = variants_for(animals, name) + if not vs: + print(f"(keine Varianten für „{name}“ gefunden)") + return + out = [] + for letter, recs in vs: + if letters and letter not in letters: + continue + agg = aggregate(recs, animals) + out.append((letter, agg)) + if as_json: + print(json.dumps({"name": name, "variants": {l: a for l, a in out}}, + ensure_ascii=False, indent=1)) + else: + print(f"=== {name} ===") + for letter, agg in out: + print(fmt_block(letter, agg)) + print() + + +if __name__ == "__main__": + main()