using System.Text.Json; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Services; using Microsoft.EntityFrameworkCore; namespace GerbilManagerWebAPI.Import.Rpro3 { /// /// „First class" RennmausPro-III-Backup-Importer. Parst die `.backup` (SQLite), dedupliziert /// RPRO3-interne Dubletten (Port von compare_rpro3.py) und überführt die Daten in unser Modell. /// /// Zwei Operationen: /// AnalyzeAsync — schreibt NICHTS; liefert Zählungen, Top-Merges, mehrdeutige Namen, neu/vorhanden. /// ExecuteAsync — idempotenter Import (deterministische GUIDs + ExternalRef). Re-Run aktualisiert /// dieselben Zeilen; vorherige RPRO3-Importe (ImportSource="RennmausPro III") /// werden vor dem Neuladen entfernt. Spreadsheet-Importe bleiben unangetastet. /// public sealed class Rpro3ImportService { 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, _decisions); var plan = BuildPlan(data, dedup); // Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz. var existing = await _db.Gerbils.AsNoTracking() .Select(g => new { g.NameSearch, g.DateOfBirth, g.ExternalRef }) .ToListAsync(); var existingRefs = existing.Where(e => e.ExternalRef is not null) .Select(e => e.ExternalRef!).ToHashSet(StringComparer.Ordinal); var existingByName = existing .GroupBy(e => e.NameSearch ?? "") .ToDictionary(g => g.Key, g => g.Select(e => e.DateOfBirth).ToList()); int newCount = 0, existingCount = 0; foreach (var gp in plan.Gerbils.Values) { if (existingRefs.Contains(gp.ExternalRef)) { existingCount++; continue; } var key = GerbilSearch.Normalize(gp.Name); if (existingByName.TryGetValue(key, out var dobs) && DobMatch(gp.Dob, dobs)) existingCount++; else newCount++; } var counts = new Rpro3Counts( OwnAnimals: data.Animals.Count(a => a.Src == Rpro3Src.Stamm), ExternalRaw: data.Animals.Count(a => a.Src == Rpro3Src.Fremd), ExternalAfterDedup: plan.Gerbils.Values.Count(g => !g.IsResident), Litters: data.Litters.Count, Contacts: data.HerkContacts.Count + data.AbnContacts.Count, Genotypes: data.ColorStammCount + data.ColorExtCount, DuplicatesMerged: dedup.DuplicatesRemoved, MergeClusters: dedup.MergeClusters.Count, Enclosures: plan.Enclosures.Count, Acquisitions: plan.Acquisitions.Count, Reservations: plan.Reservations.Count, Returns: plan.Returns.Count, WaitingList: plan.WaitingList.Count, Exhibitions: plan.Exhibitions.Count); var topMerges = dedup.MergeClusters.Values .OrderByDescending(v => v.Count) .Take(25) .Select(v => { var rep = Rpro3Dedup.Representative(v); return new Rpro3MergeSample( rep.Name ?? "—", v.Count, string.Join(", ", v.Where(x => x.Dob is not null).Select(x => x.Dob!.Value.ToString("yyyy-MM-dd")).Distinct()), Truncate(string.Join(", ", v.Where(x => !string.IsNullOrEmpty(x.Farbe)).Select(x => x.Farbe!).Distinct()), 60), Truncate(string.Join(", ", v.Where(x => Rpro3Dedup.NormValue(x.Origin).Length > 0).Select(x => x.Origin!).Distinct()), 60)); }) .ToList(); var ambiguous = dedup.Ambiguous .Take(80) .Select(a => new Rpro3AmbiguousName( a.Name, a.Variants.Select(v => new Rpro3AmbiguousVariant( v.Count, v.Dob.Count > 0 ? string.Join(", ", v.Dob) : "—", v.Farbe.Count > 0 ? Truncate(string.Join(", ", v.Farbe), 40) : "—", v.Origin.Count > 0 ? Truncate(string.Join(", ", v.Origin), 40) : "—", v.IsOwn)).ToList(), a.BareCount)) .ToList(); return new Rpro3AnalyzeResult( counts, newCount, existingCount, topMerges, ambiguous, photosProvided, availablePhotoFiles?.Count ?? 0); } private static bool DobMatch(DateOnly? rpDob, List existing) { // Match, wenn ein Bestandstier denselben (oder unbekannten) Geburtstag hat (±14 Tage). if (rpDob is null) return true; foreach (var d in existing) { if (d is null) return true; if (Math.Abs((rpDob.Value.ToDateTime(TimeOnly.MinValue) - d.Value.ToDateTime(TimeOnly.MinValue)).TotalDays) <= 14) return true; } return false; } // ───────────────────────── EXECUTE ───────────────────────── public async Task ExecuteAsync( Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary? photoSourcePaths) { var dedup = Rpro3Dedup.Run(data.Animals, _decisions); var plan = BuildPlan(data, dedup); // Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem // ExecuteAsync auf derselben DbContext-Instanz würden sonst alte Tracking-Einträge // mit den neu eingefügten (gleiche deterministische IDs) kollidieren. _db.ChangeTracker.Clear(); // 1. Vorherigen RPRO3-Import idempotent entfernen (deterministische IDs → Re-Run ok). // Health/Weight zuerst (FK auf Gerbil), dann FK-Links lösen, dann Gerbils/Litters. var priorGerbilIds = await _db.Gerbils .Where(g => g.ImportSource == Rpro3Guid.ImportSource) .Select(g => g.Id).ToListAsync(); var priorLitterRefs = plan.Litters.Values.Select(l => l.ExternalRef).ToList(); if (priorGerbilIds.Count > 0) { await _db.HealthRecords.Where(h => priorGerbilIds.Contains(h.GerbilId)).ExecuteDeleteAsync(); await _db.WeightRecords.Where(w => priorGerbilIds.Contains(w.GerbilId)).ExecuteDeleteAsync(); await _db.GerbilPhotos.Where(p => priorGerbilIds.Contains(p.GerbilId)).ExecuteDeleteAsync(); } // FK-Schleifen lösen: Litter.Father/Mother + Gerbil.Litter, die auf RPRO3-Tiere zeigen. await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)) .ExecuteUpdateAsync(s => s.SetProperty(l => l.FatherId, (Guid?)null).SetProperty(l => l.MotherId, (Guid?)null)); await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource) .ExecuteUpdateAsync(s => s.SetProperty(g => g.LitterId, (Guid?)null)); await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync(); await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync(); // 1b. Feature-Tabellen idempotent leeren. Diese Entities haben KEINE ImportSource-Spalte, // daher löschen wir gezielt die deterministischen RPRO3-IDs, die dieser Import erzeugt // (Re-Import desselben Backups → keine Dubletten). Manuell angelegte Einträge mit // anderen IDs bleiben unangetastet. Enclosures werden NICHT aus Gerbils gelöscht, // nur die EnclosureId-Verknüpfung gelöschter Tiere fällt mit den Tieren weg. var acqIds = plan.Acquisitions.Select(a => a.Id).ToList(); var resIds = plan.Reservations.Select(r => r.Id).ToList(); var retIds = plan.Returns.Select(r => r.Id).ToList(); var wlIds = plan.WaitingList.Select(w => w.Id).ToList(); var exhIds = plan.Exhibitions.Select(e => e.Id).ToList(); var encIds = plan.Enclosures.Keys.ToList(); if (acqIds.Count > 0) await _db.AcquisitionRecords.Where(x => acqIds.Contains(x.Id)).ExecuteDeleteAsync(); if (resIds.Count > 0) await _db.SaleReservations.Where(x => resIds.Contains(x.Id)).ExecuteDeleteAsync(); if (retIds.Count > 0) await _db.ReturnRecords.Where(x => retIds.Contains(x.Id)).ExecuteDeleteAsync(); if (wlIds.Count > 0) await _db.WaitingListEntries.Where(x => wlIds.Contains(x.Id)).ExecuteDeleteAsync(); if (exhIds.Count > 0) await _db.ExhibitionResults.Where(x => exhIds.Contains(x.Id)).ExecuteDeleteAsync(); // 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände). int contactsImported = 0; var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id); foreach (var c in plan.Contacts.Values) { if (existingContacts.TryGetValue(c.Id, out var ex)) { ex.Name = c.Name; ex.Email = c.Email; ex.Phone = c.Phone; ex.Address = c.Address; ex.Notes = c.Notes; ex.IsBreeder = c.IsBreeder; ex.IsReceiver = c.IsReceiver; ex.NameSuffix = c.NameSuffix; ex.Provenance = c.Provenance; } else { _db.Contacts.Add(c); } contactsImported++; } await _db.SaveChangesAsync(); // 2b. Enclosures upserten (deterministische IDs). MUSS vor den Gerbils laufen, da // Gerbil.EnclosureId ein echter FK ist. Re-Import aktualisiert dieselben Becken. var existingEnclosures = await _db.Enclosures .Where(e => plan.Enclosures.Keys.Contains(e.Id)).ToDictionaryAsync(e => e.Id); foreach (var en in plan.Enclosures.Values) { if (existingEnclosures.TryGetValue(en.Id, out var ex)) { ex.Name = en.Name; ex.Size = en.Size; ex.Capacity = en.Capacity; ex.LastCleanedDate = en.LastCleanedDate; ex.CleaningCycleDays = en.CleaningCycleDays; } else { _db.Enclosures.Add(en); } } await _db.SaveChangesAsync(); // 3. Litters (Pass 1: ohne Eltern-FKs). foreach (var l in plan.Litters.Values) _db.Litters.Add(new Litter { Id = l.Id, Name = l.Name, Date = l.Date, ExternalRef = l.ExternalRef, Notes = l.Notes, Provenance = l.Provenance, FatherId = null, MotherId = null, }); await _db.SaveChangesAsync(); // 4. Gerbils (Pass 1: ohne LitterId). var today = DateOnly.FromDateTime(DateTime.UtcNow); foreach (var g in plan.Gerbils.Values) { var gerbil = new Gerbil { Id = g.Id, Name = g.Name, Gender = g.Gender, DateOfBirth = g.Dob, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath, Genotype = g.Genotype, ColorVarietyId = g.ColorVarietyId, OriginBreeder = g.OriginBreeder, OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId, GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated, Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef, Provenance = g.Provenance, LitterId = null, EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null, }; GerbilStatusService.Apply(gerbil, today); _db.Gerbils.Add(gerbil); } await _db.SaveChangesAsync(); // 5. Pass 2: FK-Beziehungen setzen (Litter-Eltern + Gerbil-Geburtswurf). var littersInDb = await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ToDictionaryAsync(l => l.Id); foreach (var l in plan.Litters.Values) if (littersInDb.TryGetValue(l.Id, out var dbl)) { dbl.MotherId = l.MotherId; dbl.FatherId = l.FatherId; } var gerbilsInDb = await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ToDictionaryAsync(g => g.Id); foreach (var g in plan.Gerbils.Values) if (g.LitterId is not null && gerbilsInDb.TryGetValue(g.Id, out var dbg)) dbg.LitterId = g.LitterId; await _db.SaveChangesAsync(); // 6. Gesundheits- und Gewichtseinträge. foreach (var h in plan.Health) _db.HealthRecords.Add(h); foreach (var w in plan.Weights) _db.WeightRecords.Add(w); await _db.SaveChangesAsync(); // 7. Fotos (optional, aus bilder.zip). int photosImported = 0; if (photosProvided && photoSourcePaths is not null) { Directory.CreateDirectory(_photoRoot); foreach (var (gerbilId, fileNames) in plan.PhotoFiles) { int sort = 0; foreach (var fn in fileNames) { if (!photoSourcePaths.TryGetValue(fn.ToLowerInvariant(), out var src) || !File.Exists(src)) continue; var ext = Path.GetExtension(fn); var storedName = $"{Guid.NewGuid():N}{ext}"; try { File.Copy(src, Path.Combine(_photoRoot, storedName), overwrite: true); } catch { continue; } _db.GerbilPhotos.Add(new GerbilPhoto { Id = Guid.NewGuid(), GerbilId = gerbilId, FileName = storedName, SortOrder = sort++, CreatedAt = DateTimeOffset.UtcNow, }); photosImported++; } } await _db.SaveChangesAsync(); } // 8. Feature-Tabellen einfügen (Enclosures schon in 2b erledigt). foreach (var a in plan.Acquisitions) _db.AcquisitionRecords.Add(a); foreach (var r in plan.Reservations) _db.SaleReservations.Add(r); foreach (var r in plan.Returns) _db.ReturnRecords.Add(r); foreach (var w in plan.WaitingList) _db.WaitingListEntries.Add(w); foreach (var e in plan.Exhibitions) _db.ExhibitionResults.Add(e); await _db.SaveChangesAsync(); return new Rpro3ExecuteResult( plan.Gerbils.Count, plan.Litters.Count, contactsImported, plan.Health.Count, plan.Weights.Count, photosImported, plan.Enclosures.Count, plan.Acquisitions.Count, plan.Reservations.Count, plan.Returns.Count, plan.WaitingList.Count, plan.Exhibitions.Count, $"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " + $"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " + $"{photosImported} Fotos, {plan.Enclosures.Count} Gehege, {plan.Acquisitions.Count} Erwerbe, " + $"{plan.Reservations.Count} Reservierungen, {plan.Returns.Count} Rücknahmen, " + $"{plan.WaitingList.Count} Wartelisten-Einträge, {plan.Exhibitions.Count} Ausstellungen."); } // ───────────────────────── PLAN-AUFBAU ───────────────────────── private sealed class GerbilPlan { public required Guid Id { get; init; } public required string ExternalRef { get; init; } public required string Name { get; set; } public Gender Gender { get; set; } public DateOnly? Dob { get; set; } public DateOnly? DateOfDeath { get; set; } public string? CauseOfDeath { get; set; } public string? Genotype { get; set; } public Guid? ColorVarietyId { get; set; } public string? OriginBreeder { get; set; } public Guid? OriginContactId { get; set; } public Guid? ReceiverContactId { get; set; } public DateOnly? GoHomeDate { get; set; } public bool IsResident { get; set; } public bool IsCastrated { get; set; } public string? Notes { get; set; } public string? Provenance { get; set; } public Guid? LitterId { get; set; } public string? MotherRid { get; set; } public string? FatherRid { get; set; } } private sealed class LitterPlan { public required Guid Id { get; init; } public required string ExternalRef { get; init; } public string Name { get; set; } = "Wurf"; public DateOnly? Date { get; set; } public Guid? MotherId { get; set; } public Guid? FatherId { get; set; } public string? Notes { get; set; } public string? Provenance { get; set; } } private sealed class ImportPlan { public Dictionary Gerbils { get; } = new(); public Dictionary Litters { get; } = new(); public Dictionary Contacts { get; } = new(); public List Health { get; } = new(); public List Weights { get; } = new(); public Dictionary> PhotoFiles { get; } = new(); // Feature-Tabellen. public Dictionary Enclosures { get; } = new(); public Dictionary GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId public List Acquisitions { get; } = new(); public List Reservations { get; } = new(); public List Returns { get; } = new(); public List WaitingList { get; } = new(); public List Exhibitions { get; } = new(); } private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup) { _lastDedup = dedup; var plan = new ImportPlan(); // 0) Farbschlag-Katalog (Name → Id) für ColorVariety-Verknüpfung. var colorByName = _db.ColorVarieties.AsNoTracking() .ToDictionary(c => Rpro3Dedup.NormName(c.Name), c => c.Id); // 1) Kontakte (herk = Züchter/Herkunft, abn = Abnehmer). Guid? HerkContactId(int? herkId) { if (herkId is null || herkId == 1) return null; // 1 = eigene Zucht → kein Kontakt if (!data.HerkContacts.TryGetValue(herkId.Value.ToString(), out var c)) return null; return EnsureContact(plan, c, isBreeder: true, isReceiver: false); } Guid? AbnContactId(int? abnId) { if (abnId is null) return null; if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return null; return EnsureContact(plan, c, isBreeder: false, isReceiver: true); } // 2) Rid → Cluster-Root → Gerbil-GUID. Platzhalter-Tiere bekommen ihren eigenen Root. string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid; Guid GerbilIdOfRid(string rid) => Rpro3Guid.Gerbil(RootOf(rid)); // 3) Pup-Index: SID(stamm-id) → pup, und stamm-id → Welpen-Daten (Abgabe/Preis/Tod). var pupBySid = new Dictionary(StringComparer.Ordinal); foreach (var p in data.Pups.Values) if (!string.IsNullOrWhiteSpace(p.Sid) && p.Sid != "0") pupBySid[p.Sid] = p; // 4) Ein Gerbil je Cluster (Repräsentant führt; stamm-Tiere machen den Cluster resident). // Singletons (RidToRoot == self) ebenfalls einbeziehen. var clusterMembers = new Dictionary>(StringComparer.Ordinal); foreach (var a in data.Animals) { var root = RootOf(a.Rid); if (!clusterMembers.TryGetValue(root, out var list)) clusterMembers[root] = list = new(); list.Add(a); } foreach (var (root, members) in clusterMembers) { var rep = Rpro3Dedup.Representative(members); bool resident = members.Any(m => m.Src == Rpro3Src.Stamm); var id = Rpro3Guid.Gerbil(root); if (plan.Gerbils.ContainsKey(id)) continue; // Bestes bekanntes Feld aus allen Cluster-Mitgliedern wählen. var dob = members.Select(m => m.Dob).FirstOrDefault(d => d is not null) ?? rep.Dob; var farbe = members.Select(m => m.Farbe).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Farbe; var fcode = members.Select(m => m.Fcode).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Fcode; var death = members.FirstOrDefault(m => m.DateOfDeath is not null); 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, ExternalRef = Rpro3Guid.GerbilRef(root), Name = string.IsNullOrWhiteSpace(rep.Name) ? "Unbenannt" : rep.Name!, Gender = members.Select(m => m.Gender).FirstOrDefault(g => g != Gender.unknown), Dob = dob, DateOfDeath = death?.DateOfDeath, CauseOfDeath = death?.CauseOfDeath, Genotype = CleanGenotype(fcode), OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null), OriginContactId = HerkContactId(herkId), IsResident = residentOverride ?? resident, IsCastrated = members.Any(m => m.IsCastrated), MotherRid = rep.MidRaw, FatherRid = rep.PidRaw, }; if (!string.IsNullOrWhiteSpace(farbe) && colorByName.TryGetValue(Rpro3Dedup.NormName(farbe), out var cvId)) gp.ColorVarietyId = cvId; // Welpen-Zusatzdaten (Abgabe/Preis) für residente Tiere, die ein wurftier sind. foreach (var m in members.Where(m => m.Src == Rpro3Src.Stamm)) if (pupBySid.TryGetValue(m.Rid, out var pup)) { gp.GoHomeDate = pup.AbgabeDate; gp.ReceiverContactId = AbnContactId(pup.AbnId); if (pup.DateOfDeath is not null && gp.DateOfDeath is null) { gp.DateOfDeath = pup.DateOfDeath; gp.CauseOfDeath = pup.CauseOfDeath; } } 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; } // 5) Würfe aus wurf_tb (autoritativ). Eltern → Cluster-GUID. foreach (var (wid, w) in data.Litters) { var id = Rpro3Guid.Litter(wid); var lp = new LitterPlan { Id = id, ExternalRef = Rpro3Guid.LitterRef(wid), Name = string.IsNullOrWhiteSpace(w.Name) ? $"Wurf {wid}" : w.Name!, Date = w.Date, Notes = w.Notes, MotherId = ResolveParentGuid(w.MotherRid, plan), FatherId = ResolveParentGuid(w.FatherRid, plan), Provenance = JsonProvenance("RennmausPro III", new() { ["wurfId"] = wid, ["quelle"] = "wurf_tb" }), }; plan.Litters[id] = lp; } // 6) Welpen → Geburtswurf verknüpfen (pup._SID → resultierendes Gerbil). foreach (var p in data.Pups.Values) { if (string.IsNullOrWhiteSpace(p.Sid) || p.Sid == "0") continue; var gid = GerbilIdOfRid(p.Sid); if (!plan.Gerbils.TryGetValue(gid, out var gp)) continue; var litterId = Rpro3Guid.Litter(p.WurfId); if (plan.Litters.ContainsKey(litterId)) gp.LitterId = litterId; } // 7) Synthetische „Pedigree-Würfe" für Tiere mit baum_tb-Eltern, aber ohne wurf_tb-Geburtswurf. // So traversiert der Stammbaum (Litter → Father/Mother) auch externe Ahnen-Linien. foreach (var gp in plan.Gerbils.Values) { if (gp.LitterId is not null) continue; var motherId = ResolveParentGuid(gp.MotherRid, plan); var fatherId = ResolveParentGuid(gp.FatherRid, plan); if (motherId is null && fatherId is null) continue; var key = $"ped:{motherId}:{fatherId}"; var litterId = Rpro3Guid.For("litter", key); if (!plan.Litters.TryGetValue(litterId, out var lp)) { lp = new LitterPlan { Id = litterId, ExternalRef = $"rpro3-ped:{motherId}:{fatherId}", Name = "Abstammung (RennmausPro)", MotherId = motherId, FatherId = fatherId, Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = "baum_tb", ["synthetisch"] = true }), }; plan.Litters[litterId] = lp; } gp.LitterId = litterId; } // 8) Gesundheit/Gewicht/Tagebuch → stamm-Tiere (tid = stamm-id). BuildHealthAndWeights(data, plan, GerbilIdOfRid); // 9) Fotos (photo_tb id = stamm-id). foreach (var (tid, set) in data.Photos) { var gid = GerbilIdOfRid(tid); if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames; } // 10) Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung). BuildFeatureTables(data, plan, dedup, pupBySid); return plan; } /// /// Baut die 6 Feature-Entities aus den RPRO3-Feature-Tabellen. Alle Referenzen werden über /// die bereits aufgebauten Gerbil-/Contact-GUIDs aufgelöst; tids können stamm ("N") oder /// wurftier ("XjY") sein — Welpen-tids werden über _SID auf das resultierende Tier abgebildet. /// private void BuildFeatureTables( Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary pupBySid) { string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid; // tid ("N" oder "XjY") → (GerbilId?, erfasster Name?). Welpen ohne kept-_SID liefern null-Id. (Guid? id, string? name) ResolveGerbil(string? tid) { if (string.IsNullOrWhiteSpace(tid)) return (null, null); string rid = tid; if (tid.Contains('j')) { // Welpe: über _SID auf das behaltene stamm-Tier abbilden, falls vorhanden. if (data.Pups.TryGetValue(tid, out var pup) && !string.IsNullOrWhiteSpace(pup.Sid) && pup.Sid != "0") rid = pup.Sid!; else { var pupName = data.Pups.TryGetValue(tid, out var p) ? p.Name : null; return (null, pupName); } } var gid = Rpro3Guid.Gerbil(RootOf(rid)); return plan.Gerbils.TryGetValue(gid, out var gp) ? (gid, gp.Name) : (null, data.ResolveName(rid)); } // abn-id → (ContactId?, erfasster Name?). Legt den Kontakt bei Bedarf an (als Abnehmer). (Guid? id, string? name) ResolveAbn(int? abnId) { if (abnId is null) return (null, null); if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return (null, null); var id = EnsureContact(plan, c, isBreeder: false, isReceiver: true); return (id, c.DisplayName); } var now = DateTimeOffset.UtcNow; static DateTime? AsDt(DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); // ── Becken → Enclosure (+ stamm._BECKEN → Gerbil-Zuordnung) ── var beckenById = new Dictionary(); foreach (var b in data.Becken) { var id = Rpro3Guid.Enclosure(b.Id); beckenById[b.Id] = id; plan.Enclosures[id] = new Enclosure { Id = id, Name = string.IsNullOrWhiteSpace(b.Name) ? $"Becken {b.Id}" : b.Name!, Size = string.IsNullOrWhiteSpace(b.Size) ? null : b.Size, Capacity = b.Capacity is > 0 ? b.Capacity : null, LastCleanedDate = b.LastCleaned, CleaningCycleDays = b.CycleDays is > 0 ? b.CycleDays : null, }; } foreach (var (rid, beckenId) in data.StammBecken) { if (!beckenById.TryGetValue(beckenId, out var encId)) continue; var gid = Rpro3Guid.Gerbil(RootOf(rid)); if (plan.Gerbils.ContainsKey(gid)) plan.GerbilEnclosure[gid] = encId; } // ── herktier_tb → AcquisitionRecord ── foreach (var a in data.Acquisitions) { var (gid, _) = ResolveGerbil(a.Tid); if (gid is null) continue; // kein Tier → kein Erwerb // Herkunfts-Kontakt steht bereits auf Gerbil.OriginContactId; hier Datum/Preis/Notiz. Guid? sourceContactId = plan.Gerbils.TryGetValue(gid.Value, out var gp) ? gp.OriginContactId : null; plan.Acquisitions.Add(new AcquisitionRecord { Id = Rpro3Guid.Acquisition(a.Id), GerbilId = gid, SourceContactId = sourceContactId, Date = a.Date, Price = a.Price is > 0 ? a.Price : null, Note = NullIfEmpty(a.Note), CreatedAt = now, }); } // ── abgeben_tb + abstat_tb → SaleReservation (eine Zeile je Tier) ── // abgeben = Vormerkung/Reservierung (oft Welpen-tid), abstat = Abgabe-Abschluss (stamm-tid). // Beide werden über das Tier zusammengeführt; ist beides vorhanden, gewinnt „abgegeben". var reservationByGerbil = new Dictionary(); SaleReservation Res(Guid gid, string? name, string tidKey) { if (reservationByGerbil.TryGetValue(gid, out var ex)) return ex; var res = new SaleReservation { Id = Rpro3Guid.Reservation(tidKey), GerbilId = gid, GerbilName = name, Status = "verfuegbar", CreatedAt = now, UpdatedAt = now, }; reservationByGerbil[gid] = res; plan.Reservations.Add(res); return res; } foreach (var ab in data.Abgeben) { var (gid, name) = ResolveGerbil(ab.Tid); if (gid is null) continue; var res = Res(gid.Value, name, ab.Tid); var (cid, cname) = ResolveAbn(ab.AbnId); if (res.Status != "abgegeben") res.Status = ab.Reserved ? "reserviert" : res.Status; res.ReservedForContactId ??= cid; res.ContactName ??= cname; res.AppointmentDate ??= AsDt(ab.Appointment); res.Note ??= NullIfEmpty(ab.Note); } foreach (var st in data.Abstat) { var (gid, name) = ResolveGerbil(st.Tid); if (gid is null) continue; var res = Res(gid.Value, name, st.Tid); var (cid, cname) = ResolveAbn(st.AbnId); res.Status = "abgegeben"; res.ReservedForContactId ??= cid; res.ContactName ??= cname; res.HandedOverDate ??= AsDt(st.HandedOver); if (st.Price is > 0) res.Price ??= st.Price; res.Note ??= NullIfEmpty(st.Note); res.UpdatedAt = now; } // ── getback_tb → ReturnRecord ── foreach (var gb in data.Getback) { var (gid, name) = ResolveGerbil(gb.Tid); var (cid, cname) = ResolveAbn(gb.AbnId); plan.Returns.Add(new ReturnRecord { Id = Rpro3Guid.Return(gb.Id), GerbilId = gid, GerbilName = name, ReturnDate = AsDt(gb.ReturnDate), ReturnPrice = gb.ReturnPrice is > 0 ? gb.ReturnPrice : null, OriginalPrice = gb.OriginalPrice is > 0 ? gb.OriginalPrice : null, OriginalSaleDate = AsDt(gb.OriginalSaleDate), FromContactId = cid, FromContactName = cname, Note = NullIfEmpty(gb.Note), CreatedAt = now, }); } // ── nachfrage_tb → WaitingListEntry ── foreach (var nf in data.Nachfrage) { var (cid, cname) = ResolveAbn(nf.AbnId); plan.WaitingList.Add(new WaitingListEntry { Id = Rpro3Guid.WaitingList(nf.Id), ContactId = cid, ContactName = cname, WishColor = NullIfEmpty(nf.WishColor), WishGender = nf.WishGender switch { Gender.male => "male", Gender.female => "female", _ => null, }, RequestedAt = AsDt(nf.RequestedAt), Status = MapWaitlistStatus(nf.Status), Note = NullIfEmpty(nf.Note), CreatedAt = now, }); } // ── ausz_tb → ExhibitionResult (in vielen Backups leer; trotzdem voll verdrahtet) ── foreach (var ex in data.Ausstellungen) { var (gid, name) = ResolveGerbil(ex.Tid); var noteParts = new[] { ex.Place, ex.Juror is { Length: > 0 } ? $"Juror: {ex.Juror}" : null, ex.Note } .Where(s => !string.IsNullOrWhiteSpace(s)); var note = string.Join(" · ", noteParts); plan.Exhibitions.Add(new ExhibitionResult { Id = Rpro3Guid.Exhibition(ex.Id), GerbilId = gid, EntityName = name, EventName = string.IsNullOrWhiteSpace(ex.EventName) ? "Ausstellung" : ex.EventName!, Date = AsDt(ex.Date), Placement = NullIfEmpty(ex.Placement), Award = NullIfEmpty(ex.Award), Note = string.IsNullOrWhiteSpace(note) ? null : note, CreatedAt = now, }); } } private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); /// RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert). private static string MapWaitlistStatus(string? raw) { var s = (raw ?? "").Trim().ToLowerInvariant(); return s switch { "" or "offen" or "0" => "offen", "erfuellt" or "erfüllt" or "1" => "erfuellt", "storniert" or "2" => "storniert", _ => "offen", }; } private Guid? ResolveParentGuid(string? rid, ImportPlan plan) { if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null; var gid = Rpro3Guid.Gerbil(_lastDedup!.RidToRoot.TryGetValue(rid, out var r) ? r : rid); return plan.Gerbils.ContainsKey(gid) ? gid : null; } // Kontext-Hack: ResolveParentGuid braucht die RidToRoot-Map des aktuellen Plans. private Rpro3Dedup.DedupResult? _lastDedup; private void BuildHealthAndWeights(Rpro3Data data, ImportPlan plan, Func gerbilIdOfRid) { int seq = 0; foreach (var h in data.Health) { var gid = gerbilIdOfRid(h.Tid); if (!plan.Gerbils.ContainsKey(gid) || h.Date is null) continue; var desc = string.IsNullOrWhiteSpace(h.Description) ? "Krankheitseintrag" : h.Description!; if (!string.IsNullOrWhiteSpace(h.Medication)) desc += $" (Medikation: {h.Medication})"; plan.Health.Add(new HealthRecord { Id = Rpro3Guid.Health(h.Tid, desc, seq++), GerbilId = gid, Date = h.Date.Value, Type = HealthRecordType.Treatment, Description = desc, CreatedAt = DateTimeOffset.UtcNow, }); } // Tagebuch als "Other"-Gesundheitseinträge (kein eigenes Tagebuch-Modell vorhanden). foreach (var d in data.Diary) { var gid = gerbilIdOfRid(d.Tid); if (!plan.Gerbils.ContainsKey(gid) || d.Date is null || string.IsNullOrWhiteSpace(d.Description)) continue; var desc = (string.IsNullOrWhiteSpace(d.Title) ? "" : d.Title + ": ") + d.Description; plan.Health.Add(new HealthRecord { Id = Rpro3Guid.Health(d.Tid, "diary:" + desc, seq++), GerbilId = gid, Date = d.Date.Value, Type = HealthRecordType.Other, Description = "Tagebuch: " + desc, CreatedAt = DateTimeOffset.UtcNow, }); } int wseq = 0; var seen = new HashSet(); foreach (var w in data.Weights) { var gid = gerbilIdOfRid(w.Tid); if (!plan.Gerbils.ContainsKey(gid) || w.Date is null || w.Grams <= 0) continue; var id = Rpro3Guid.Weight(w.Tid + ":" + w.Date.Value.DayNumber, wseq++); if (!seen.Add(id)) continue; plan.Weights.Add(new WeightRecord { Id = id, GerbilId = gid, Date = w.Date.Value, WeightGrams = w.Grams, Notes = w.Notes, }); } } private Guid EnsureContact(ImportPlan plan, Rpro3Contact c, bool isBreeder, bool isReceiver) { var id = Rpro3Guid.Contact(c.Namespace, c.Id); if (plan.Contacts.TryGetValue(id, out var ex)) { ex.IsBreeder |= isBreeder; ex.IsReceiver |= isReceiver; return id; } var address = string.Join(", ", new[] { c.Str, $"{c.Plz} {c.Ort}".Trim() }.Where(s => !string.IsNullOrWhiteSpace(s))); var notes = string.Join("\n", new[] { c.Memo, c.Bem, c.Http }.Where(s => !string.IsNullOrWhiteSpace(s))); plan.Contacts[id] = new Contact { Id = id, Name = c.DisplayName, Email = string.IsNullOrWhiteSpace(c.Mail) ? null : c.Mail, Phone = string.IsNullOrWhiteSpace(c.Telp) ? c.Teld : c.Telp, Address = string.IsNullOrWhiteSpace(address) ? null : address, Notes = string.IsNullOrWhiteSpace(notes) ? null : notes, IsBreeder = isBreeder, IsReceiver = isReceiver, NameSuffix = string.IsNullOrWhiteSpace(c.Clan) ? null : c.Clan, Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = c.Namespace == "herk" ? "herk_tb" : "abn_tb", ["rpro3Id"] = c.Id }), }; return id; } // ───────────────────────── Helpers ───────────────────────── /// RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt /// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten. internal static string? CleanGenotype(string? fcode) { if (string.IsNullOrWhiteSpace(fcode)) return null; var sb = new System.Text.StringBuilder(fcode.Length); foreach (var ch in fcode) if (ch != '[' && ch != ']') sb.Append(ch); var cleaned = sb.ToString().Trim(); return cleaned.Length == 0 ? null : cleaned; } private static string? BuildNotes(List members) { var defects = members.Select(m => m.Defects).Where(d => !string.IsNullOrWhiteSpace(d)).Distinct().ToList(); var zb = members.Select(m => m.Zb).FirstOrDefault(z => !string.IsNullOrWhiteSpace(z)); var parts = new List(); if (!string.IsNullOrWhiteSpace(zb)) parts.Add($"Zuchtbuch-Nr.: {zb}"); if (defects.Count > 0) parts.Add("Defekte: " + string.Join("; ", defects)); return parts.Count > 0 ? string.Join("\n", parts) : null; } private static string BuildProvenance(Rpro3Data data, List members, Rpro3Dedup.DedupResult dedup) { var rep = Rpro3Dedup.Representative(members); var obj = new Dictionary { ["importSource"] = "RennmausPro III", ["mergedRecordCount"] = members.Count, ["rpro3Ids"] = members.Select(m => m.Rid).Take(8).ToList(), ["mother"] = data.ResolveName(rep.MidRaw), ["father"] = data.ResolveName(rep.PidRaw), }; if (members.Count > 1) obj["note"] = $"{members.Count} RennmausPro-Datensätze zusammengelegt (Name + Geburtsdatum + Farbe + Herkunft)."; return JsonSerializer.Serialize(obj); } private static string JsonProvenance(string source, Dictionary extra) { extra["importSource"] = source; return JsonSerializer.Serialize(extra); } private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max]; } }