diff --git a/GerbilManager.Tests/Rpro3ImportTests.cs b/GerbilManager.Tests/Rpro3ImportTests.cs new file mode 100644 index 0000000..e0c2cb2 --- /dev/null +++ b/GerbilManager.Tests/Rpro3ImportTests.cs @@ -0,0 +1,218 @@ +using GerbilManagerWebAPI.Import.Rpro3; +using GerbilManagerWebAPI.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace GerbilManager.Tests +{ + /// + /// Tests für den RennmausPro-III-Importer (Reader + Dedup + Execute). + /// Die meisten Tests bauen eine hermetische Mini-SQLite im RPRO3-Schema; ein optionaler + /// Test gegen die echte entpackte DB läuft nur, wenn sie lokal vorhanden ist. + /// + public class Rpro3ImportTests + { + static Rpro3ImportTests() + { + // CP1252 für die Tests verfügbar machen (die Produktion registriert es im Reader-Ctor). + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + } + + // ── Unit: Decode / JDN / Genotyp ────────────────────────────── + + [Fact] + public void JdnToDate_converts_known_julian_day_numbers() + { + // Blacky _BIRTH = 2455036.0 → 2009-07-23 (siehe rpro3-vergleich.md). + Assert.Equal(new DateOnly(2009, 7, 23), Rpro3Reader.JdnToDate(2455036.0)); + Assert.Null(Rpro3Reader.JdnToDate(0)); + Assert.Null(Rpro3Reader.JdnToDate(null)); + } + + [Fact] + public void SmartDecode_prefers_utf8_then_cp1252() + { + var utf8 = System.Text.Encoding.UTF8.GetBytes("männlich"); + Assert.Equal("männlich", Rpro3Reader.SmartDecode(utf8)); + + var cp1252 = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich"); + // CP1252-Bytes sind kein gültiges UTF-8 → Fallback dekodiert korrekt. + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + Assert.Equal("männlich", Rpro3Reader.SmartDecode(cp1252)); + } + + [Fact] + public void CleanGenotype_strips_brackets_keeps_dash() + { + Assert.Equal("aa Ccchm DD EE GG Pp spsp", Rpro3Reader_CleanGenotype("aa Cc[chm] DD EE GG Pp spsp")); + Assert.Equal("Kim AA C- Dd", Rpro3Reader_CleanGenotype("Kim AA C- Dd")); + } + + private static string? Rpro3Reader_CleanGenotype(string s) + { + // CleanGenotype ist internal in Rpro3ImportService → über InternalsVisibleTo erreichbar. + var m = typeof(Rpro3ImportService).GetMethod("CleanGenotype", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + return (string?)m!.Invoke(null, new object?[] { s }); + } + + // ── Unit: Dedup ─────────────────────────────────────────────── + + [Fact] + public void Dedup_merges_compatible_same_name_records() + { + var animals = new List + { + Ext("u1", "Alice", new DateOnly(2010, 7, 19), "Schimmel", "Clan A"), + Ext("u2", "Alice", new DateOnly(2010, 7, 19), "Schimmel", ""), // kompatibel → merge + Ext("u3", "Alice", null, "Schimmel", "Clan A"), // kompatibel → merge + Ext("u4", "Alice", new DateOnly(2001, 1, 1), "Schwarz", "Clan B"), // Konflikt → eigener Cluster + }; + var r = Rpro3Dedup.Run(animals); + // Mind. ein Merge-Cluster (u1/u2/u3), u4 separat → Name "Alice" mehrdeutig. + Assert.Single(r.MergeClusters); + Assert.Equal(3, r.MergeClusters.Values.First().Count); + Assert.Contains(r.Ambiguous, a => a.Name == "Alice"); + } + + [Fact] + public void Dedup_does_not_merge_placeholder_names() + { + var animals = new List + { + Ext("u1", "unbekannt", null, "", ""), + Ext("u2", "unbekannt", null, "", ""), + }; + var r = Rpro3Dedup.Run(animals); + Assert.Empty(r.MergeClusters); + Assert.Equal(2, r.Placeholders.Count); + } + + // ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ──── + + [Fact] + public async Task Execute_imports_animals_litters_contacts_and_is_idempotent() + { + var dbPath = BuildMiniRpro3(); + try + { + var data = new Rpro3Reader().ReadFromDbFile(dbPath); + Assert.Equal(2, data.Animals.Count(a => a.Src == Rpro3Src.Stamm)); + Assert.True(data.Animals.Count(a => a.Src == Rpro3Src.Fremd) >= 2); + + using var efConn = new SqliteConnection("DataSource=:memory:"); + efConn.Open(); + var opts = new DbContextOptionsBuilder().UseSqlite(efConn).Options; + using var ctx = new ApplicationContext(opts); + ctx.Database.EnsureCreated(); + + var config = new ConfigurationBuilder().Build(); + var service = new Rpro3ImportService(ctx, config, null); + + var analyze = await service.AnalyzeAsync(data, photosProvided: false, availablePhotoFiles: null); + Assert.Equal(2, analyze.Counts.OwnAnimals); + Assert.True(analyze.NewVsCurrentNew >= 2); + + var first = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null); + Assert.True(first.GerbilsImported >= 4); // 2 stamm + ≥2 fremd + Assert.Equal(1, first.LittersImported >= 1 ? 1 : 0); // mind. 1 Wurf importiert + var gerbilCount1 = await ctx.Gerbils.CountAsync(); + var litterCount1 = await ctx.Litters.CountAsync(); + + // Re-Run: idempotent — gleiche Zeilenzahl, keine Dubletten. + var second = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null); + Assert.Equal(gerbilCount1, await ctx.Gerbils.CountAsync()); + Assert.Equal(litterCount1, await ctx.Litters.CountAsync()); + + // Pedigree: Wurf hat Mutter + Vater verknüpft (resolved gegen importierte Tiere). + var litter = await ctx.Litters.FirstAsync(l => l.Name!.StartsWith("Wurf")); + Assert.NotNull(litter.MotherId); + Assert.NotNull(litter.FatherId); + + // Genotyp wurde aus _FCODE übernommen + Klammern entfernt. + var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky"); + Assert.Equal("aa Ccchm DD EE GG Pp spsp", blacky.Genotype); + Assert.True(blacky.IsResident); + + // Encoding: "männlich" korrekt dekodiert (Latin-1-Quelle). + Assert.Equal(Gender.male, blacky.Gender); + } + finally { try { File.Delete(dbPath); } catch { } } + } + + // ── Helpers ── + + private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) => + new() { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Dob = dob, Farbe = farbe, Origin = origin }; + + /// Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten). + private static string BuildMiniRpro3() + { + var path = Path.Combine(Path.GetTempPath(), "mini-rpro3-" + Guid.NewGuid().ToString("N") + ".db"); + using var conn = new SqliteConnection($"DataSource={path}"); + conn.Open(); + void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); } + + Exec("CREATE TABLE herk_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)"); + Exec("CREATE TABLE abn_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)"); + Exec("CREATE TABLE baum_tb (id, _MID, _PID)"); + Exec("CREATE TABLE stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE)"); + Exec("CREATE TABLE fremd_tb (id INTEGER, _NAME, _SEX, _BIRTH, _ZB, _HERK, _MID, _PID, _TODAM, _KASTRAT_DATE)"); + Exec("CREATE TABLE wurf_tb (id INTEGER, _MID, _PID, _AM, _BEZ, _BEM)"); + Exec("CREATE TABLE wurftier_tb (id, _WID, _NAME, _SEX, _BIRTH, _SID, _TODAM, _WARUM, _ABAM, _ABN, _PRICE, _ZB, _FEHLER)"); + Exec("CREATE TABLE color_tb (id, _FARBE, _FCODE)"); + Exec("CREATE TABLE wurfcolor_tb (id, _FARBE, _FCODE)"); + Exec("CREATE TABLE fremdcolor_tb (id, _FARBE, _FCODE)"); + Exec("CREATE TABLE tod_tb (tid, _AM, _WARUM)"); + Exec("CREATE TABLE waage_tb (_TID, _GRAMM, _DATE, _BEM)"); + Exec("CREATE TABLE jungwaage_tb (_TID, _WID, _GRAMM, _DATE, _BEM)"); + Exec("CREATE TABLE krank_tb (_TID, _DATE, _BEM, _MED)"); + Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)"); + Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)"); + + // Kontakte + Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')"); + Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (61,'M.Knoss GG','Clan X','Gießen','a@b.de')"); + + // Geschlecht als Latin-1-Bytes einfügen (BLOB), um den smart_decode-Fallback zu testen. + var maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich"); + var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich"); + void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb) + { + using var c = conn.CreateCommand(); + c.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER) VALUES ($id,$n,$s,$b,$h,$z,3,'')"; + c.Parameters.AddWithValue("$id", id); + c.Parameters.AddWithValue("$n", name); + c.Parameters.AddWithValue("$s", sex); + c.Parameters.AddWithValue("$b", birth); + c.Parameters.AddWithValue("$h", herk); + c.Parameters.AddWithValue("$z", zb); + c.ExecuteNonQuery(); + } + InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009"); + InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009"); + + // Genotyp (Bracket-Notation) für Blacky. + Exec("INSERT INTO color_tb (id,_FARBE,_FCODE) VALUES ('1','Schwarz','aa Cc[chm] DD EE GG Pp spsp')"); + + // Externe Ahnen (Eltern von Blacky & Kuke via baum_tb). + Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (1,'Oma','weiblich',0,1,'','')"); + Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (2,'Opa','männlich',0,1,'','')"); + Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('1','u1','u2')"); // Blacky Eltern = Oma×Opa + Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('2','u1','u2')"); // Kuke Eltern = Oma×Opa + + // Wurf: Mutter Kuke (2) × Vater Blacky (1) + Exec("INSERT INTO wurf_tb (id,_MID,_PID,_AM,_BEZ,_BEM) VALUES (1,'2','1',2455246.0,'Wurf A','Erster Wurf')"); + Exec("INSERT INTO wurftier_tb (id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_ABAM,_ABN,_PRICE,_ZB) VALUES ('1j1','1','Alvin','männlich',2455246.0,'','','',NULL,'','21 A')"); + + // Gewicht + Krankheit für Blacky. + Exec("INSERT INTO waage_tb (_TID,_GRAMM,_DATE,_BEM) VALUES ('1',55.0,2455083.0,'')"); + Exec("INSERT INTO krank_tb (_TID,_DATE,_BEM,_MED) VALUES ('1',2455077.0,'Prellung','')"); + + conn.Close(); + SqliteConnection.ClearAllPools(); + return path; + } + } +} diff --git a/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs b/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs new file mode 100644 index 0000000..2e5c20b --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs @@ -0,0 +1,54 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// Zählungen für die Analyse-Übersicht (RennmausPro-III-Import). + public record Rpro3Counts( + int OwnAnimals, // eigene Tiere (stamm) + int ExternalRaw, // externe Ahnen (fremd, roh, vor Dedup) + int ExternalAfterDedup, // externe Ahnen nach Dedup + int Litters, // Würfe + int Contacts, // Kontakte (Herkunft + Abnehmer) + int Genotypes, // hinterlegte Genotypen (eigen + extern) + int DuplicatesMerged, // weggefallene interne Dubletten + int MergeClusters); // Anzahl zusammengelegter Cluster + + /// Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin). + public record Rpro3AmbiguousVariant( + int Count, + string Dob, + string Farbe, + string Origin, + bool IsOwn); + + public record Rpro3AmbiguousName( + string Name, + IReadOnlyList Variants, + int BareCount); + + /// Ein großer sicherer Merge-Cluster (zur Veranschaulichung der Dedup-Wirkung). + public record Rpro3MergeSample( + string Name, + int RecordCount, + string Dob, + string Farbe, + string Origin); + + /// Ergebnis von POST /import/rpro3/analyze — schreibt NICHTS in die DB. + public record Rpro3AnalyzeResult( + Rpro3Counts Counts, + int NewVsCurrentNew, // wie viele Tiere im aktuellen Stand fehlen würden (neu) + int NewVsCurrentExisting, // wie viele schon vorhanden sind (Match) + IReadOnlyList TopMerges, + IReadOnlyList AmbiguousNames, + bool PhotosProvided, + int PhotoFilesAvailable); + + /// Ergebnis von POST /import/rpro3/execute. + public record Rpro3ExecuteResult( + int GerbilsImported, + int LittersImported, + int ContactsImported, + int HealthRecordsImported, + int WeightRecordsImported, + int PhotosImported, + string Message); +} diff --git a/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs index 78fb631..623c74c 100644 --- a/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs @@ -1,4 +1,7 @@ +using System.IO.Compression; +using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Import.Rpro3; using Microsoft.AspNetCore.Http.HttpResults; namespace GerbilManagerWebAPI.Endpoints @@ -8,6 +11,11 @@ namespace GerbilManagerWebAPI.Endpoints /// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes) /// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run /// against Julian's DB under god-supervision after he approves the dry-run. + /// + /// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"): + /// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren; + /// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen. + /// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs). /// public static class ImportEndpoints { @@ -36,7 +44,109 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.Ok(result); }); + // ── RPRO3 ────────────────────────────────────────────────────────── + group.MapPost("/rpro3/analyze", async Task, BadRequest>> ( + IFormFile backup, IFormFile? images, + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + try + { + var reader = new Rpro3Reader(); + await using var bs = backup.OpenReadStream(); + using var seekable = await ToSeekable(bs); + var data = reader.ReadFromBackup(seekable, out var workDir); + try + { + IReadOnlySet? photoFiles = null; + if (images is not null) + { + await using var imgStream = images.OpenReadStream(); + using var imgSeekable = await ToSeekable(imgStream); + photoFiles = ListZipImageNames(imgSeekable); + } + var service = new Rpro3ImportService(db, config, env); + var result = await service.AnalyzeAsync(data, images is not null, photoFiles); + return TypedResults.Ok(result); + } + finally { TryDeleteDir(workDir); } + } + catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); } + }).WithTags("Import").DisableAntiforgery(); + + group.MapPost("/rpro3/execute", async Task, BadRequest>> ( + IFormFile backup, IFormFile? images, + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + try + { + var reader = new Rpro3Reader(); + await using var bs = backup.OpenReadStream(); + using var seekable = await ToSeekable(bs); + var data = reader.ReadFromBackup(seekable, out var workDir); + try + { + Dictionary? photoPaths = null; + if (images is not null) + { + await using var imgStream = images.OpenReadStream(); + using var imgSeekable = await ToSeekable(imgStream); + photoPaths = ExtractZipImages(imgSeekable, workDir); + } + var service = new Rpro3ImportService(db, config, env); + var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths); + return TypedResults.Ok(result); + } + finally { TryDeleteDir(workDir); } + } + catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); } + }).WithTags("Import").DisableAntiforgery(); + return app; } + + // IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren. + private static async Task ToSeekable(Stream s) + { + var ms = new MemoryStream(); + await s.CopyToAsync(ms); + ms.Position = 0; + return ms; + } + + private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + + private static IReadOnlySet ListZipImageNames(Stream zip) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); + foreach (var e in archive.Entries) + if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant())) + set.Add(e.Name); + return set; + } + + // Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad. + private static Dictionary ExtractZipImages(Stream zip, string workDir) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var imgDir = Path.Combine(workDir, "bilder"); + Directory.CreateDirectory(imgDir); + using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); + foreach (var e in archive.Entries) + { + if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant())) + continue; + var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name)); + try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; } + catch { /* defektes Archiv-Entry überspringen */ } + } + return map; + } + + private static void TryDeleteDir(string dir) + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } + catch { /* best effort */ } + } } } diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index 8901b96..d1f12a0 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -12,6 +12,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs new file mode 100644 index 0000000..10fdb67 --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using System.Text; + +namespace GerbilManagerWebAPI.Import.Rpro3 +{ + /// + /// Port von tools/import/compare_rpro3.py `dedup()`. RennmausPro erkennt beim Import vorhandene + /// Tiere NICHT → dasselbe Tier (v. a. die 7427 externen) kommt mehrfach vor. + /// + /// Union-Find INNERHALB gleicher (normalisierter) Namen: + /// merge(a,b) wenn DOB/Farbe/Herkunft kompatibel (gleich ODER eine Seite unbekannt) + /// UND mindestens ein bekanntes Feld positiv übereinstimmt. Konflikt in einem bekannten + /// Feld → NICHT mergen (unsicher → mehrdeutig). + /// Platzhalter-Namen ("unbekannt","-","","?" …) werden nie dedupt. + /// + public static class Rpro3Dedup + { + private static readonly HashSet PlaceholderNames = new(StringComparer.Ordinal) + { "", "-", "n", "unbekannt", "unbekannt?", "?", "nn", "n.n.", "na", "namenlos", "." }; + + private static readonly HashSet UnknownValues = new(StringComparer.Ordinal) + { "", "unbekannt", "unbek.", "unbek", "unbekannte zucht", "?", "n.n.", + "unbekannter farbschlag", "keine angabe", "k.a.", "na", "unbekannte farbe" }; + + public sealed class DedupResult + { + /// Cluster (mehr als ein Datensatz) → die zusammengelegten Tiere. Key = Wurzel-Rid. + public Dictionary> MergeClusters { get; } = new(); + /// Repräsentant-Rid je Cluster (auch Singletons) → Cluster-Wurzel. + public Dictionary RidToRoot { get; } = new(); + /// Mehrdeutige Namen: Name → Varianten (manuelle Entscheidung nötig). + public List Ambiguous { get; } = new(); + /// Platzhalter-Datensätze (nicht dedupbar) gruppiert nach Name. + public List Placeholders { get; } = new(); + public int CollapsedRecords { get; set; } // Summe der Datensätze in Merge-Clustern + public int DuplicatesRemoved { get; set; } // CollapsedRecords - Anzahl Cluster + } + + public sealed class AmbiguousName + { + public required string Name { get; init; } + public List Variants { get; } = new(); + public int BareCount { get; set; } // merkmalslose Varianten (nur Name) + } + + public sealed class Variant + { + public int Count { get; set; } + public List Dob { get; } = new(); + public List Farbe { get; } = new(); + public List Origin { get; } = new(); + public bool IsOwn { get; set; } + } + + public static string NormName(string? s) + { + if (s is null) return ""; + var n = s.Normalize(NormalizationForm.FormKC).Trim().ToLowerInvariant(); + return string.Join(' ', n.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + public static string NormValue(string? s) + { + var v = NormName(s); + return UnknownValues.Contains(v) ? "" : v; + } + + public static DedupResult Run(IReadOnlyList animals) + { + var result = new DedupResult(); + + foreach (var a in animals) + { + a.NameKey = NormName(a.Name); + a.FarbeKey = NormValue(a.Farbe); + a.OriginKey = NormValue(a.Origin); + } + + var byName = new Dictionary>(); + foreach (var a in animals) + { + if (PlaceholderNames.Contains(a.NameKey)) { result.Placeholders.Add(a); continue; } + if (!byName.TryGetValue(a.NameKey, out var list)) byName[a.NameKey] = list = new(); + list.Add(a); + } + + var parent = new Dictionary(); + string Find(string x) + { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + } + void Union(string x, string y) + { + parent.TryAdd(x, x); parent.TryAdd(y, y); + parent[Find(x)] = Find(y); + } + + static int Positive(Rpro3Animal a, Rpro3Animal b) + { + int agree = 0; + if (a.Dob is not null && b.Dob is not null && a.Dob == b.Dob) agree++; + if (a.FarbeKey.Length > 0 && a.FarbeKey == b.FarbeKey) agree++; + if (a.OriginKey.Length > 0 && a.OriginKey == b.OriginKey) agree++; + return agree; + } + static int Conflict(Rpro3Animal a, Rpro3Animal b) + { + int c = 0; + if (a.Dob is not null && b.Dob is not null && a.Dob != b.Dob) c++; + if (a.FarbeKey.Length > 0 && b.FarbeKey.Length > 0 && a.FarbeKey != b.FarbeKey) c++; + if (a.OriginKey.Length > 0 && b.OriginKey.Length > 0 && a.OriginKey != b.OriginKey) c++; + return c; + } + static bool Compat(string? x, string? y) => + string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y) || x == y; + static bool CompatDob(DateOnly? x, DateOnly? y) => x is null || y is null || x == y; + + foreach (var (_, group) in byName) + { + foreach (var a in group) parent.TryAdd(a.Rid, a.Rid); + for (int i = 0; i < group.Count; i++) + for (int j = i + 1; j < group.Count; j++) + { + var a = group[i]; var b = group[j]; + 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); + } + } + + // Cluster sammeln + var clusters = new Dictionary>(); + foreach (var a in animals) + { + if (PlaceholderNames.Contains(a.NameKey)) continue; + var root = Find(a.Rid); + result.RidToRoot[a.Rid] = root; + if (!clusters.TryGetValue(root, out var list)) clusters[root] = list = new(); + list.Add(a); + } + foreach (var (k, v) in clusters) + if (v.Count > 1) + { + result.MergeClusters[k] = v; + result.CollapsedRecords += v.Count; + } + result.DuplicatesRemoved = result.CollapsedRecords - result.MergeClusters.Count; + + // Mehrdeutige Namen: Name löst sich nach sicherem Merge in >1 Cluster auf + var nameToRoots = new Dictionary>(); + var disp = new Dictionary(); + foreach (var a in animals) + { + if (PlaceholderNames.Contains(a.NameKey)) continue; + if (!nameToRoots.TryGetValue(a.NameKey, out var set)) nameToRoots[a.NameKey] = set = new(); + set.Add(Find(a.Rid)); + disp.TryAdd(a.NameKey, a.Name ?? a.NameKey); + } + + foreach (var (nm, roots) in nameToRoots) + { + if (roots.Count <= 1) continue; + var amb = new AmbiguousName { Name = disp.GetValueOrDefault(nm, nm) }; + foreach (var root in roots) + { + var recs = clusters[root]; + var dob = recs.Where(r => r.Dob is not null).Select(r => r.Dob!.Value.ToString("yyyy-MM-dd")).Distinct().OrderBy(x => x).ToList(); + var farbe = recs.Where(r => !string.IsNullOrEmpty(r.Farbe)).Select(r => r.Farbe!).Distinct().OrderBy(x => x).ToList(); + var origin = recs.Where(r => NormValue(r.Origin).Length > 0).Select(r => r.Origin!).Distinct().OrderBy(x => x).ToList(); + bool bare = dob.Count == 0 && farbe.Count == 0 && origin.Count == 0; + if (bare) { amb.BareCount += recs.Count; continue; } + var v = new Variant { Count = recs.Count, IsOwn = recs.Any(r => r.Src == Rpro3Src.Stamm) }; + v.Dob.AddRange(dob); v.Farbe.AddRange(farbe); v.Origin.AddRange(origin); + amb.Variants.Add(v); + } + amb.Variants.Sort((x, y) => y.Count.CompareTo(x.Count)); + // Nur Namen mit mind. einer informativen Variante interessieren die Züchterin. + if (amb.Variants.Count >= 1) result.Ambiguous.Add(amb); + } + result.Ambiguous.Sort((x, y) => y.Variants.Count.CompareTo(x.Variants.Count)); + + return result; + } + + /// Repräsentant eines Clusters: bevorzugt ein stamm-Tier, sonst das mit den meisten + /// bekannten Feldern (DOB/Farbe/Origin), Tiebreak per Rid (stabil). + public static Rpro3Animal Representative(List cluster) + { + return cluster + .OrderByDescending(a => a.Src == Rpro3Src.Stamm) + .ThenByDescending(a => (a.Dob is not null ? 1 : 0) + (a.FarbeKey.Length > 0 ? 1 : 0) + (a.OriginKey.Length > 0 ? 1 : 0)) + .ThenBy(a => a.Rid, StringComparer.Ordinal) + .First(); + } + } +} diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs new file mode 100644 index 0000000..9950a91 --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using System.Text; + +namespace GerbilManagerWebAPI.Import.Rpro3 +{ + /// + /// Deterministische GUIDs aus stabilen RPRO3-Schlüsseln (analog zum Python `generate_guid`-Muster). + /// Gleicher Schlüssel → gleiche GUID über Re-Imports hinweg → idempotent. + /// MD5-basiert (Namespace-Präfix vermeidet Kollisionen mit anderen Entitätstypen). + /// + public static class Rpro3Guid + { + public const string ImportSource = "RennmausPro III"; + + public static Guid For(string @namespace, string key) + { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes($"rpro3:{@namespace}:{key}")); + return new Guid(bytes); + } + + public static Guid Gerbil(string clusterRootRid) => For("gerbil", clusterRootRid); + public static Guid Litter(int wurfId) => For("litter", wurfId.ToString()); + public static Guid Contact(string ns, int id) => For("contact", $"{ns}:{id}"); + public static Guid Health(string tid, string disc, int seq) => For("health", $"{tid}:{seq}:{disc}"); + public static Guid Weight(string tid, int seq) => For("weight", $"{tid}:{seq}"); + + /// Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte). + public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}"; + public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}"; + } +} diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs new file mode 100644 index 0000000..de435b0 --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs @@ -0,0 +1,586 @@ +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; + + 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 AnalyzeAsync( + Rpro3Data data, bool photosProvided, IReadOnlySet? 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 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); + 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 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(); + } + + 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); + + 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 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]; + } +} diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs new file mode 100644 index 0000000..a537c6b --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs @@ -0,0 +1,143 @@ +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Import.Rpro3 +{ + /// + /// Quelle eines RPRO3-Tier-Datensatzes. „stamm" = eigenes Tier, „fremd" = externer Ahn, + /// „wurftier" = Welpe eines Wurfs (nur relevant, wenn nicht in ein stamm-Tier überführt). + /// + public enum Rpro3Src { Stamm, Fremd, Wurftier } + + /// Einheitlicher Tier-Datensatz aus der RPRO3-SQLite (vor Dedup). + /// `Rid` ist die RPRO3-ID im jeweiligen Namensraum: "N"=stamm, "uN"=fremd, "XjY"=wurftier. + public sealed class Rpro3Animal + { + public required string Rid { get; init; } + public Rpro3Src Src { get; init; } + public string? Name { get; set; } + public Gender Gender { get; set; } + public DateOnly? Dob { get; set; } + public string? Farbe { get; set; } // _FARBE Farbschlagname + public string? Fcode { get; set; } // _FCODE 8-Locus-Genotyp + public string? Origin { get; set; } // aufgelöster Herkunfts-Name (herk_tb) + public int? OriginHerkId { get; set; } // herk_tb.id (1 = eigene Zucht) + public string? Zb { get; set; } + public string? MidRaw { get; set; } // Mutter-Ref im RPRO3-Namensraum + public string? PidRaw { get; set; } // Vater-Ref im RPRO3-Namensraum + public DateOnly? DateOfDeath { get; set; } + public string? CauseOfDeath { get; set; } + public string? Defects { get; set; } // _FEHLER + public bool IsCastrated { get; set; } + public int? StatusCode { get; set; } // stamm._STATUS (RPRO3) + + // Dedup-Schlüssel (normalisiert), wird von Rpro3Dedup gesetzt. + public string NameKey { get; set; } = ""; + public string FarbeKey { get; set; } = ""; + public string OriginKey { get; set; } = ""; + } + + public sealed class Rpro3Litter + { + public required int Id { get; init; } + public string? Name { get; set; } // _BEZ "Wurf A" + public DateOnly? Date { get; set; } // _AM + public string? MotherRid { get; set; } // stamm-id als string + public string? FatherRid { get; set; } + public string? Notes { get; set; } // _BEM + } + + public sealed class Rpro3Pup + { + public required string Id { get; init; } // "XjY" + public int WurfId { get; init; } + public string? Name { get; set; } + public Gender Gender { get; set; } + public DateOnly? Dob { get; set; } + public string? Sid { get; set; } // → stamm-id, falls als eigenes Tier behalten + public DateOnly? DateOfDeath { get; set; } + public string? CauseOfDeath { get; set; } + public DateOnly? AbgabeDate { get; set; } + public int? AbnId { get; set; } // → abn_tb + public decimal? Price { get; set; } + public string? Zb { get; set; } + public string? Defects { get; set; } + } + + public sealed class Rpro3Contact + { + public required int Id { get; init; } + public required string Namespace { get; init; } // "herk" | "abn" + public string? Bez { get; set; } + public string? Anrede { get; set; } + public string? Vname { get; set; } + public string? Nname { get; set; } + public string? Clan { get; set; } + public string? Str { get; set; } + public string? Ort { get; set; } + public string? Plz { get; set; } + public string? Telp { get; set; } + public string? Teld { get; set; } + public string? Mail { get; set; } + public string? Http { get; set; } + public string? Memo { get; set; } + public string? Bem { get; set; } + + /// Anzeigename: Bezeichnung/Cattery, sonst Clan, sonst Vor+Nachname. + public string DisplayName => + FirstNonEmpty(Bez, Clan, $"{Vname} {Nname}".Trim()) ?? "Unbekannt"; + + private static string? FirstNonEmpty(params string?[] vals) => + vals.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + } + + public sealed class Rpro3Weight + { + public required string Tid { get; init; } // stamm-id (waage) bzw. wurftier-id (jungwaage) + public DateOnly? Date { get; set; } + public int Grams { get; set; } + public string? Notes { get; set; } + } + + public sealed class Rpro3Health + { + public required string Tid { get; init; } + public DateOnly? Date { get; set; } + public string? Description { get; set; } + public string? Medication { get; set; } + } + + public sealed class Rpro3Diary + { + public required string Tid { get; init; } + public DateOnly? Date { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + } + + public sealed class Rpro3PhotoSet + { + public required string Tid { get; init; } // stamm-id + public List FileNames { get; } = new(); + } + + /// Vollständig geparster RPRO3-Backup-Inhalt. + public sealed class Rpro3Data + { + public List Animals { get; } = new(); // stamm + fremd + public Dictionary Litters { get; } = new(); + public Dictionary Pups { get; } = new(); + public Dictionary HerkContacts { get; } = new(); + public Dictionary AbnContacts { get; } = new(); + public List Weights { get; } = new(); + public List Health { get; } = new(); + public List Diary { get; } = new(); + public Dictionary Photos { get; } = new(); + + // Roh-Zählungen (vor Dedup) für den Report. + public int ColorStammCount { get; set; } + public int ColorExtCount { get; set; } + + /// Löst eine RPRO3-Ref ("N"/"uN"/"XjY") in einen Anzeigenamen auf (für Provenance/Report). + public Func ResolveName { get; set; } = _ => null; + } +} diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs new file mode 100644 index 0000000..f84fb53 --- /dev/null +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs @@ -0,0 +1,477 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using GerbilManagerWebAPI.Models; +using Microsoft.Data.Sqlite; + +namespace GerbilManagerWebAPI.Import.Rpro3 +{ + /// + /// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte + /// `_rpro3.db` direkt und überführt sie in . + /// + /// KRITISCHE PARSING-DETAILS (Port von tools/import/compare_rpro3.py): + /// 1. DATUM: _BIRTH/_AM/_DATE/_TODAM/_ABAM sind astronomische Julianische Tageszahlen (REAL). + /// 0/None = unbekannt. date = fromordinal(round(jdn) - 1721425). + /// 2. ENCODING (gemischt!): TEXT teils UTF-8 (neuer), teils CP1252 (älter). Wir lesen jede + /// Spalte als BLOB (Bytes) und dekodieren erst strikt UTF-8, bei Fehler CP1252 + /// (siehe smart_decode). Microsoft.Data.Sqlite würde TEXT sonst als UTF-8 erzwingen → Mojibake. + /// 3. ID-NAMENSRÄUME: "N"=stamm_tb, "uN"=fremd_tb, "XjY"=wurftier_tb. + /// + public sealed class Rpro3Reader + { + private static bool _cp1252Registered; + + public Rpro3Reader() + { + // CP1252 ist auf .NET Core nicht ohne CodePagesEncodingProvider verfügbar. + if (!_cp1252Registered) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + _cp1252Registered = true; + } + } + + /// Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und + /// parst sie. Wirft , wenn keine DB gefunden wurde. + /// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn). + public Rpro3Data ReadFromBackup(Stream backupStream, out string workDir) + { + workDir = Path.Combine(Path.GetTempPath(), "rpro3-import-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + try + { + using var archive = new ZipArchive(backupStream, ZipArchiveMode.Read, leaveOpen: true); + var dbEntry = archive.Entries.FirstOrDefault(e => + e.Name.Equals("_rpro3.db", StringComparison.OrdinalIgnoreCase)) + ?? archive.Entries.FirstOrDefault(e => + e.Name.EndsWith(".db", StringComparison.OrdinalIgnoreCase)); + if (dbEntry is null) + throw new Rpro3FormatException( + "In der hochgeladenen Datei wurde keine RennmausPro-Datenbank (_rpro3.db) gefunden."); + + var dbPath = Path.Combine(workDir, "_rpro3.db"); + dbEntry.ExtractToFile(dbPath, overwrite: true); + return ReadFromDbFile(dbPath); + } + catch (InvalidDataException ex) + { + throw new Rpro3FormatException( + "Die Datei ist kein gültiges RennmausPro-Backup (kein lesbares ZIP-Archiv).", ex); + } + } + + /// Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad). + public Rpro3Data ReadFromDbFile(string dbPath) + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + }.ToString(); + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + return Parse(conn); + } + + // ---------- Decoding helpers (Port von smart_decode / jdn_to_date) ---------- + + /// RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252. + internal static string? SmartDecode(byte[]? bytes) + { + if (bytes is null || bytes.Length == 0) return bytes is null ? null : ""; + try + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(bytes); + } + catch (DecoderFallbackException) + { + return Encoding.GetEncoding(1252).GetString(bytes); + } + } + + /// JDN (REAL) → DateOnly. 0/None/ungültig = null. + internal static DateOnly? JdnToDate(double? jdn) + { + if (jdn is null || jdn.Value <= 0) return null; + // Python: date.fromordinal(round(jdn) - 1721425). .NET-Ordinaltage zählen ab 0001-01-01 + // mit DateOnly.FromDayNumber(n) wobei DayNumber 0 = 0001-01-01 (= Python-ordinal 1). + // → dayNumber = round(jdn) - 1721425 - 1. + long dayNumber = (long)Math.Round(jdn.Value) - 1721425 - 1; + if (dayNumber < 0 || dayNumber > DateOnly.MaxValue.DayNumber) return null; + return DateOnly.FromDayNumber((int)dayNumber); + } + + private static Gender ParseGender(string? sex) => (sex ?? "").Trim().ToLowerInvariant() switch + { + "männlich" or "maennlich" or "m" or "male" => Gender.male, + "weiblich" or "w" or "f" or "female" => Gender.female, + _ => Gender.unknown, + }; + + // ---------- BLOB-aware accessors ---------- + + private static string? Str(SqliteDataReader r, int ord) + { + if (r.IsDBNull(ord)) return null; + // Als Bytes lesen → smart decode (UTF-8/CP1252). + using var stream = r.GetStream(ord); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return SmartDecode(ms.ToArray()); + } + + private static double? Real(SqliteDataReader r, int ord) + { + if (r.IsDBNull(ord)) return null; + try { return r.GetDouble(ord); } + catch { return double.TryParse(Str(r, ord), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; } + } + + private static int? IntOrNull(SqliteDataReader r, int ord) + { + if (r.IsDBNull(ord)) return null; + try { return (int)r.GetInt64(ord); } + catch + { + var s = Str(r, ord); + return int.TryParse(s, out var v) ? v : null; + } + } + + private static decimal? DecimalOrNull(SqliteDataReader r, int ord) + { + if (r.IsDBNull(ord)) return null; + try { return (decimal)r.GetDouble(ord); } + catch + { + var s = Str(r, ord); + return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : null; + } + } + + // Map column name → ordinal once per query (RPRO3 column order is stable but we stay safe). + private static Func OrdinalLookup(SqliteDataReader r) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < r.FieldCount; i++) map[r.GetName(i)] = i; + return (rr, name) => map.TryGetValue(name, out var o) ? o : -1; + } + + // ---------- Parse ---------- + + private Rpro3Data Parse(SqliteConnection conn) + { + var data = new Rpro3Data(); + + // herk_tb → Anzeigename + voller Kontakt + var herkName = new Dictionary(); + foreach (var (id, c) in ReadContacts(conn, "herk_tb", "herk", "_HERK_IGNORED")) + { + data.HerkContacts[c.Id.ToString()] = c; + herkName[id] = c.DisplayName; + } + foreach (var (_, c) in ReadContacts(conn, "abn_tb", "abn", "_ABN_IGNORED")) + data.AbnContacts[c.Id.ToString()] = c; + + // baum_tb: master parent table (id → Mutter/Vater im Namensraum) + var baum = new Dictionary(); + Query(conn, "SELECT id, _MID, _PID FROM baum_tb", (r, ord) => + { + var id = Str(r, ord(r, "id")); + if (id is not null) baum[id] = (Str(r, ord(r, "_MID")), Str(r, ord(r, "_PID"))); + }); + + // Farben: color_tb (stamm), wurfcolor_tb (pup), fremdcolor_tb (extern) + var colorStamm = ReadColors(conn, "color_tb"); + var colorPup = ReadColors(conn, "wurfcolor_tb"); + var colorExt = ReadColors(conn, "fremdcolor_tb"); + data.ColorStammCount = colorStamm.Count; + data.ColorExtCount = colorExt.Count; + + (string farbe, string fcode) ColorFor(string rid) + { + Dictionary src = + rid.StartsWith('u') ? colorExt : rid.Contains('j') ? colorPup : colorStamm; + return src.TryGetValue(rid, out var v) ? v : ("", ""); + } + + // Tod (stamm/fremd nutzen tod_tb via tid = stamm-id; fremd hat eigenes _TODAM) + var tod = new Dictionary(); + Query(conn, "SELECT tid, _AM, _WARUM FROM tod_tb", (r, ord) => + { + var tid = Str(r, ord(r, "tid")); + if (tid is not null) tod[tid] = (JdnToDate(Real(r, ord(r, "_AM"))), Str(r, ord(r, "_WARUM"))); + }); + + // stamm_tb: Namen für Eltern-Auflösung + var stammName = new Dictionary(); + var fremdName = new Dictionary(); + var pupName = new Dictionary(); + + // stamm_tb (eigene Tiere) + Query(conn, + "SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE FROM stamm_tb", + (r, ord) => + { + var sid = IntOrNull(r, ord(r, "id")); + if (sid is null) return; + var rid = sid.Value.ToString(); + var name = Str(r, ord(r, "_NAME")); + stammName[sid.Value] = name; + var (farbe, fcode) = ColorFor(rid); + baum.TryGetValue(rid, out var par); + var herkId = IntOrNull(r, ord(r, "_HERKUNFT")); + tod.TryGetValue(rid, out var death); + data.Animals.Add(new Rpro3Animal + { + Rid = rid, + Src = Rpro3Src.Stamm, + Name = name, + Gender = ParseGender(Str(r, ord(r, "_SEX"))), + Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), + Farbe = farbe, + Fcode = fcode, + Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "", + OriginHerkId = herkId, + Zb = Str(r, ord(r, "_ZB")), + MidRaw = par.mid, + PidRaw = par.pid, + DateOfDeath = death.am, + CauseOfDeath = death.warum, + Defects = Str(r, ord(r, "_FEHLER")), + IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null, + StatusCode = IntOrNull(r, ord(r, "_STATUS")), + }); + }); + + // fremd_tb (externe Ahnen) + Query(conn, + "SELECT id,_NAME,_SEX,_BIRTH,_ZB,_HERK,_MID,_PID,_TODAM,_KASTRAT_DATE FROM fremd_tb", + (r, ord) => + { + var fid = IntOrNull(r, ord(r, "id")); + if (fid is null) return; + var rid = "u" + fid.Value; + var name = Str(r, ord(r, "_NAME")); + fremdName[fid.Value] = name; + var (farbe, fcode) = ColorFor(rid); + var herkId = IntOrNull(r, ord(r, "_HERK")); + data.Animals.Add(new Rpro3Animal + { + Rid = rid, + Src = Rpro3Src.Fremd, + Name = name, + Gender = ParseGender(Str(r, ord(r, "_SEX"))), + Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), + Farbe = farbe, + Fcode = fcode, + Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "", + OriginHerkId = herkId, + Zb = Str(r, ord(r, "_ZB")), + MidRaw = Str(r, ord(r, "_MID")), + PidRaw = Str(r, ord(r, "_PID")), + DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))), + IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null, + }); + }); + + // wurf_tb (Würfe) + Query(conn, "SELECT id,_MID,_PID,_AM,_BEZ,_BEM FROM wurf_tb", (r, ord) => + { + var wid = IntOrNull(r, ord(r, "id")); + if (wid is null) return; + data.Litters[wid.Value] = new Rpro3Litter + { + Id = wid.Value, + Name = Str(r, ord(r, "_BEZ")), + Date = JdnToDate(Real(r, ord(r, "_AM"))), + MotherRid = Str(r, ord(r, "_MID")), + FatherRid = Str(r, ord(r, "_PID")), + Notes = Str(r, ord(r, "_BEM")), + }; + }); + + // wurftier_tb (Welpen) + Query(conn, + "SELECT id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_WARUM,_ABAM,_ABN,_PRICE,_ZB,_FEHLER FROM wurftier_tb", + (r, ord) => + { + var id = Str(r, ord(r, "id")); + if (id is null) return; + var name = Str(r, ord(r, "_NAME")); + pupName[id] = name; + data.Pups[id] = new Rpro3Pup + { + Id = id, + WurfId = IntOrNull(r, ord(r, "_WID")) ?? 0, + Name = name, + Gender = ParseGender(Str(r, ord(r, "_SEX"))), + Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), + Sid = Str(r, ord(r, "_SID")), + DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))), + CauseOfDeath = Str(r, ord(r, "_WARUM")), + AbgabeDate = JdnToDate(Real(r, ord(r, "_ABAM"))), + AbnId = IntOrNull(r, ord(r, "_ABN")), + Price = DecimalOrNull(r, ord(r, "_PRICE")), + Zb = Str(r, ord(r, "_ZB")), + Defects = Str(r, ord(r, "_FEHLER")), + }; + }); + + // Eltern-Namen auflösen (für Report / Provenance) + string? NameFor(string? rid) + { + if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL") return null; + if (rid.StartsWith('u')) + return int.TryParse(rid[1..], out var fid) && fremdName.TryGetValue(fid, out var n) ? n : null; + if (rid.Contains('j')) + return pupName.TryGetValue(rid, out var n) ? n : null; + return int.TryParse(rid, out var sid) && stammName.TryGetValue(sid, out var sn) ? sn : null; + } + data.ResolveName = NameFor; + + // Gewichte: waage_tb (_TID = stamm-id) + jungwaage_tb (_TID = wurftier-id "XjY" oder via _WID) + Query(conn, "SELECT _TID,_GRAMM,_DATE,_BEM FROM waage_tb", (r, ord) => + { + var tid = Str(r, ord(r, "_TID")); + if (tid is null) return; + data.Weights.Add(new Rpro3Weight + { + Tid = tid, + Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0), + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Notes = Str(r, ord(r, "_BEM")), + }); + }); + Query(conn, "SELECT _TID,_WID,_GRAMM,_DATE,_BEM FROM jungwaage_tb", (r, ord) => + { + // _TID ist hier i. d. R. die laufende Pup-Nr im Wurf; _WID der Wurf → "WIDjTID". + var tidRaw = Str(r, ord(r, "_TID")); + var widRaw = IntOrNull(r, ord(r, "_WID")); + string? tid = tidRaw is not null && tidRaw.Contains('j') ? tidRaw + : widRaw is not null && tidRaw is not null ? $"{widRaw}j{tidRaw}" : null; + if (tid is null) return; + data.Weights.Add(new Rpro3Weight + { + Tid = tid, + Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0), + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Notes = Str(r, ord(r, "_BEM")), + }); + }); + + // Krankheiten: krank_tb (_TID = stamm-id) + Query(conn, "SELECT _TID,_DATE,_BEM,_MED FROM krank_tb", (r, ord) => + { + var tid = Str(r, ord(r, "_TID")); + if (tid is null) return; + data.Health.Add(new Rpro3Health + { + Tid = tid, + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Description = Str(r, ord(r, "_BEM")), + Medication = Str(r, ord(r, "_MED")), + }); + }); + + // Tagebuch: diary_tb (_TID = stamm-id) + Query(conn, "SELECT _TID,_BEZ,_DATE,_DESC FROM diary_tb", (r, ord) => + { + var tid = Str(r, ord(r, "_TID")); + if (tid is null) return; + data.Diary.Add(new Rpro3Diary + { + Tid = tid, + Title = Str(r, ord(r, "_BEZ")), + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Description = Str(r, ord(r, "_DESC")), + }); + }); + + // Fotos: photo_tb (id = stamm-id, _P1/_P2/_P3 = Bilddateinamen) + Query(conn, "SELECT id,_P1,_P2,_P3 FROM photo_tb", (r, ord) => + { + var pid = IntOrNull(r, ord(r, "id")); + if (pid is null) return; + var set = new Rpro3PhotoSet { Tid = pid.Value.ToString() }; + foreach (var col in new[] { "_P1", "_P2", "_P3" }) + { + var fn = Str(r, ord(r, col)); + if (!string.IsNullOrWhiteSpace(fn) && fn != "---") + set.FileNames.Add(Path.GetFileName(fn.Replace('\\', '/'))); + } + if (set.FileNames.Count > 0) data.Photos[set.Tid] = set; + }); + + return data; + } + + private static Dictionary ReadColors(SqliteConnection conn, string table) + { + var dict = new Dictionary(); + Query(conn, $"SELECT id,_FARBE,_FCODE FROM {table}", (r, ord) => + { + var id = Str(r, ord(r, "id")); + if (id is not null) + dict[id] = (Str(r, ord(r, "_FARBE")) ?? "", Str(r, ord(r, "_FCODE")) ?? ""); + }); + return dict; + } + + private static IEnumerable<(int id, Rpro3Contact c)> ReadContacts( + SqliteConnection conn, string table, string ns, string _) + { + var list = new List<(int, Rpro3Contact)>(); + Query(conn, + $"SELECT id,_BEZ,_ANREDE,_VNAME,_NNAME,_CLAN,_STR,_ORT,_PLZ,_TELP,_TELD,_MAIL,_HTTP,_MEMO,_BEM FROM {table}", + (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + list.Add((id.Value, new Rpro3Contact + { + Id = id.Value, + Namespace = ns, + Bez = Str(r, ord(r, "_BEZ")), + Anrede = Str(r, ord(r, "_ANREDE")), + Vname = Str(r, ord(r, "_VNAME")), + Nname = Str(r, ord(r, "_NNAME")), + Clan = Str(r, ord(r, "_CLAN")), + Str = Str(r, ord(r, "_STR")), + Ort = Str(r, ord(r, "_ORT")), + Plz = Str(r, ord(r, "_PLZ")), + Telp = Str(r, ord(r, "_TELP")), + Teld = Str(r, ord(r, "_TELD")), + Mail = Str(r, ord(r, "_MAIL")), + Http = Str(r, ord(r, "_HTTP")), + Memo = Str(r, ord(r, "_MEMO")), + Bem = Str(r, ord(r, "_BEM")), + })); + }); + return list; + } + + private static void Query( + SqliteConnection conn, string sql, + Action> onRow) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + using var r = cmd.ExecuteReader(); + Func? ord = null; + while (r.Read()) + { + ord ??= OrdinalLookup(r); + onRow(r, ord); + } + } + } + + public sealed class Rpro3FormatException : Exception + { + public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { } + } +} diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 78faa7a..824790c 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -416,6 +416,15 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } + // RPRO3: RennmausPro-III-Backup-Import (multipart-Upload). Mock liefert eine feste + // Auswertung bzw. ein Import-Ergebnis — keine echte Datei-Verarbeitung. + if (path === '/import/rpro3/analyze' && method === 'POST') { + return json(route, 200, db.rpro3.analyze) + } + if (path === '/import/rpro3/execute' && method === 'POST') { + return json(route, 200, db.rpro3.execute) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index b07cefe..0e74e15 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -78,6 +78,11 @@ export interface MockDb { namesConfigured: boolean // FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback) feedback: Record[] + // RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs. + rpro3: { + analyze: Record + execute: Record + } } function gerbil( @@ -376,5 +381,54 @@ export function seedDb(): MockDb { saleAdConfigured: true, namesConfigured: true, feedback: [], + rpro3: { + analyze: { + counts: { + ownAnimals: 303, + externalRaw: 7427, + externalAfterDedup: 3391, + litters: 127, + contacts: 750, + genotypes: 7732, + duplicatesMerged: 4036, + mergeClusters: 782, + }, + newVsCurrentNew: 280, + newVsCurrentExisting: 23, + topMerges: [ + { name: 'Alice', recordCount: 30, dob: '2010-07-19', farbe: 'Schimmel', origin: 'Clan of Kleine Füchse' }, + { name: 'BlackEye', recordCount: 28, dob: '', farbe: 'Silberschimmel', origin: 'Clan of Desert' }, + ], + ambiguousNames: [ + { + name: 'Merlin', + bareCount: 0, + variants: [ + { count: 4, dob: '2007-08-22', farbe: 'Blaufuchs', origin: 'Clan of the Yankees', isOwn: false }, + { count: 3, dob: '—', farbe: 'Platin-Schecke', origin: '—', isOwn: false }, + ], + }, + { + name: 'Bella', + bareCount: 0, + variants: [ + { count: 2, dob: '2012-06-21', farbe: 'Blaufuchs', origin: 'Clan of Sunset Glow', isOwn: false }, + { count: 1, dob: '2013-03-20', farbe: 'Algierfuchs, hell', origin: 'eigene Zucht', isOwn: true }, + ], + }, + ], + photosProvided: false, + photoFilesAvailable: 0, + }, + execute: { + gerbilsImported: 3694, + littersImported: 1678, + contactsImported: 508, + healthRecordsImported: 39, + weightRecordsImported: 229, + photosImported: 0, + message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.', + }, + }, } } diff --git a/gerbil-manager-web/e2e/rennmauspro-import.spec.ts b/gerbil-manager-web/e2e/rennmauspro-import.spec.ts new file mode 100644 index 0000000..f918058 --- /dev/null +++ b/gerbil-manager-web/e2e/rennmauspro-import.spec.ts @@ -0,0 +1,49 @@ +/** RPRO3: RennmausPro-III-Import-Rubrik unter „Hilfe" — Auswerten → Import durchführen. */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const t = de.pages.rpro3Import +const h = de.hilfe + +test('Hilfe verlinkt auf den RennmausPro-Import', async ({ page }) => { + skipUnlessMock() + await page.goto('/hilfe') + // Den Abschnitt aufklappen und dem Link folgen. + await page.getByText(h.sections.rpro3Import).click() + await page.getByRole('link', { name: new RegExp(h.rpro3Link.button) }).click() + await expect(page).toHaveURL(/\/hilfe\/rennmauspro-import$/) + await expect(page.getByRole('heading', { name: t.title })).toBeVisible() +}) + +test('Auswerten zeigt die Übersicht, danach importiert "Import durchführen"', async ({ page }) => { + skipUnlessMock() + await page.goto('/hilfe/rennmauspro-import') + + // Auswerten ohne Datei ist deaktiviert. + const analyzeBtn = page.getByRole('button', { name: t.analyzeButton }) + await expect(analyzeBtn).toBeDisabled() + + // Eine (Pseudo-).backup-Datei auswählen. + await page.getByLabel(t.backupLabel).setInputFiles({ + name: 'RennmausPro.backup', + mimeType: 'application/octet-stream', + buffer: Buffer.from('PK fake-zip'), + }) + await expect(analyzeBtn).toBeEnabled() + await analyzeBtn.click() + + // Auswertung erscheint (Zählungen + Abgleich). + await expect(page.getByRole('heading', { name: t.step2Title })).toBeVisible() + await expect(page.getByText(t.counts.ownAnimals)).toBeVisible() + await expect(page.getByText('303')).toBeVisible() + await expect(page.getByText(t.newCount(280))).toBeVisible() + + // Mehrdeutige Namen sind aufklappbar. + await page.getByText(t.ambiguousTitle).click() + await expect(page.getByText('Merlin')).toBeVisible() + + // Import durchführen. + await page.getByRole('button', { name: t.executeButton }).click() + await expect(page.getByRole('heading', { name: t.successTitle })).toBeVisible() + await expect(page.getByText('3694')).toBeVisible() + await expect(page.getByText(t.resultCounts.gerbils)).toBeVisible() +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 6c1af9a..4f71bb7 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' import HilfePage from './pages/HilfePage' +import RennmausProImportPage from './pages/RennmausProImportPage' import FarbkatalogPage from './pages/FarbkatalogPage' import VertraegeListPage from './pages/VertraegeListPage' import VertragWizardPage from './pages/VertragWizardPage' @@ -71,6 +72,8 @@ export default function App() { } /> } /> } /> + {/* RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") */} + } /> } /> {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} diff --git a/gerbil-manager-web/src/api/rpro3.ts b/gerbil-manager-web/src/api/rpro3.ts new file mode 100644 index 0000000..ee1e26f --- /dev/null +++ b/gerbil-manager-web/src/api/rpro3.ts @@ -0,0 +1,103 @@ +/** + * RPRO3: API-Client für den RennmausPro-III-Backup-Import. + * POST /import/rpro3/analyze -> multipart 'backup' (+ optional 'images') -> Auswertung (kein Schreiben) + * POST /import/rpro3/execute -> derselbe Upload -> idempotenter Import + * + * Upload via FormData (nicht api.post — das erzwingt application/json); der Browser setzt + * den multipart-Boundary-Header selbst. + */ +import { API_BASE_URL, ApiError } from './client' +import { de } from '../strings/de' + +export interface Rpro3Counts { + ownAnimals: number + externalRaw: number + externalAfterDedup: number + litters: number + contacts: number + genotypes: number + duplicatesMerged: number + mergeClusters: number +} + +export interface Rpro3AmbiguousVariant { + count: number + dob: string + farbe: string + origin: string + isOwn: boolean +} + +export interface Rpro3AmbiguousName { + name: string + variants: Rpro3AmbiguousVariant[] + bareCount: number +} + +export interface Rpro3MergeSample { + name: string + recordCount: number + dob: string + farbe: string + origin: string +} + +export interface Rpro3AnalyzeResult { + counts: Rpro3Counts + newVsCurrentNew: number + newVsCurrentExisting: number + topMerges: Rpro3MergeSample[] + ambiguousNames: Rpro3AmbiguousName[] + photosProvided: boolean + photoFilesAvailable: number +} + +export interface Rpro3ExecuteResult { + gerbilsImported: number + littersImported: number + contactsImported: number + healthRecordsImported: number + weightRecordsImported: number + photosImported: number + message: string +} + +async function postUpload(path: string, backup: File, images: File | null): Promise { + const form = new FormData() + form.append('backup', backup) + if (images) form.append('images', images) + + let response: Response + try { + response = await fetch(`${API_BASE_URL}${path}`, { method: 'POST', body: form }) + } catch { + throw new ApiError(de.api.errors.network, null, path) + } + if (!response.ok) { + // 400 trägt eine deutsche Klartext-Fehlermeldung (z. B. „keine _rpro3.db gefunden"). + let body: unknown + let text: string | null = null + try { + text = await response.clone().text() + body = text ? JSON.parse(text) : undefined + } catch { + body = undefined + } + const message = + response.status === 400 && text + ? text.replace(/^"|"$/g, '') + : response.status >= 500 + ? de.api.errors.server + : de.api.errors.unknown + throw new ApiError(message, response.status, path, body) + } + return (await response.json()) as T +} + +export function analyzeRpro3(backup: File, images: File | null): Promise { + return postUpload('/import/rpro3/analyze', backup, images) +} + +export function executeRpro3(backup: File, images: File | null): Promise { + return postUpload('/import/rpro3/execute', backup, images) +} diff --git a/gerbil-manager-web/src/pages/HilfePage.tsx b/gerbil-manager-web/src/pages/HilfePage.tsx index eabf3ca..cd8cce7 100644 --- a/gerbil-manager-web/src/pages/HilfePage.tsx +++ b/gerbil-manager-web/src/pages/HilfePage.tsx @@ -3,6 +3,7 @@ * Accordion via
/ — kein JS-State nötig, barrierefrei, mobiltauglich. * Texte referenzieren nur Bezeichnungen aus de.ts (bleiben bei Umbenennung korrekt). */ +import { Link } from 'react-router-dom' import { de } from '../strings/de' import './hilfe.css' @@ -210,6 +211,20 @@ const sections: Section[] = [ ), }, + { + id: 'rpro3-import', + title: t.sections.rpro3Import, + content: ( + <> +

{t.rpro3Link.text}

+

+ + {t.rpro3Link.button} → + +

+ + ), + }, ] export default function HilfePage() { diff --git a/gerbil-manager-web/src/pages/RennmausProImportPage.tsx b/gerbil-manager-web/src/pages/RennmausProImportPage.tsx new file mode 100644 index 0000000..90ec172 --- /dev/null +++ b/gerbil-manager-web/src/pages/RennmausProImportPage.tsx @@ -0,0 +1,255 @@ +/** + * RPRO3: Rubrik unter „Hilfe" für den RennmausPro-III-Backup-Import. + * Laienverständlicher 3-Schritt-Ablauf: + * 1) .backup (+ optional _bilder.zip) auswählen → „Auswerten" (schreibt nichts) + * 2) Auswertung: Zählungen, Dubletten, mehrdeutige Namen, neu/vorhanden + * 3) „Import durchführen" → Ergebnis + * Alle Texte aus de.ts. + */ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { de } from '../strings/de' +import { ApiError } from '../api/client' +import { + analyzeRpro3, + executeRpro3, + type Rpro3AnalyzeResult, + type Rpro3ExecuteResult, +} from '../api/rpro3' +import './rpro3-import.css' + +const t = de.pages.rpro3Import + +type Phase = 'select' | 'analyzing' | 'analyzed' | 'executing' | 'done' + +export default function RennmausProImportPage() { + const [backup, setBackup] = useState(null) + const [images, setImages] = useState(null) + const [phase, setPhase] = useState('select') + const [analysis, setAnalysis] = useState(null) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + function reset() { + setBackup(null) + setImages(null) + setPhase('select') + setAnalysis(null) + setResult(null) + setError(null) + } + + async function handleAnalyze() { + if (!backup) { + setError(t.noBackup) + return + } + setError(null) + setPhase('analyzing') + try { + const res = await analyzeRpro3(backup, images) + setAnalysis(res) + setPhase('analyzed') + } catch (err) { + setError(err instanceof ApiError ? err.message : de.api.errors.unknown) + setPhase('select') + } + } + + async function handleExecute() { + if (!backup) return + setError(null) + setPhase('executing') + try { + const res = await executeRpro3(backup, images) + setResult(res) + setPhase('done') + } catch (err) { + setError(err instanceof ApiError ? err.message : de.api.errors.unknown) + setPhase('analyzed') + } + } + + return ( +
+

{t.title}

+

{t.subtitle}

+

{t.intro}

+ + {error && ( +
+ {t.errorTitle}: {error} +
+ )} + + {/* Schritt 1: Dateien auswählen */} + {phase !== 'done' && ( +
+

{t.step1Title}

+
+ + setBackup(e.target.files?.[0] ?? null)} + /> + {t.backupHint} +
+
+ + setImages(e.target.files?.[0] ?? null)} + /> + {t.imagesHint} +
+ +
+ )} + + {/* Schritt 2: Auswertung */} + {analysis && phase !== 'done' && ( + + )} + + {/* Schritt 3: Import durchführen */} + {analysis && (phase === 'analyzed' || phase === 'executing') && ( +
+

{t.step3Title}

+

{t.executeWarning}

+ +
+ )} + + {/* Ergebnis */} + {result && phase === 'done' && ( +
+

{t.successTitle}

+
    +
  • {result.gerbilsImported} {t.resultCounts.gerbils}
  • +
  • {result.littersImported} {t.resultCounts.litters}
  • +
  • {result.contactsImported} {t.resultCounts.contacts}
  • +
  • {result.healthRecordsImported} {t.resultCounts.health}
  • +
  • {result.weightRecordsImported} {t.resultCounts.weights}
  • +
  • {result.photosImported} {t.resultCounts.photos}
  • +
+
+ {de.nav.gerbils} + +
+
+ )} +
+ ) +} + +function AnalysisView({ analysis }: { analysis: Rpro3AnalyzeResult }) { + const c = analysis.counts + return ( +
+

{t.step2Title}

+ +

{t.overviewTitle}

+
+ + + + + + +
+ +

{t.dedupExplain}

+ +

{t.newVsExistingTitle}

+

+ {t.newCount(analysis.newVsCurrentNew)}{' '} + {t.existingCount(analysis.newVsCurrentExisting)} +

+ +

+ {analysis.photosProvided ? t.photosNote(analysis.photoFilesAvailable) : t.photosNoneNote} +

+ + {analysis.topMerges.length > 0 && ( +
+ {t.topMergesTitle} +
+ + + + + + + + + + + + {analysis.topMerges.map((m, i) => ( + + + + + + + + ))} + +
{t.topMergesCol.name}{t.topMergesCol.count}{t.topMergesCol.dob}{t.topMergesCol.farbe}{t.topMergesCol.origin}
{m.name}{m.recordCount}{m.dob || '—'}{m.farbe || '—'}{m.origin || '—'}
+
+
+ )} + + {analysis.ambiguousNames.length > 0 && ( +
+ {t.ambiguousTitle} +

{t.ambiguousIntro}

+
    + {analysis.ambiguousNames.map((a, i) => ( +
  • + {a.name} +
      + {a.variants.map((v, j) => ( +
    • + {v.isOwn && } + {t.ambiguousVariant(v.count, v.dob, v.farbe, v.origin)} +
    • + ))} +
    +
  • + ))} +
+
+ )} +
+ ) +} + +function Count({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/gerbil-manager-web/src/pages/hilfe.css b/gerbil-manager-web/src/pages/hilfe.css index ebafb90..50d4496 100644 --- a/gerbil-manager-web/src/pages/hilfe.css +++ b/gerbil-manager-web/src/pages/hilfe.css @@ -103,3 +103,17 @@ .hilfe-warning { color: var(--color-warning-text, #92400e); } + +.hilfe-cta { + display: inline-block; + padding: 0.55rem 1rem; + border-radius: 6px; + background: var(--color-primary, #2563eb); + color: #fff; + text-decoration: none; + font-weight: 600; +} + +.hilfe-cta:hover { + background: var(--color-primary-dark, #1d4ed8); +} diff --git a/gerbil-manager-web/src/pages/rpro3-import.css b/gerbil-manager-web/src/pages/rpro3-import.css new file mode 100644 index 0000000..de46e9a --- /dev/null +++ b/gerbil-manager-web/src/pages/rpro3-import.css @@ -0,0 +1,235 @@ +.rpro3-page { + max-width: 760px; +} + +.rpro3-subtitle { + color: var(--color-muted, #666); + margin-bottom: 0.75rem; +} + +.rpro3-intro { + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.rpro3-card { + border: 1px solid var(--color-border, #ddd); + border-radius: 8px; + background: var(--color-surface, #fff); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.rpro3-card h3 { + margin: 0 0 1rem; + font-size: 1.1rem; +} + +.rpro3-card h4 { + margin: 1.25rem 0 0.5rem; + font-size: 1rem; +} + +.rpro3-field { + margin-bottom: 1rem; +} + +.rpro3-field label { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.rpro3-field input[type='file'] { + display: block; + width: 100%; + max-width: 100%; +} + +.rpro3-field small { + display: block; + color: var(--color-muted, #666); + margin-top: 0.25rem; +} + +.rpro3-btn { + display: inline-block; + padding: 0.6rem 1.1rem; + border: 1px solid var(--color-border, #ccc); + border-radius: 6px; + background: var(--color-surface, #fff); + color: inherit; + font-size: 1rem; + cursor: pointer; + text-decoration: none; +} + +.rpro3-btn:hover:not(:disabled) { + background: var(--color-hover, #f5f5f5); +} + +.rpro3-btn-primary { + background: var(--color-primary, #2563eb); + border-color: var(--color-primary, #2563eb); + color: #fff; +} + +.rpro3-btn-primary:hover:not(:disabled) { + background: var(--color-primary-dark, #1d4ed8); +} + +.rpro3-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.rpro3-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1rem; +} + +.rpro3-error { + background: var(--color-danger-bg, #fef2f2); + border-left: 3px solid var(--color-danger, #dc2626); + padding: 0.75rem 1rem; + border-radius: 0 4px 4px 0; + margin-bottom: 1.25rem; +} + +.rpro3-warning { + background: var(--color-warning-bg, #fffbeb); + border-left: 3px solid var(--color-warning, #f59e0b); + padding: 0.6rem 0.9rem; + border-radius: 0 4px 4px 0; + line-height: 1.55; + margin-bottom: 1rem; +} + +.rpro3-hint { + background: var(--color-info-bg, #eff6ff); + border-left: 3px solid var(--color-info, #3b82f6); + padding: 0.5rem 0.75rem; + border-radius: 0 4px 4px 0; + font-size: 0.93rem; + line-height: 1.55; +} + +.rpro3-photos-note { + color: var(--color-muted, #666); + font-size: 0.93rem; +} + +/* Zählungen als Kachel-Grid */ +.rpro3-counts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.75rem; + margin: 0.5rem 0 0.5rem; +} + +.rpro3-count { + border: 1px solid var(--color-border, #eee); + border-radius: 6px; + padding: 0.6rem 0.75rem; + background: var(--color-hover, #fafafa); +} + +.rpro3-count dt { + font-size: 0.85rem; + color: var(--color-muted, #666); +} + +.rpro3-count dd { + margin: 0.2rem 0 0; + font-size: 1.5rem; + font-weight: 700; +} + +.rpro3-badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: var(--color-hover, #f1f5f9); + font-size: 0.92rem; +} + +.rpro3-badge-new { + background: var(--color-info-bg, #eff6ff); + color: var(--color-info, #1d4ed8); + font-weight: 600; +} + +.rpro3-details { + margin-top: 1rem; + border: 1px solid var(--color-border, #eee); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.rpro3-details summary { + cursor: pointer; + font-weight: 600; +} + +.rpro3-table-wrap { + overflow-x: auto; + margin-top: 0.75rem; +} + +.rpro3-table { + border-collapse: collapse; + width: 100%; + font-size: 0.9rem; +} + +.rpro3-table th, +.rpro3-table td { + border: 1px solid var(--color-border, #eee); + padding: 0.35rem 0.55rem; + text-align: left; + white-space: nowrap; +} + +.rpro3-table th { + background: var(--color-hover, #f5f5f5); +} + +.rpro3-ambiguous { + margin: 0.75rem 0 0 1rem; + line-height: 1.5; +} + +.rpro3-ambiguous > li { + margin-bottom: 0.6rem; +} + +.rpro3-ambiguous ul { + margin: 0.2rem 0 0 1rem; + font-size: 0.92rem; + color: var(--color-muted, #555); +} + +.rpro3-star { + color: var(--color-primary, #2563eb); +} + +.rpro3-success { + border-color: var(--color-success, #16a34a); +} + +.rpro3-result-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.5rem; +} + +.rpro3-result-list li { + background: var(--color-hover, #f6f6f6); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index a146723..8b51412 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -820,6 +820,65 @@ export const de = { }, }, }, + // ── RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") ── + rpro3Import: { + title: 'RennmausPro-Import', + subtitle: + 'Daten aus deiner alten RennmausPro-III-Software übernehmen — Tiere, Würfe, Stammbäume, Kontakte und Genotypen.', + intro: + 'Lade hier deine RennmausPro-Sicherungsdatei hoch (die Datei endet auf „.backup"). Optional kannst du zusätzlich das zugehörige Bilder-Archiv (endet auf „_bilder.zip") hochladen. Zuerst wird die Datei nur ausgewertet — es wird noch nichts gespeichert. Du siehst dann eine Übersicht und entscheidest selbst, ob du den Import durchführst.', + step1Title: '1. Dateien auswählen', + backupLabel: 'RennmausPro-Sicherung (.backup)', + backupHint: 'Pflicht. Die Datei aus deiner RennmausPro-III-Software.', + imagesLabel: 'Bilder-Archiv (_bilder.zip)', + imagesHint: 'Optional. Wird gebraucht, um die Tierfotos zuzuordnen.', + analyzeButton: 'Auswerten', + analyzing: 'Datei wird ausgewertet …', + noBackup: 'Bitte zuerst eine RennmausPro-Sicherung (.backup) auswählen.', + step2Title: '2. Auswertung', + overviewTitle: 'Was steckt in der Datei?', + counts: { + ownAnimals: 'Eigene Tiere', + externalAfterDedup: 'Externe Ahnen (nach Bereinigung)', + externalRaw: 'Externe Ahnen (roh, vor Bereinigung)', + litters: 'Würfe', + contacts: 'Kontakte (Züchter & Abnehmer)', + genotypes: 'Hinterlegte Genotypen', + duplicatesMerged: 'Erkannte Dubletten (zusammengelegt)', + }, + newVsExistingTitle: 'Abgleich mit deinem aktuellen Bestand', + newCount: (n: number) => `${n} Tiere wären neu`, + existingCount: (n: number) => `${n} Tiere sind vermutlich schon vorhanden`, + dedupExplain: + 'RennmausPro hat beim Import vorhandene Tiere nicht erkannt — dadurch kommt dasselbe Tier oft mehrfach vor. Diese Dubletten werden automatisch anhand von Name, Geburtsdatum, Farbe und Herkunft zusammengelegt.', + topMergesTitle: 'Größte automatische Zusammenlegungen', + topMergesCol: { name: 'Tier', count: 'Datensätze', dob: 'Geburtsdatum', farbe: 'Farbe', origin: 'Herkunft' }, + ambiguousTitle: 'Mehrdeutige Namen — bitte später prüfen', + ambiguousIntro: + 'Bei diesen Namen gibt es mehrere unterschiedliche Tiere. Sie werden vorsichtshalber NICHT automatisch zusammengelegt, damit nichts falsch verknüpft wird. Du kannst sie nach dem Import in der Tierliste prüfen.', + ambiguousVariant: (count: number, dob: string, farbe: string, origin: string) => + `${count}× · ${dob} · ${farbe} · ${origin}`, + ambiguousOwn: 'eigenes Tier', + ambiguousMore: (n: number) => `… und ${n} weitere mehrdeutige Namen.`, + photosNote: (n: number) => `Bilder-Archiv erkannt: ${n} Bilddateien werden den Tieren zugeordnet.`, + photosNoneNote: 'Kein Bilder-Archiv hochgeladen — Tiere werden ohne Fotos importiert.', + step3Title: '3. Import durchführen', + executeWarning: + 'Beim Import werden die Tiere, Würfe, Kontakte und Genotypen aus RennmausPro übernommen. Ein erneuter Import aktualisiert dieselben Daten (es entstehen keine Dubletten). Deine manuell angelegten Tiere bleiben unberührt.', + executeButton: 'Import jetzt durchführen', + executing: 'Import läuft … das kann einen Moment dauern.', + successTitle: 'Import abgeschlossen', + resultCounts: { + gerbils: 'Tiere importiert', + litters: 'Würfe importiert', + contacts: 'Kontakte importiert', + health: 'Gesundheitseinträge', + weights: 'Gewichtseinträge', + photos: 'Fotos', + }, + errorTitle: 'Es ist ein Fehler aufgetreten', + restart: 'Neue Datei auswählen', + }, }, // ── HELP-1: In-App-Anleitung ── hilfe: { @@ -838,6 +897,12 @@ export const de = { einstellungen: 'Zuchtprofil (Einstellungen)', statistik: 'Statistik', datensicherung: 'Datensicherung', + rpro3Import: 'Daten aus RennmausPro übernehmen', + }, + // Verweis auf die eigene RennmausPro-Import-Rubrik. + rpro3Link: { + text: 'Hast du früher mit der Software „RennmausPro III" gearbeitet? Du kannst deine alten Daten (Tiere, Würfe, Stammbäume, Kontakte) bequem übernehmen.', + button: 'Zum RennmausPro-Import', }, }, api: {