feat(import): RPRO3-Dubletten-Entscheidungen der Züchterin als Override

Manuelle "gleich/verschieden?"-Entscheidungen aus den rpro3-import-Tickets
fließen jetzt in den RennmausPro-III-Import ein:

- Rpro3Decisions (Modell + JSON-Loader): same (Force-Merge), different
  (Force-Split), fields (Farbe/DOB/Herkunft/resident/Notiz). Schlüssel = rid.
- Rpro3Dedup.Run(animals, decisions): honoriert die Overrides nach dem
  automatischen Dedup (eine rid zieht ihren Cluster mit).
- Rpro3ImportService: lädt Import/Rpro3/rpro3-decisions.json, wendet
  Feld-Overrides im Plan-Builder an.
- rpro3-decisions.json: 14 bestätigte Entscheidungen (Eiji, Momo, Samuel,
  Female, Kennedy, Fegur, Tuli, Bura, Mister X, Zoey, Max, Akiro, Merlin,
  Snickers).
- tools/import/rpro3_lookup.py: Triage-Helfer (Farbe/Gencode/Eltern/
  Nachzucht+Partner aus _rpro3.db) für die Rückfragen an die Züchterin.
- 4 neue Tests (Force-Merge/Force-Split/Loader); Suite 268 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:26:50 +02:00
parent bbd305861f
commit a333bce1b3
7 changed files with 699 additions and 4 deletions

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
///
/// • <c>Same</c> — 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).
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
/// Zusammenlegen über Gruppengrenzen).
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
///
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
/// verschieben sich, sobald sich die Clusterbildung ändert.
/// </summary>
public sealed class Rpro3Decisions
{
public List<Rpro3Decision> Decisions { get; set; } = new();
public static Rpro3Decisions Empty { get; } = new();
/// <summary>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).</summary>
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<Rpro3Decisions>(json, JsonOpts);
return d ?? Empty;
}
catch (JsonException)
{
return Empty;
}
}
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
public IEnumerable<IReadOnlyList<string>> SameGroups()
{
foreach (var d in Decisions)
foreach (var g in d.Same)
if (g.Count >= 2) yield return g;
}
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
public Dictionary<string, int> BuildSplitGroups()
{
var map = new Dictionary<string, int>(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;
}
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
{
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
foreach (var d in Decisions)
foreach (var (rid, ov) in d.Fields)
map[rid] = ov;
return map;
}
}
public sealed class Rpro3Decision
{
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
public string? Name { get; set; }
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
public string? Ticket { get; set; }
public List<List<string>> Same { get; set; } = new();
public List<List<string>> Different { get; set; } = new();
public Dictionary<string, Rpro3FieldOverride> 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; }
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
public string? Note { get; set; }
}
}