Merge branch 'worktree-agent-a1dfa0392b5ace900'
# Conflicts: # gerbil-manager-web/e2e/mock-data.ts # gerbil-manager-web/src/App.tsx
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -143,3 +143,6 @@ node_modules/
|
|||||||
|
|
||||||
# Runtime: gespeicherte Vertrags-Dateien
|
# Runtime: gespeicherte Vertrags-Dateien
|
||||||
GerbilManagerWebAPI/contract-storage/
|
GerbilManagerWebAPI/contract-storage/
|
||||||
|
|
||||||
|
# Agent-Worktrees (lokal, nicht versionieren)
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
218
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
218
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Rpro3Animal>
|
||||||
|
{
|
||||||
|
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<Rpro3Animal>
|
||||||
|
{
|
||||||
|
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<ApplicationContext>().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 };
|
||||||
|
|
||||||
|
/// <summary>Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
54
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Dtos
|
||||||
|
{
|
||||||
|
/// <summary>Zählungen für die Analyse-Übersicht (RennmausPro-III-Import).</summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin).</summary>
|
||||||
|
public record Rpro3AmbiguousVariant(
|
||||||
|
int Count,
|
||||||
|
string Dob,
|
||||||
|
string Farbe,
|
||||||
|
string Origin,
|
||||||
|
bool IsOwn);
|
||||||
|
|
||||||
|
public record Rpro3AmbiguousName(
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<Rpro3AmbiguousVariant> Variants,
|
||||||
|
int BareCount);
|
||||||
|
|
||||||
|
/// <summary>Ein großer sicherer Merge-Cluster (zur Veranschaulichung der Dedup-Wirkung).</summary>
|
||||||
|
public record Rpro3MergeSample(
|
||||||
|
string Name,
|
||||||
|
int RecordCount,
|
||||||
|
string Dob,
|
||||||
|
string Farbe,
|
||||||
|
string Origin);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von POST /import/rpro3/analyze — schreibt NICHTS in die DB.</summary>
|
||||||
|
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<Rpro3MergeSample> TopMerges,
|
||||||
|
IReadOnlyList<Rpro3AmbiguousName> AmbiguousNames,
|
||||||
|
bool PhotosProvided,
|
||||||
|
int PhotoFilesAvailable);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von POST /import/rpro3/execute.</summary>
|
||||||
|
public record Rpro3ExecuteResult(
|
||||||
|
int GerbilsImported,
|
||||||
|
int LittersImported,
|
||||||
|
int ContactsImported,
|
||||||
|
int HealthRecordsImported,
|
||||||
|
int WeightRecordsImported,
|
||||||
|
int PhotosImported,
|
||||||
|
string Message);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Import;
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Import.Rpro3;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
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/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
/// 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.
|
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ImportEndpoints
|
public static class ImportEndpoints
|
||||||
{
|
{
|
||||||
@@ -36,7 +44,109 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(result);
|
return TypedResults.Ok(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||||
|
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||||
|
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<string>? 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<Results<Ok<Rpro3ExecuteResult>, BadRequest<string>>> (
|
||||||
|
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<string, string>? 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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren.
|
||||||
|
private static async Task<MemoryStream> 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<string> ListZipImageNames(Stream zip)
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(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<string, string> ExtractZipImages(Stream zip, string workDir)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(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 */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||||
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
197
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
197
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class Rpro3Dedup
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> PlaceholderNames = new(StringComparer.Ordinal)
|
||||||
|
{ "", "-", "n", "unbekannt", "unbekannt?", "?", "nn", "n.n.", "na", "namenlos", "." };
|
||||||
|
|
||||||
|
private static readonly HashSet<string> UnknownValues = new(StringComparer.Ordinal)
|
||||||
|
{ "", "unbekannt", "unbek.", "unbek", "unbekannte zucht", "?", "n.n.",
|
||||||
|
"unbekannter farbschlag", "keine angabe", "k.a.", "na", "unbekannte farbe" };
|
||||||
|
|
||||||
|
public sealed class DedupResult
|
||||||
|
{
|
||||||
|
/// <summary>Cluster (mehr als ein Datensatz) → die zusammengelegten Tiere. Key = Wurzel-Rid.</summary>
|
||||||
|
public Dictionary<string, List<Rpro3Animal>> MergeClusters { get; } = new();
|
||||||
|
/// <summary>Repräsentant-Rid je Cluster (auch Singletons) → Cluster-Wurzel.</summary>
|
||||||
|
public Dictionary<string, string> RidToRoot { get; } = new();
|
||||||
|
/// <summary>Mehrdeutige Namen: Name → Varianten (manuelle Entscheidung nötig).</summary>
|
||||||
|
public List<AmbiguousName> Ambiguous { get; } = new();
|
||||||
|
/// <summary>Platzhalter-Datensätze (nicht dedupbar) gruppiert nach Name.</summary>
|
||||||
|
public List<Rpro3Animal> 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<Variant> Variants { get; } = new();
|
||||||
|
public int BareCount { get; set; } // merkmalslose Varianten (nur Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Variant
|
||||||
|
{
|
||||||
|
public int Count { get; set; }
|
||||||
|
public List<string> Dob { get; } = new();
|
||||||
|
public List<string> Farbe { get; } = new();
|
||||||
|
public List<string> 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<Rpro3Animal> 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<string, List<Rpro3Animal>>();
|
||||||
|
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, string>();
|
||||||
|
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<string, List<Rpro3Animal>>();
|
||||||
|
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<string, HashSet<string>>();
|
||||||
|
var disp = new Dictionary<string, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Repräsentant eines Clusters: bevorzugt ein stamm-Tier, sonst das mit den meisten
|
||||||
|
/// bekannten Feldern (DOB/Farbe/Origin), Tiebreak per Rid (stabil).</summary>
|
||||||
|
public static Rpro3Animal Representative(List<Rpro3Animal> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs
Normal file
31
GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
|
||||||
|
/// <summary>Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte).</summary>
|
||||||
|
public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}";
|
||||||
|
public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
586
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
586
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// „First class" RennmausPro-III-Backup-Importer. Parst die `.backup` (SQLite), dedupliziert
|
||||||
|
/// RPRO3-interne Dubletten (Port von compare_rpro3.py) und überführt die Daten in unser Modell.
|
||||||
|
///
|
||||||
|
/// Zwei Operationen:
|
||||||
|
/// AnalyzeAsync — schreibt NICHTS; liefert Zählungen, Top-Merges, mehrdeutige Namen, neu/vorhanden.
|
||||||
|
/// ExecuteAsync — idempotenter Import (deterministische GUIDs + ExternalRef). Re-Run aktualisiert
|
||||||
|
/// dieselben Zeilen; vorherige RPRO3-Importe (ImportSource="RennmausPro III")
|
||||||
|
/// werden vor dem Neuladen entfernt. Spreadsheet-Importe bleiben unangetastet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Rpro3ImportService
|
||||||
|
{
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _photoRoot;
|
||||||
|
|
||||||
|
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
|
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── ANALYZE ─────────────────────────
|
||||||
|
|
||||||
|
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||||
|
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||||
|
{
|
||||||
|
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||||
|
var plan = BuildPlan(data, dedup);
|
||||||
|
|
||||||
|
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
||||||
|
var existing = await _db.Gerbils.AsNoTracking()
|
||||||
|
.Select(g => new { g.NameSearch, g.DateOfBirth, g.ExternalRef })
|
||||||
|
.ToListAsync();
|
||||||
|
var existingRefs = existing.Where(e => e.ExternalRef is not null)
|
||||||
|
.Select(e => e.ExternalRef!).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var existingByName = existing
|
||||||
|
.GroupBy(e => e.NameSearch ?? "")
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.DateOfBirth).ToList());
|
||||||
|
|
||||||
|
int newCount = 0, existingCount = 0;
|
||||||
|
foreach (var gp in plan.Gerbils.Values)
|
||||||
|
{
|
||||||
|
if (existingRefs.Contains(gp.ExternalRef)) { existingCount++; continue; }
|
||||||
|
var key = GerbilSearch.Normalize(gp.Name);
|
||||||
|
if (existingByName.TryGetValue(key, out var dobs) && DobMatch(gp.Dob, dobs)) existingCount++;
|
||||||
|
else newCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts = new Rpro3Counts(
|
||||||
|
OwnAnimals: data.Animals.Count(a => a.Src == Rpro3Src.Stamm),
|
||||||
|
ExternalRaw: data.Animals.Count(a => a.Src == Rpro3Src.Fremd),
|
||||||
|
ExternalAfterDedup: plan.Gerbils.Values.Count(g => !g.IsResident),
|
||||||
|
Litters: data.Litters.Count,
|
||||||
|
Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
|
||||||
|
Genotypes: data.ColorStammCount + data.ColorExtCount,
|
||||||
|
DuplicatesMerged: dedup.DuplicatesRemoved,
|
||||||
|
MergeClusters: dedup.MergeClusters.Count);
|
||||||
|
|
||||||
|
var topMerges = dedup.MergeClusters.Values
|
||||||
|
.OrderByDescending(v => v.Count)
|
||||||
|
.Take(25)
|
||||||
|
.Select(v =>
|
||||||
|
{
|
||||||
|
var rep = Rpro3Dedup.Representative(v);
|
||||||
|
return new Rpro3MergeSample(
|
||||||
|
rep.Name ?? "—",
|
||||||
|
v.Count,
|
||||||
|
string.Join(", ", v.Where(x => x.Dob is not null).Select(x => x.Dob!.Value.ToString("yyyy-MM-dd")).Distinct()),
|
||||||
|
Truncate(string.Join(", ", v.Where(x => !string.IsNullOrEmpty(x.Farbe)).Select(x => x.Farbe!).Distinct()), 60),
|
||||||
|
Truncate(string.Join(", ", v.Where(x => Rpro3Dedup.NormValue(x.Origin).Length > 0).Select(x => x.Origin!).Distinct()), 60));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ambiguous = dedup.Ambiguous
|
||||||
|
.Take(80)
|
||||||
|
.Select(a => new Rpro3AmbiguousName(
|
||||||
|
a.Name,
|
||||||
|
a.Variants.Select(v => new Rpro3AmbiguousVariant(
|
||||||
|
v.Count,
|
||||||
|
v.Dob.Count > 0 ? string.Join(", ", v.Dob) : "—",
|
||||||
|
v.Farbe.Count > 0 ? Truncate(string.Join(", ", v.Farbe), 40) : "—",
|
||||||
|
v.Origin.Count > 0 ? Truncate(string.Join(", ", v.Origin), 40) : "—",
|
||||||
|
v.IsOwn)).ToList(),
|
||||||
|
a.BareCount))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new Rpro3AnalyzeResult(
|
||||||
|
counts, newCount, existingCount, topMerges, ambiguous,
|
||||||
|
photosProvided, availablePhotoFiles?.Count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DobMatch(DateOnly? rpDob, List<DateOnly?> existing)
|
||||||
|
{
|
||||||
|
// Match, wenn ein Bestandstier denselben (oder unbekannten) Geburtstag hat (±14 Tage).
|
||||||
|
if (rpDob is null) return true;
|
||||||
|
foreach (var d in existing)
|
||||||
|
{
|
||||||
|
if (d is null) return true;
|
||||||
|
if (Math.Abs((rpDob.Value.ToDateTime(TimeOnly.MinValue) - d.Value.ToDateTime(TimeOnly.MinValue)).TotalDays) <= 14)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── EXECUTE ─────────────────────────
|
||||||
|
|
||||||
|
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
||||||
|
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
||||||
|
{
|
||||||
|
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||||
|
var plan = BuildPlan(data, dedup);
|
||||||
|
|
||||||
|
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
|
||||||
|
// ExecuteAsync auf derselben DbContext-Instanz würden sonst alte Tracking-Einträge
|
||||||
|
// mit den neu eingefügten (gleiche deterministische IDs) kollidieren.
|
||||||
|
_db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
// 1. Vorherigen RPRO3-Import idempotent entfernen (deterministische IDs → Re-Run ok).
|
||||||
|
// Health/Weight zuerst (FK auf Gerbil), dann FK-Links lösen, dann Gerbils/Litters.
|
||||||
|
var priorGerbilIds = await _db.Gerbils
|
||||||
|
.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
|
||||||
|
.Select(g => g.Id).ToListAsync();
|
||||||
|
var priorLitterRefs = plan.Litters.Values.Select(l => l.ExternalRef).ToList();
|
||||||
|
|
||||||
|
if (priorGerbilIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.HealthRecords.Where(h => priorGerbilIds.Contains(h.GerbilId)).ExecuteDeleteAsync();
|
||||||
|
await _db.WeightRecords.Where(w => priorGerbilIds.Contains(w.GerbilId)).ExecuteDeleteAsync();
|
||||||
|
await _db.GerbilPhotos.Where(p => priorGerbilIds.Contains(p.GerbilId)).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
// FK-Schleifen lösen: Litter.Father/Mother + Gerbil.Litter, die auf RPRO3-Tiere zeigen.
|
||||||
|
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FatherId, (Guid?)null).SetProperty(l => l.MotherId, (Guid?)null));
|
||||||
|
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(g => g.LitterId, (Guid?)null));
|
||||||
|
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync();
|
||||||
|
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände).
|
||||||
|
int contactsImported = 0;
|
||||||
|
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||||
|
foreach (var c in plan.Contacts.Values)
|
||||||
|
{
|
||||||
|
if (existingContacts.TryGetValue(c.Id, out var ex))
|
||||||
|
{
|
||||||
|
ex.Name = c.Name; ex.Email = c.Email; ex.Phone = c.Phone; ex.Address = c.Address;
|
||||||
|
ex.Notes = c.Notes; ex.IsBreeder = c.IsBreeder; ex.IsReceiver = c.IsReceiver;
|
||||||
|
ex.NameSuffix = c.NameSuffix; ex.Provenance = c.Provenance;
|
||||||
|
}
|
||||||
|
else { _db.Contacts.Add(c); }
|
||||||
|
contactsImported++;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 3. Litters (Pass 1: ohne Eltern-FKs).
|
||||||
|
foreach (var l in plan.Litters.Values)
|
||||||
|
_db.Litters.Add(new Litter
|
||||||
|
{
|
||||||
|
Id = l.Id, Name = l.Name, Date = l.Date, ExternalRef = l.ExternalRef,
|
||||||
|
Notes = l.Notes, Provenance = l.Provenance, FatherId = null, MotherId = null,
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 4. Gerbils (Pass 1: ohne LitterId).
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
foreach (var g in plan.Gerbils.Values)
|
||||||
|
{
|
||||||
|
var gerbil = new Gerbil
|
||||||
|
{
|
||||||
|
Id = g.Id, Name = g.Name, Gender = g.Gender,
|
||||||
|
DateOfBirth = g.Dob, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
|
||||||
|
Genotype = g.Genotype, ColorVarietyId = g.ColorVarietyId, OriginBreeder = g.OriginBreeder,
|
||||||
|
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
|
||||||
|
GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
|
||||||
|
Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
|
||||||
|
Provenance = g.Provenance, LitterId = null,
|
||||||
|
};
|
||||||
|
GerbilStatusService.Apply(gerbil, today);
|
||||||
|
_db.Gerbils.Add(gerbil);
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 5. Pass 2: FK-Beziehungen setzen (Litter-Eltern + Gerbil-Geburtswurf).
|
||||||
|
var littersInDb = await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ToDictionaryAsync(l => l.Id);
|
||||||
|
foreach (var l in plan.Litters.Values)
|
||||||
|
if (littersInDb.TryGetValue(l.Id, out var dbl))
|
||||||
|
{
|
||||||
|
dbl.MotherId = l.MotherId; dbl.FatherId = l.FatherId;
|
||||||
|
}
|
||||||
|
var gerbilsInDb = await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ToDictionaryAsync(g => g.Id);
|
||||||
|
foreach (var g in plan.Gerbils.Values)
|
||||||
|
if (g.LitterId is not null && gerbilsInDb.TryGetValue(g.Id, out var dbg))
|
||||||
|
dbg.LitterId = g.LitterId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 6. Gesundheits- und Gewichtseinträge.
|
||||||
|
foreach (var h in plan.Health) _db.HealthRecords.Add(h);
|
||||||
|
foreach (var w in plan.Weights) _db.WeightRecords.Add(w);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 7. Fotos (optional, aus bilder.zip).
|
||||||
|
int photosImported = 0;
|
||||||
|
if (photosProvided && photoSourcePaths is not null)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_photoRoot);
|
||||||
|
foreach (var (gerbilId, fileNames) in plan.PhotoFiles)
|
||||||
|
{
|
||||||
|
int sort = 0;
|
||||||
|
foreach (var fn in fileNames)
|
||||||
|
{
|
||||||
|
if (!photoSourcePaths.TryGetValue(fn.ToLowerInvariant(), out var src) || !File.Exists(src)) continue;
|
||||||
|
var ext = Path.GetExtension(fn);
|
||||||
|
var storedName = $"{Guid.NewGuid():N}{ext}";
|
||||||
|
try { File.Copy(src, Path.Combine(_photoRoot, storedName), overwrite: true); }
|
||||||
|
catch { continue; }
|
||||||
|
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), GerbilId = gerbilId, FileName = storedName,
|
||||||
|
SortOrder = sort++, CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
photosImported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Rpro3ExecuteResult(
|
||||||
|
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
|
||||||
|
plan.Health.Count, plan.Weights.Count, photosImported,
|
||||||
|
$"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " +
|
||||||
|
$"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " +
|
||||||
|
$"{photosImported} Fotos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── PLAN-AUFBAU ─────────────────────────
|
||||||
|
|
||||||
|
private sealed class GerbilPlan
|
||||||
|
{
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public required string ExternalRef { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public Gender Gender { get; set; }
|
||||||
|
public DateOnly? Dob { get; set; }
|
||||||
|
public DateOnly? DateOfDeath { get; set; }
|
||||||
|
public string? CauseOfDeath { get; set; }
|
||||||
|
public string? Genotype { get; set; }
|
||||||
|
public Guid? ColorVarietyId { get; set; }
|
||||||
|
public string? OriginBreeder { get; set; }
|
||||||
|
public Guid? OriginContactId { get; set; }
|
||||||
|
public Guid? ReceiverContactId { get; set; }
|
||||||
|
public DateOnly? GoHomeDate { get; set; }
|
||||||
|
public bool IsResident { get; set; }
|
||||||
|
public bool IsCastrated { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public string? Provenance { get; set; }
|
||||||
|
public Guid? LitterId { get; set; }
|
||||||
|
public string? MotherRid { get; set; }
|
||||||
|
public string? FatherRid { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LitterPlan
|
||||||
|
{
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public required string ExternalRef { get; init; }
|
||||||
|
public string Name { get; set; } = "Wurf";
|
||||||
|
public DateOnly? Date { get; set; }
|
||||||
|
public Guid? MotherId { get; set; }
|
||||||
|
public Guid? FatherId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public string? Provenance { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImportPlan
|
||||||
|
{
|
||||||
|
public Dictionary<Guid, GerbilPlan> Gerbils { get; } = new();
|
||||||
|
public Dictionary<Guid, LitterPlan> Litters { get; } = new();
|
||||||
|
public Dictionary<Guid, Contact> Contacts { get; } = new();
|
||||||
|
public List<HealthRecord> Health { get; } = new();
|
||||||
|
public List<WeightRecord> Weights { get; } = new();
|
||||||
|
public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup)
|
||||||
|
{
|
||||||
|
_lastDedup = dedup;
|
||||||
|
var plan = new ImportPlan();
|
||||||
|
|
||||||
|
// 0) Farbschlag-Katalog (Name → Id) für ColorVariety-Verknüpfung.
|
||||||
|
var colorByName = _db.ColorVarieties.AsNoTracking()
|
||||||
|
.ToDictionary(c => Rpro3Dedup.NormName(c.Name), c => c.Id);
|
||||||
|
|
||||||
|
// 1) Kontakte (herk = Züchter/Herkunft, abn = Abnehmer).
|
||||||
|
Guid? HerkContactId(int? herkId)
|
||||||
|
{
|
||||||
|
if (herkId is null || herkId == 1) return null; // 1 = eigene Zucht → kein Kontakt
|
||||||
|
if (!data.HerkContacts.TryGetValue(herkId.Value.ToString(), out var c)) return null;
|
||||||
|
return EnsureContact(plan, c, isBreeder: true, isReceiver: false);
|
||||||
|
}
|
||||||
|
Guid? AbnContactId(int? abnId)
|
||||||
|
{
|
||||||
|
if (abnId is null) return null;
|
||||||
|
if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return null;
|
||||||
|
return EnsureContact(plan, c, isBreeder: false, isReceiver: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Rid → Cluster-Root → Gerbil-GUID. Platzhalter-Tiere bekommen ihren eigenen Root.
|
||||||
|
string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
|
||||||
|
Guid GerbilIdOfRid(string rid) => Rpro3Guid.Gerbil(RootOf(rid));
|
||||||
|
|
||||||
|
// 3) Pup-Index: SID(stamm-id) → pup, und stamm-id → Welpen-Daten (Abgabe/Preis/Tod).
|
||||||
|
var pupBySid = new Dictionary<string, Rpro3Pup>(StringComparer.Ordinal);
|
||||||
|
foreach (var p in data.Pups.Values)
|
||||||
|
if (!string.IsNullOrWhiteSpace(p.Sid) && p.Sid != "0")
|
||||||
|
pupBySid[p.Sid] = p;
|
||||||
|
|
||||||
|
// 4) Ein Gerbil je Cluster (Repräsentant führt; stamm-Tiere machen den Cluster resident).
|
||||||
|
// Singletons (RidToRoot == self) ebenfalls einbeziehen.
|
||||||
|
var clusterMembers = new Dictionary<string, List<Rpro3Animal>>(StringComparer.Ordinal);
|
||||||
|
foreach (var a in data.Animals)
|
||||||
|
{
|
||||||
|
var root = RootOf(a.Rid);
|
||||||
|
if (!clusterMembers.TryGetValue(root, out var list)) clusterMembers[root] = list = new();
|
||||||
|
list.Add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (root, members) in clusterMembers)
|
||||||
|
{
|
||||||
|
var rep = Rpro3Dedup.Representative(members);
|
||||||
|
bool resident = members.Any(m => m.Src == Rpro3Src.Stamm);
|
||||||
|
var id = Rpro3Guid.Gerbil(root);
|
||||||
|
if (plan.Gerbils.ContainsKey(id)) continue;
|
||||||
|
|
||||||
|
// Bestes bekanntes Feld aus allen Cluster-Mitgliedern wählen.
|
||||||
|
var dob = members.Select(m => m.Dob).FirstOrDefault(d => d is not null) ?? rep.Dob;
|
||||||
|
var farbe = members.Select(m => m.Farbe).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Farbe;
|
||||||
|
var fcode = members.Select(m => m.Fcode).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Fcode;
|
||||||
|
var death = members.FirstOrDefault(m => m.DateOfDeath is not null);
|
||||||
|
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
|
||||||
|
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
|
||||||
|
|
||||||
|
var gp = new GerbilPlan
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ExternalRef = Rpro3Guid.GerbilRef(root),
|
||||||
|
Name = string.IsNullOrWhiteSpace(rep.Name) ? "Unbenannt" : rep.Name!,
|
||||||
|
Gender = members.Select(m => m.Gender).FirstOrDefault(g => g != Gender.unknown),
|
||||||
|
Dob = dob,
|
||||||
|
DateOfDeath = death?.DateOfDeath,
|
||||||
|
CauseOfDeath = death?.CauseOfDeath,
|
||||||
|
Genotype = CleanGenotype(fcode),
|
||||||
|
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
||||||
|
OriginContactId = HerkContactId(herkId),
|
||||||
|
IsResident = resident,
|
||||||
|
IsCastrated = members.Any(m => m.IsCastrated),
|
||||||
|
MotherRid = rep.MidRaw,
|
||||||
|
FatherRid = rep.PidRaw,
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(farbe) && colorByName.TryGetValue(Rpro3Dedup.NormName(farbe), out var cvId))
|
||||||
|
gp.ColorVarietyId = cvId;
|
||||||
|
|
||||||
|
// Welpen-Zusatzdaten (Abgabe/Preis) für residente Tiere, die ein wurftier sind.
|
||||||
|
foreach (var m in members.Where(m => m.Src == Rpro3Src.Stamm))
|
||||||
|
if (pupBySid.TryGetValue(m.Rid, out var pup))
|
||||||
|
{
|
||||||
|
gp.GoHomeDate = pup.AbgabeDate;
|
||||||
|
gp.ReceiverContactId = AbnContactId(pup.AbnId);
|
||||||
|
if (pup.DateOfDeath is not null && gp.DateOfDeath is null)
|
||||||
|
{
|
||||||
|
gp.DateOfDeath = pup.DateOfDeath; gp.CauseOfDeath = pup.CauseOfDeath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gp.Notes = BuildNotes(members);
|
||||||
|
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||||
|
plan.Gerbils[id] = gp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Würfe aus wurf_tb (autoritativ). Eltern → Cluster-GUID.
|
||||||
|
foreach (var (wid, w) in data.Litters)
|
||||||
|
{
|
||||||
|
var id = Rpro3Guid.Litter(wid);
|
||||||
|
var lp = new LitterPlan
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ExternalRef = Rpro3Guid.LitterRef(wid),
|
||||||
|
Name = string.IsNullOrWhiteSpace(w.Name) ? $"Wurf {wid}" : w.Name!,
|
||||||
|
Date = w.Date,
|
||||||
|
Notes = w.Notes,
|
||||||
|
MotherId = ResolveParentGuid(w.MotherRid, plan),
|
||||||
|
FatherId = ResolveParentGuid(w.FatherRid, plan),
|
||||||
|
Provenance = JsonProvenance("RennmausPro III", new() { ["wurfId"] = wid, ["quelle"] = "wurf_tb" }),
|
||||||
|
};
|
||||||
|
plan.Litters[id] = lp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Welpen → Geburtswurf verknüpfen (pup._SID → resultierendes Gerbil).
|
||||||
|
foreach (var p in data.Pups.Values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(p.Sid) || p.Sid == "0") continue;
|
||||||
|
var gid = GerbilIdOfRid(p.Sid);
|
||||||
|
if (!plan.Gerbils.TryGetValue(gid, out var gp)) continue;
|
||||||
|
var litterId = Rpro3Guid.Litter(p.WurfId);
|
||||||
|
if (plan.Litters.ContainsKey(litterId)) gp.LitterId = litterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Synthetische „Pedigree-Würfe" für Tiere mit baum_tb-Eltern, aber ohne wurf_tb-Geburtswurf.
|
||||||
|
// So traversiert der Stammbaum (Litter → Father/Mother) auch externe Ahnen-Linien.
|
||||||
|
foreach (var gp in plan.Gerbils.Values)
|
||||||
|
{
|
||||||
|
if (gp.LitterId is not null) continue;
|
||||||
|
var motherId = ResolveParentGuid(gp.MotherRid, plan);
|
||||||
|
var fatherId = ResolveParentGuid(gp.FatherRid, plan);
|
||||||
|
if (motherId is null && fatherId is null) continue;
|
||||||
|
var key = $"ped:{motherId}:{fatherId}";
|
||||||
|
var litterId = Rpro3Guid.For("litter", key);
|
||||||
|
if (!plan.Litters.TryGetValue(litterId, out var lp))
|
||||||
|
{
|
||||||
|
lp = new LitterPlan
|
||||||
|
{
|
||||||
|
Id = litterId,
|
||||||
|
ExternalRef = $"rpro3-ped:{motherId}:{fatherId}",
|
||||||
|
Name = "Abstammung (RennmausPro)",
|
||||||
|
MotherId = motherId,
|
||||||
|
FatherId = fatherId,
|
||||||
|
Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = "baum_tb", ["synthetisch"] = true }),
|
||||||
|
};
|
||||||
|
plan.Litters[litterId] = lp;
|
||||||
|
}
|
||||||
|
gp.LitterId = litterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) Gesundheit/Gewicht/Tagebuch → stamm-Tiere (tid = stamm-id).
|
||||||
|
BuildHealthAndWeights(data, plan, GerbilIdOfRid);
|
||||||
|
|
||||||
|
// 9) Fotos (photo_tb id = stamm-id).
|
||||||
|
foreach (var (tid, set) in data.Photos)
|
||||||
|
{
|
||||||
|
var gid = GerbilIdOfRid(tid);
|
||||||
|
if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid? ResolveParentGuid(string? rid, ImportPlan plan)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
|
||||||
|
var gid = Rpro3Guid.Gerbil(_lastDedup!.RidToRoot.TryGetValue(rid, out var r) ? r : rid);
|
||||||
|
return plan.Gerbils.ContainsKey(gid) ? gid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontext-Hack: ResolveParentGuid braucht die RidToRoot-Map des aktuellen Plans.
|
||||||
|
private Rpro3Dedup.DedupResult? _lastDedup;
|
||||||
|
|
||||||
|
private void BuildHealthAndWeights(Rpro3Data data, ImportPlan plan, Func<string, Guid> gerbilIdOfRid)
|
||||||
|
{
|
||||||
|
int seq = 0;
|
||||||
|
foreach (var h in data.Health)
|
||||||
|
{
|
||||||
|
var gid = gerbilIdOfRid(h.Tid);
|
||||||
|
if (!plan.Gerbils.ContainsKey(gid) || h.Date is null) continue;
|
||||||
|
var desc = string.IsNullOrWhiteSpace(h.Description) ? "Krankheitseintrag" : h.Description!;
|
||||||
|
if (!string.IsNullOrWhiteSpace(h.Medication)) desc += $" (Medikation: {h.Medication})";
|
||||||
|
plan.Health.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Rpro3Guid.Health(h.Tid, desc, seq++),
|
||||||
|
GerbilId = gid, Date = h.Date.Value, Type = HealthRecordType.Treatment,
|
||||||
|
Description = desc, CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Tagebuch als "Other"-Gesundheitseinträge (kein eigenes Tagebuch-Modell vorhanden).
|
||||||
|
foreach (var d in data.Diary)
|
||||||
|
{
|
||||||
|
var gid = gerbilIdOfRid(d.Tid);
|
||||||
|
if (!plan.Gerbils.ContainsKey(gid) || d.Date is null || string.IsNullOrWhiteSpace(d.Description)) continue;
|
||||||
|
var desc = (string.IsNullOrWhiteSpace(d.Title) ? "" : d.Title + ": ") + d.Description;
|
||||||
|
plan.Health.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Rpro3Guid.Health(d.Tid, "diary:" + desc, seq++),
|
||||||
|
GerbilId = gid, Date = d.Date.Value, Type = HealthRecordType.Other,
|
||||||
|
Description = "Tagebuch: " + desc, CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int wseq = 0;
|
||||||
|
var seen = new HashSet<Guid>();
|
||||||
|
foreach (var w in data.Weights)
|
||||||
|
{
|
||||||
|
var gid = gerbilIdOfRid(w.Tid);
|
||||||
|
if (!plan.Gerbils.ContainsKey(gid) || w.Date is null || w.Grams <= 0) continue;
|
||||||
|
var id = Rpro3Guid.Weight(w.Tid + ":" + w.Date.Value.DayNumber, wseq++);
|
||||||
|
if (!seen.Add(id)) continue;
|
||||||
|
plan.Weights.Add(new WeightRecord
|
||||||
|
{
|
||||||
|
Id = id, GerbilId = gid, Date = w.Date.Value, WeightGrams = w.Grams, Notes = w.Notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid EnsureContact(ImportPlan plan, Rpro3Contact c, bool isBreeder, bool isReceiver)
|
||||||
|
{
|
||||||
|
var id = Rpro3Guid.Contact(c.Namespace, c.Id);
|
||||||
|
if (plan.Contacts.TryGetValue(id, out var ex))
|
||||||
|
{
|
||||||
|
ex.IsBreeder |= isBreeder; ex.IsReceiver |= isReceiver;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
var address = string.Join(", ",
|
||||||
|
new[] { c.Str, $"{c.Plz} {c.Ort}".Trim() }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||||
|
var notes = string.Join("\n",
|
||||||
|
new[] { c.Memo, c.Bem, c.Http }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||||
|
plan.Contacts[id] = new Contact
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = c.DisplayName,
|
||||||
|
Email = string.IsNullOrWhiteSpace(c.Mail) ? null : c.Mail,
|
||||||
|
Phone = string.IsNullOrWhiteSpace(c.Telp) ? c.Teld : c.Telp,
|
||||||
|
Address = string.IsNullOrWhiteSpace(address) ? null : address,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
|
||||||
|
IsBreeder = isBreeder,
|
||||||
|
IsReceiver = isReceiver,
|
||||||
|
NameSuffix = string.IsNullOrWhiteSpace(c.Clan) ? null : c.Clan,
|
||||||
|
Provenance = JsonProvenance("RennmausPro III",
|
||||||
|
new() { ["quelle"] = c.Namespace == "herk" ? "herk_tb" : "abn_tb", ["rpro3Id"] = c.Id }),
|
||||||
|
};
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── Helpers ─────────────────────────
|
||||||
|
|
||||||
|
/// <summary>RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt
|
||||||
|
/// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten.</summary>
|
||||||
|
internal static string? CleanGenotype(string? fcode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fcode)) return null;
|
||||||
|
var sb = new System.Text.StringBuilder(fcode.Length);
|
||||||
|
foreach (var ch in fcode)
|
||||||
|
if (ch != '[' && ch != ']') sb.Append(ch);
|
||||||
|
var cleaned = sb.ToString().Trim();
|
||||||
|
return cleaned.Length == 0 ? null : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildNotes(List<Rpro3Animal> members)
|
||||||
|
{
|
||||||
|
var defects = members.Select(m => m.Defects).Where(d => !string.IsNullOrWhiteSpace(d)).Distinct().ToList();
|
||||||
|
var zb = members.Select(m => m.Zb).FirstOrDefault(z => !string.IsNullOrWhiteSpace(z));
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(zb)) parts.Add($"Zuchtbuch-Nr.: {zb}");
|
||||||
|
if (defects.Count > 0) parts.Add("Defekte: " + string.Join("; ", defects));
|
||||||
|
return parts.Count > 0 ? string.Join("\n", parts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildProvenance(Rpro3Data data, List<Rpro3Animal> members, Rpro3Dedup.DedupResult dedup)
|
||||||
|
{
|
||||||
|
var rep = Rpro3Dedup.Representative(members);
|
||||||
|
var obj = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["importSource"] = "RennmausPro III",
|
||||||
|
["mergedRecordCount"] = members.Count,
|
||||||
|
["rpro3Ids"] = members.Select(m => m.Rid).Take(8).ToList(),
|
||||||
|
["mother"] = data.ResolveName(rep.MidRaw),
|
||||||
|
["father"] = data.ResolveName(rep.PidRaw),
|
||||||
|
};
|
||||||
|
if (members.Count > 1)
|
||||||
|
obj["note"] = $"{members.Count} RennmausPro-Datensätze zusammengelegt (Name + Geburtsdatum + Farbe + Herkunft).";
|
||||||
|
return JsonSerializer.Serialize(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JsonProvenance(string source, Dictionary<string, object?> extra)
|
||||||
|
{
|
||||||
|
extra["importSource"] = source;
|
||||||
|
return JsonSerializer.Serialize(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||||
|
}
|
||||||
|
}
|
||||||
143
GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs
Normal file
143
GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public enum Rpro3Src { Stamm, Fremd, Wurftier }
|
||||||
|
|
||||||
|
/// <summary>Einheitlicher Tier-Datensatz aus der RPRO3-SQLite (vor Dedup).
|
||||||
|
/// `Rid` ist die RPRO3-ID im jeweiligen Namensraum: "N"=stamm, "uN"=fremd, "XjY"=wurftier.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Anzeigename: Bezeichnung/Cattery, sonst Clan, sonst Vor+Nachname.</summary>
|
||||||
|
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<string> FileNames { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vollständig geparster RPRO3-Backup-Inhalt.</summary>
|
||||||
|
public sealed class Rpro3Data
|
||||||
|
{
|
||||||
|
public List<Rpro3Animal> Animals { get; } = new(); // stamm + fremd
|
||||||
|
public Dictionary<int, Rpro3Litter> Litters { get; } = new();
|
||||||
|
public Dictionary<string, Rpro3Pup> Pups { get; } = new();
|
||||||
|
public Dictionary<string, Rpro3Contact> HerkContacts { get; } = new();
|
||||||
|
public Dictionary<string, Rpro3Contact> AbnContacts { get; } = new();
|
||||||
|
public List<Rpro3Weight> Weights { get; } = new();
|
||||||
|
public List<Rpro3Health> Health { get; } = new();
|
||||||
|
public List<Rpro3Diary> Diary { get; } = new();
|
||||||
|
public Dictionary<string, Rpro3PhotoSet> Photos { get; } = new();
|
||||||
|
|
||||||
|
// Roh-Zählungen (vor Dedup) für den Report.
|
||||||
|
public int ColorStammCount { get; set; }
|
||||||
|
public int ColorExtCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Löst eine RPRO3-Ref ("N"/"uN"/"XjY") in einen Anzeigenamen auf (für Provenance/Report).</summary>
|
||||||
|
public Func<string?, string?> ResolveName { get; set; } = _ => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
477
GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs
Normal file
477
GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte
|
||||||
|
/// `_rpro3.db` direkt und überführt sie in <see cref="Rpro3Data"/>.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und
|
||||||
|
/// parst sie. Wirft <see cref="Rpro3FormatException"/>, wenn keine DB gefunden wurde.
|
||||||
|
/// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad).</summary>
|
||||||
|
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) ----------
|
||||||
|
|
||||||
|
/// <summary>RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JDN (REAL) → DateOnly. 0/None/ungültig = null.</summary>
|
||||||
|
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<SqliteDataReader, string, int> OrdinalLookup(SqliteDataReader r)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, int>(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<int, string>();
|
||||||
|
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<string, (string? mid, string? pid)>();
|
||||||
|
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<string, (string, string)> 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<string, (DateOnly? am, string? warum)>();
|
||||||
|
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<int, string?>();
|
||||||
|
var fremdName = new Dictionary<int, string?>();
|
||||||
|
var pupName = new Dictionary<string, string?>();
|
||||||
|
|
||||||
|
// 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<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, (string, string)>();
|
||||||
|
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<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
using var r = cmd.ExecuteReader();
|
||||||
|
Func<SqliteDataReader, string, int>? 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -508,6 +508,15 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
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: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export interface MockDb {
|
|||||||
namesConfigured: boolean
|
namesConfigured: boolean
|
||||||
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
||||||
feedback: Record<string, unknown>[]
|
feedback: Record<string, unknown>[]
|
||||||
|
// RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs.
|
||||||
|
rpro3: {
|
||||||
|
analyze: Record<string, unknown>
|
||||||
|
execute: Record<string, unknown>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -467,5 +472,54 @@ export function seedDb(): MockDb {
|
|||||||
thread: [],
|
thread: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
gerbil-manager-web/e2e/rennmauspro-import.spec.ts
Normal file
49
gerbil-manager-web/e2e/rennmauspro-import.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ import StammbaumPage from './pages/StammbaumPage'
|
|||||||
import StatistikPage from './pages/StatistikPage'
|
import StatistikPage from './pages/StatistikPage'
|
||||||
import HilfePage from './pages/HilfePage'
|
import HilfePage from './pages/HilfePage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
|
import RennmausProImportPage from './pages/RennmausProImportPage'
|
||||||
import FarbkatalogPage from './pages/FarbkatalogPage'
|
import FarbkatalogPage from './pages/FarbkatalogPage'
|
||||||
import VertraegeListPage from './pages/VertraegeListPage'
|
import VertraegeListPage from './pages/VertraegeListPage'
|
||||||
import VertragWizardPage from './pages/VertragWizardPage'
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
@@ -75,6 +76,8 @@ export default function App() {
|
|||||||
<Route index element={<HilfePage />} />
|
<Route index element={<HilfePage />} />
|
||||||
{/* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet eingereichte Fehlerberichte */}
|
{/* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet eingereichte Fehlerberichte */}
|
||||||
<Route path="tickets" element={<TicketsPage />} />
|
<Route path="tickets" element={<TicketsPage />} />
|
||||||
|
{/* RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") */}
|
||||||
|
<Route path="rennmauspro-import" element={<RennmausProImportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||||
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||||
|
|||||||
103
gerbil-manager-web/src/api/rpro3.ts
Normal file
103
gerbil-manager-web/src/api/rpro3.ts
Normal file
@@ -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<T>(path: string, backup: File, images: File | null): Promise<T> {
|
||||||
|
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<Rpro3AnalyzeResult> {
|
||||||
|
return postUpload<Rpro3AnalyzeResult>('/import/rpro3/analyze', backup, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeRpro3(backup: File, images: File | null): Promise<Rpro3ExecuteResult> {
|
||||||
|
return postUpload<Rpro3ExecuteResult>('/import/rpro3/execute', backup, images)
|
||||||
|
}
|
||||||
@@ -211,6 +211,20 @@ const sections: Section[] = [
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'rpro3-import',
|
||||||
|
title: t.sections.rpro3Import,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p>{t.rpro3Link.text}</p>
|
||||||
|
<p>
|
||||||
|
<Link to="/hilfe/rennmauspro-import" className="hilfe-cta">
|
||||||
|
{t.rpro3Link.button} →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function HilfePage() {
|
export default function HilfePage() {
|
||||||
|
|||||||
255
gerbil-manager-web/src/pages/RennmausProImportPage.tsx
Normal file
255
gerbil-manager-web/src/pages/RennmausProImportPage.tsx
Normal file
@@ -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<File | null>(null)
|
||||||
|
const [images, setImages] = useState<File | null>(null)
|
||||||
|
const [phase, setPhase] = useState<Phase>('select')
|
||||||
|
const [analysis, setAnalysis] = useState<Rpro3AnalyzeResult | null>(null)
|
||||||
|
const [result, setResult] = useState<Rpro3ExecuteResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<section className="page rpro3-page">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<p className="rpro3-subtitle">{t.subtitle}</p>
|
||||||
|
<p className="rpro3-intro">{t.intro}</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rpro3-error" role="alert">
|
||||||
|
<strong>{t.errorTitle}:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schritt 1: Dateien auswählen */}
|
||||||
|
{phase !== 'done' && (
|
||||||
|
<div className="rpro3-card">
|
||||||
|
<h3>{t.step1Title}</h3>
|
||||||
|
<div className="rpro3-field">
|
||||||
|
<label htmlFor="rpro3-backup">{t.backupLabel}</label>
|
||||||
|
<input
|
||||||
|
id="rpro3-backup"
|
||||||
|
type="file"
|
||||||
|
accept=".backup,.zip,.db"
|
||||||
|
disabled={phase === 'analyzing' || phase === 'executing'}
|
||||||
|
onChange={(e) => setBackup(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<small>{t.backupHint}</small>
|
||||||
|
</div>
|
||||||
|
<div className="rpro3-field">
|
||||||
|
<label htmlFor="rpro3-images">{t.imagesLabel}</label>
|
||||||
|
<input
|
||||||
|
id="rpro3-images"
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
disabled={phase === 'analyzing' || phase === 'executing'}
|
||||||
|
onChange={(e) => setImages(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<small>{t.imagesHint}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rpro3-btn rpro3-btn-primary"
|
||||||
|
disabled={!backup || phase === 'analyzing' || phase === 'executing'}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
>
|
||||||
|
{phase === 'analyzing' ? t.analyzing : t.analyzeButton}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schritt 2: Auswertung */}
|
||||||
|
{analysis && phase !== 'done' && (
|
||||||
|
<AnalysisView analysis={analysis} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schritt 3: Import durchführen */}
|
||||||
|
{analysis && (phase === 'analyzed' || phase === 'executing') && (
|
||||||
|
<div className="rpro3-card">
|
||||||
|
<h3>{t.step3Title}</h3>
|
||||||
|
<p className="rpro3-warning">{t.executeWarning}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rpro3-btn rpro3-btn-primary"
|
||||||
|
disabled={phase === 'executing'}
|
||||||
|
onClick={handleExecute}
|
||||||
|
>
|
||||||
|
{phase === 'executing' ? t.executing : t.executeButton}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ergebnis */}
|
||||||
|
{result && phase === 'done' && (
|
||||||
|
<div className="rpro3-card rpro3-success">
|
||||||
|
<h3>{t.successTitle}</h3>
|
||||||
|
<ul className="rpro3-result-list">
|
||||||
|
<li><strong>{result.gerbilsImported}</strong> {t.resultCounts.gerbils}</li>
|
||||||
|
<li><strong>{result.littersImported}</strong> {t.resultCounts.litters}</li>
|
||||||
|
<li><strong>{result.contactsImported}</strong> {t.resultCounts.contacts}</li>
|
||||||
|
<li><strong>{result.healthRecordsImported}</strong> {t.resultCounts.health}</li>
|
||||||
|
<li><strong>{result.weightRecordsImported}</strong> {t.resultCounts.weights}</li>
|
||||||
|
<li><strong>{result.photosImported}</strong> {t.resultCounts.photos}</li>
|
||||||
|
</ul>
|
||||||
|
<div className="rpro3-actions">
|
||||||
|
<Link to="/rennmaeuse" className="rpro3-btn">{de.nav.gerbils}</Link>
|
||||||
|
<button type="button" className="rpro3-btn" onClick={reset}>{t.restart}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisView({ analysis }: { analysis: Rpro3AnalyzeResult }) {
|
||||||
|
const c = analysis.counts
|
||||||
|
return (
|
||||||
|
<div className="rpro3-card">
|
||||||
|
<h3>{t.step2Title}</h3>
|
||||||
|
|
||||||
|
<h4>{t.overviewTitle}</h4>
|
||||||
|
<dl className="rpro3-counts">
|
||||||
|
<Count label={t.counts.ownAnimals} value={c.ownAnimals} />
|
||||||
|
<Count label={t.counts.externalAfterDedup} value={c.externalAfterDedup} />
|
||||||
|
<Count label={t.counts.litters} value={c.litters} />
|
||||||
|
<Count label={t.counts.contacts} value={c.contacts} />
|
||||||
|
<Count label={t.counts.genotypes} value={c.genotypes} />
|
||||||
|
<Count label={t.counts.duplicatesMerged} value={c.duplicatesMerged} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="rpro3-hint">{t.dedupExplain}</p>
|
||||||
|
|
||||||
|
<h4>{t.newVsExistingTitle}</h4>
|
||||||
|
<p>
|
||||||
|
<span className="rpro3-badge rpro3-badge-new">{t.newCount(analysis.newVsCurrentNew)}</span>{' '}
|
||||||
|
<span className="rpro3-badge">{t.existingCount(analysis.newVsCurrentExisting)}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="rpro3-photos-note">
|
||||||
|
{analysis.photosProvided ? t.photosNote(analysis.photoFilesAvailable) : t.photosNoneNote}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{analysis.topMerges.length > 0 && (
|
||||||
|
<details className="rpro3-details">
|
||||||
|
<summary>{t.topMergesTitle}</summary>
|
||||||
|
<div className="rpro3-table-wrap">
|
||||||
|
<table className="rpro3-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t.topMergesCol.name}</th>
|
||||||
|
<th>{t.topMergesCol.count}</th>
|
||||||
|
<th>{t.topMergesCol.dob}</th>
|
||||||
|
<th>{t.topMergesCol.farbe}</th>
|
||||||
|
<th>{t.topMergesCol.origin}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analysis.topMerges.map((m, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{m.name}</td>
|
||||||
|
<td>{m.recordCount}</td>
|
||||||
|
<td>{m.dob || '—'}</td>
|
||||||
|
<td>{m.farbe || '—'}</td>
|
||||||
|
<td>{m.origin || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.ambiguousNames.length > 0 && (
|
||||||
|
<details className="rpro3-details">
|
||||||
|
<summary>{t.ambiguousTitle}</summary>
|
||||||
|
<p className="rpro3-hint">{t.ambiguousIntro}</p>
|
||||||
|
<ul className="rpro3-ambiguous">
|
||||||
|
{analysis.ambiguousNames.map((a, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<strong>{a.name}</strong>
|
||||||
|
<ul>
|
||||||
|
{a.variants.map((v, j) => (
|
||||||
|
<li key={j}>
|
||||||
|
{v.isOwn && <span className="rpro3-star" title={t.ambiguousOwn}>★ </span>}
|
||||||
|
{t.ambiguousVariant(v.count, v.dob, v.farbe, v.origin)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Count({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rpro3-count">
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,3 +122,17 @@
|
|||||||
.hilfe-warning {
|
.hilfe-warning {
|
||||||
color: var(--color-warning-text, #92400e);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
235
gerbil-manager-web/src/pages/rpro3-import.css
vendored
Normal file
235
gerbil-manager-web/src/pages/rpro3-import.css
vendored
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -827,6 +827,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 ──
|
// ── HELP-1: In-App-Anleitung ──
|
||||||
hilfe: {
|
hilfe: {
|
||||||
@@ -845,6 +904,12 @@ export const de = {
|
|||||||
einstellungen: 'Zuchtprofil (Einstellungen)',
|
einstellungen: 'Zuchtprofil (Einstellungen)',
|
||||||
statistik: 'Statistik',
|
statistik: 'Statistik',
|
||||||
datensicherung: 'Datensicherung',
|
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: {
|
api: {
|
||||||
|
|||||||
Reference in New Issue
Block a user