feat(import): RennmausPro-III-Backup-Importer (analyze+execute, Dedup, Fotos)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
586
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
586
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
@@ -0,0 +1,586 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// „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.
|
||||
/// </summary>
|
||||
public sealed class Rpro3ImportService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// ───────────────────────── ANALYZE ─────────────────────────
|
||||
|
||||
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
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);
|
||||
|
||||
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<DateOnly?> 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<Rpro3ExecuteResult> ExecuteAsync(
|
||||
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
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();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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,
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
return new Rpro3ExecuteResult(
|
||||
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
|
||||
plan.Health.Count, plan.Weights.Count, photosImported,
|
||||
$"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-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<Guid, GerbilPlan> Gerbils { get; } = new();
|
||||
public Dictionary<Guid, LitterPlan> Litters { get; } = new();
|
||||
public Dictionary<Guid, Contact> Contacts { get; } = new();
|
||||
public List<HealthRecord> Health { get; } = new();
|
||||
public List<WeightRecord> Weights { get; } = new();
|
||||
public Dictionary<Guid, List<string>> PhotoFiles { 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<string, Rpro3Pup>(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<string, List<Rpro3Animal>>(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);
|
||||
|
||||
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 = 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);
|
||||
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;
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
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<string, Guid> 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<Guid>();
|
||||
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 ─────────────────────────
|
||||
|
||||
/// <summary>RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt
|
||||
/// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten.</summary>
|
||||
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<Rpro3Animal> 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<string>();
|
||||
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<Rpro3Animal> members, Rpro3Dedup.DedupResult dedup)
|
||||
{
|
||||
var rep = Rpro3Dedup.Representative(members);
|
||||
var obj = new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?> extra)
|
||||
{
|
||||
extra["importSource"] = source;
|
||||
return JsonSerializer.Serialize(extra);
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user