diff --git a/GerbilManager.Tests/Rpro3ImportTests.cs b/GerbilManager.Tests/Rpro3ImportTests.cs index e0c2cb2..c17cfbd 100644 --- a/GerbilManager.Tests/Rpro3ImportTests.cs +++ b/GerbilManager.Tests/Rpro3ImportTests.cs @@ -141,6 +141,97 @@ namespace GerbilManager.Tests finally { try { File.Delete(dbPath); } catch { } } } + // ── Integration: Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung) ── + + [Fact] + public async Task Execute_imports_feature_tables_and_is_idempotent() + { + var dbPath = BuildMiniRpro3(); + try + { + var data = new Rpro3Reader().ReadFromDbFile(dbPath); + // Reader hat alle Feature-Tabellen erfasst. + Assert.Equal(2, data.Becken.Count); + Assert.Single(data.Acquisitions); + Assert.Single(data.Abgeben); + Assert.Single(data.Abstat); + Assert.Single(data.Getback); + Assert.Single(data.Nachfrage); + Assert.Single(data.Ausstellungen); + Assert.Equal(1, data.StammBecken["1"]); // Blacky → Becken 1 + + using var efConn = new SqliteConnection("DataSource=:memory:"); + efConn.Open(); + var opts = new DbContextOptionsBuilder().UseSqlite(efConn).Options; + using var ctx = new ApplicationContext(opts); + ctx.Database.EnsureCreated(); + var service = new Rpro3ImportService(ctx, new ConfigurationBuilder().Build(), null); + + var res = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null); + Assert.Equal(2, res.EnclosuresImported); + Assert.Equal(1, res.AcquisitionsImported); + Assert.Equal(2, res.ReservationsImported); // abgeben(Blacky, reserviert) + abstat(Kuke, abgegeben) + Assert.Equal(1, res.ReturnsImported); + Assert.Equal(1, res.WaitingListImported); + Assert.Equal(1, res.ExhibitionsImported); + + // Becken → Gehege; Blacky ist Becken 1 zugeordnet. + Assert.Equal(2, await ctx.Enclosures.CountAsync()); + var kaefig = await ctx.Enclosures.FirstAsync(e => e.Name == "Käfig A"); + Assert.Equal("80x35x40", kaefig.Size); + Assert.Equal(3, kaefig.Capacity); + Assert.Equal(60, kaefig.CleaningCycleDays); + var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky"); + Assert.Equal(kaefig.Id, blacky.EnclosureId); + + // Erwerb verknüpft auf Blacky mit Preis/Datum. + var acq = await ctx.AcquisitionRecords.SingleAsync(); + Assert.Equal(blacky.Id, acq.GerbilId); + Assert.Equal(9.5m, acq.Price); + Assert.NotNull(acq.Date); + + // Reservierung: Blacky reserviert für Lisa M. + var resv = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == blacky.Id); + Assert.Equal("reserviert", resv.Status); + Assert.Equal("Lisa M.", resv.ContactName); + + // Abgabe-Abschluss: Kuke → abgegeben. + var kuke = await ctx.Gerbils.FirstAsync(g => g.Name == "Kuke"); + var kukeRes = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == kuke.Id); + Assert.Equal("abgegeben", kukeRes.Status); + Assert.Equal(15.0m, kukeRes.Price); + + // Rücknahme: Blacky kam zurück. + var ret = await ctx.ReturnRecords.SingleAsync(); + Assert.Equal(blacky.Id, ret.GerbilId); + Assert.Equal(15.0m, ret.OriginalPrice); + Assert.Equal("Lisa M.", ret.FromContactName); + + // Warteliste: männlicher Schimmel. + var wl = await ctx.WaitingListEntries.SingleAsync(); + Assert.Equal("male", wl.WishGender); + Assert.Equal("Schimmel", wl.WishColor); + Assert.Equal("offen", wl.Status); + + // Ausstellung: Blacky, 1. Platz, BOB. + var exh = await ctx.ExhibitionResults.SingleAsync(); + Assert.Equal(blacky.Id, exh.GerbilId); + Assert.Equal("Schau 2011", exh.EventName); + Assert.Equal("1. Platz", exh.Placement); + Assert.Equal("BOB", exh.Award); + + // Re-Run: idempotent — keine Dubletten in den Feature-Tabellen. + await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null); + Assert.Equal(2, await ctx.Enclosures.CountAsync()); + Assert.Equal(1, await ctx.AcquisitionRecords.CountAsync()); + Assert.Equal(2, await ctx.SaleReservations.CountAsync()); // Blacky + Kuke + Assert.Equal(1, await ctx.ReturnRecords.CountAsync()); + Assert.Equal(1, await ctx.WaitingListEntries.CountAsync()); + Assert.Equal(1, await ctx.ExhibitionResults.CountAsync()); + } + finally { try { File.Delete(dbPath); } catch { } } + } + // ── Helpers ── private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) => @@ -157,7 +248,7 @@ namespace GerbilManager.Tests 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 stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE, _BECKEN)"); 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)"); @@ -170,6 +261,14 @@ namespace GerbilManager.Tests 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)"); + // Feature-Tabellen. + Exec("CREATE TABLE becken_tb (id INTEGER, _BEZ, _SIZE, _MENGE, _CLEANED, _CYCLUS, _NEXT)"); + Exec("CREATE TABLE herktier_tb (id INTEGER, _TID, _DATE, _PRICE, _BEM, _ART)"); + Exec("CREATE TABLE abgeben_tb (tid, _RES, _BEM, _TERMIN, _ABN, _BEZ)"); + Exec("CREATE TABLE abstat_tb (tid, _IDABN, _AM, _PRICE, _ART, _BEM)"); + Exec("CREATE TABLE getback_tb (id INTEGER, _TID, _ZAM, _ZPREIS, _AM, _PREIS, _ABN, _BEM)"); + Exec("CREATE TABLE nachfrage_tb (id INTEGER, _COLOR, _SEX, _DATE, _ABN, _CODE, _VOM, _STATUS, _ART)"); + Exec("CREATE TABLE ausz_tb (id INTEGER, _TID, _VERANSTALTUNG, _DATE, _ORT, _PLATZ, _AUSZ, _EXTRA, _JUROR, _BEM)"); // Kontakte Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')"); @@ -178,19 +277,20 @@ namespace GerbilManager.Tests // 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) + void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb, int becken = 0) { 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.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_BECKEN) VALUES ($id,$n,$s,$b,$h,$z,3,'',$bk)"; 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.Parameters.AddWithValue("$bk", becken); c.ExecuteNonQuery(); } - InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009"); + InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009", becken: 1); InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009"); // Genotyp (Bracket-Notation) für Blacky. @@ -210,6 +310,25 @@ namespace GerbilManager.Tests 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','')"); + // Feature-Tabellen-Daten. + // Abnehmer-Kontakt (für Reservierung/Rücknahme/Warteliste). + Exec("INSERT INTO abn_tb (id,_BEZ,_VNAME,_NNAME) VALUES (5,'Lisa M.','Lisa','Müller')"); + // Becken 1 (Blacky wohnt dort, _BECKEN=1), Becken 2 ohne Bewohner. + Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (1,'Käfig A','80x35x40',3,2455595.0,60,2455655.0)"); + Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (2,'Terrarium','55x48x110',32,2455638.0,90,2455728.0)"); + // Erwerb: Blacky (stamm 1) am JDN für 9.50. + Exec("INSERT INTO herktier_tb (id,_TID,_DATE,_PRICE,_BEM) VALUES (61,'1',2455076.0,9.5,'gekauft')"); + // Reservierung: Blacky (stamm 1) reserviert für abn 5. + Exec("INSERT INTO abgeben_tb (tid,_RES,_BEM,_TERMIN,_ABN,_BEZ) VALUES ('1',1,'',2455730.0,5,0)"); + // Abgabe-Abschluss: Kuke (stamm 2) abgegeben an abn 5 für 15. + Exec("INSERT INTO abstat_tb (tid,_IDABN,_AM,_PRICE,_BEM) VALUES ('2',5,2455716.0,15.0,'Zog aus.')"); + // Rücknahme: Blacky kam zurück von abn 5. + Exec("INSERT INTO getback_tb (id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM) VALUES (2,'1',2455992.0,0.0,2455798.0,15.0,5,'kam zurück')"); + // Warteliste: Wunsch nach männlichem Schimmel von abn 5. + Exec("INSERT INTO nachfrage_tb (id,_COLOR,_SEX,_DATE,_ABN,_CODE,_VOM,_STATUS) VALUES (1,'Schimmel','männlich',0.0,5,'egal',2456172.0,'')"); + // Ausstellung: ein Ergebnis für Blacky (in echten Backups oft leer). + Exec("INSERT INTO ausz_tb (id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM) VALUES (1,'1','Schau 2011',2455700.0,'Halle A','1. Platz','BOB','Frau X','schön')"); + conn.Close(); SqliteConnection.ClearAllPools(); return path; diff --git a/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs b/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs index 2e5c20b..70575a3 100644 --- a/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs +++ b/GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs @@ -9,7 +9,13 @@ namespace GerbilManagerWebAPI.Dtos int Contacts, // Kontakte (Herkunft + Abnehmer) int Genotypes, // hinterlegte Genotypen (eigen + extern) int DuplicatesMerged, // weggefallene interne Dubletten - int MergeClusters); // Anzahl zusammengelegter Cluster + int MergeClusters, // Anzahl zusammengelegter Cluster + int Enclosures, // Gehege/Becken + int Acquisitions, // Erwerbe (herktier_tb) + int Reservations, // Reservierungen/Abgaben (abgeben_tb + abstat_tb) + int Returns, // Rücknahmen (getback_tb) + int WaitingList, // Wartelisten-Einträge (nachfrage_tb) + int Exhibitions); // Ausstellungsergebnisse (ausz_tb) /// Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin). public record Rpro3AmbiguousVariant( @@ -50,5 +56,11 @@ namespace GerbilManagerWebAPI.Dtos int HealthRecordsImported, int WeightRecordsImported, int PhotosImported, + int EnclosuresImported, + int AcquisitionsImported, + int ReservationsImported, + int ReturnsImported, + int WaitingListImported, + int ExhibitionsImported, string Message); } diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs index 9950a91..b8d887e 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs @@ -24,6 +24,14 @@ namespace GerbilManagerWebAPI.Import.Rpro3 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}"); + // Feature-Tabellen — deterministische IDs für Idempotenz (Re-Import ersetzt dieselben Zeilen). + public static Guid Enclosure(int beckenId) => For("enclosure", beckenId.ToString()); + public static Guid Acquisition(int herktierId) => For("acquisition", herktierId.ToString()); + public static Guid Reservation(string tid) => For("reservation", tid); + public static Guid Return(int getbackId) => For("return", getbackId.ToString()); + public static Guid WaitingList(int nachfrageId) => For("waitinglist", nachfrageId.ToString()); + public static Guid Exhibition(int auszId) => For("exhibition", auszId.ToString()); + /// Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte). public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}"; public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}"; diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs index de435b0..61a70f5 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs @@ -63,7 +63,13 @@ namespace GerbilManagerWebAPI.Import.Rpro3 Contacts: data.HerkContacts.Count + data.AbnContacts.Count, Genotypes: data.ColorStammCount + data.ColorExtCount, DuplicatesMerged: dedup.DuplicatesRemoved, - MergeClusters: dedup.MergeClusters.Count); + MergeClusters: dedup.MergeClusters.Count, + Enclosures: plan.Enclosures.Count, + Acquisitions: plan.Acquisitions.Count, + Reservations: plan.Reservations.Count, + Returns: plan.Returns.Count, + WaitingList: plan.WaitingList.Count, + Exhibitions: plan.Exhibitions.Count); var topMerges = dedup.MergeClusters.Values .OrderByDescending(v => v.Count) @@ -145,6 +151,23 @@ namespace GerbilManagerWebAPI.Import.Rpro3 await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync(); await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync(); + // 1b. Feature-Tabellen idempotent leeren. Diese Entities haben KEINE ImportSource-Spalte, + // daher löschen wir gezielt die deterministischen RPRO3-IDs, die dieser Import erzeugt + // (Re-Import desselben Backups → keine Dubletten). Manuell angelegte Einträge mit + // anderen IDs bleiben unangetastet. Enclosures werden NICHT aus Gerbils gelöscht, + // nur die EnclosureId-Verknüpfung gelöschter Tiere fällt mit den Tieren weg. + var acqIds = plan.Acquisitions.Select(a => a.Id).ToList(); + var resIds = plan.Reservations.Select(r => r.Id).ToList(); + var retIds = plan.Returns.Select(r => r.Id).ToList(); + var wlIds = plan.WaitingList.Select(w => w.Id).ToList(); + var exhIds = plan.Exhibitions.Select(e => e.Id).ToList(); + var encIds = plan.Enclosures.Keys.ToList(); + if (acqIds.Count > 0) await _db.AcquisitionRecords.Where(x => acqIds.Contains(x.Id)).ExecuteDeleteAsync(); + if (resIds.Count > 0) await _db.SaleReservations.Where(x => resIds.Contains(x.Id)).ExecuteDeleteAsync(); + if (retIds.Count > 0) await _db.ReturnRecords.Where(x => retIds.Contains(x.Id)).ExecuteDeleteAsync(); + if (wlIds.Count > 0) await _db.WaitingListEntries.Where(x => wlIds.Contains(x.Id)).ExecuteDeleteAsync(); + if (exhIds.Count > 0) await _db.ExhibitionResults.Where(x => exhIds.Contains(x.Id)).ExecuteDeleteAsync(); + // 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände). int contactsImported = 0; var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id); @@ -161,6 +184,21 @@ namespace GerbilManagerWebAPI.Import.Rpro3 } await _db.SaveChangesAsync(); + // 2b. Enclosures upserten (deterministische IDs). MUSS vor den Gerbils laufen, da + // Gerbil.EnclosureId ein echter FK ist. Re-Import aktualisiert dieselben Becken. + var existingEnclosures = await _db.Enclosures + .Where(e => plan.Enclosures.Keys.Contains(e.Id)).ToDictionaryAsync(e => e.Id); + foreach (var en in plan.Enclosures.Values) + { + if (existingEnclosures.TryGetValue(en.Id, out var ex)) + { + ex.Name = en.Name; ex.Size = en.Size; ex.Capacity = en.Capacity; + ex.LastCleanedDate = en.LastCleanedDate; ex.CleaningCycleDays = en.CleaningCycleDays; + } + else { _db.Enclosures.Add(en); } + } + await _db.SaveChangesAsync(); + // 3. Litters (Pass 1: ohne Eltern-FKs). foreach (var l in plan.Litters.Values) _db.Litters.Add(new Litter @@ -183,6 +221,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3 GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated, Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef, Provenance = g.Provenance, LitterId = null, + EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null, }; GerbilStatusService.Apply(gerbil, today); _db.Gerbils.Add(gerbil); @@ -233,12 +272,24 @@ namespace GerbilManagerWebAPI.Import.Rpro3 await _db.SaveChangesAsync(); } + // 8. Feature-Tabellen einfügen (Enclosures schon in 2b erledigt). + foreach (var a in plan.Acquisitions) _db.AcquisitionRecords.Add(a); + foreach (var r in plan.Reservations) _db.SaleReservations.Add(r); + foreach (var r in plan.Returns) _db.ReturnRecords.Add(r); + foreach (var w in plan.WaitingList) _db.WaitingListEntries.Add(w); + foreach (var e in plan.Exhibitions) _db.ExhibitionResults.Add(e); + await _db.SaveChangesAsync(); + return new Rpro3ExecuteResult( plan.Gerbils.Count, plan.Litters.Count, contactsImported, plan.Health.Count, plan.Weights.Count, photosImported, + plan.Enclosures.Count, plan.Acquisitions.Count, plan.Reservations.Count, + plan.Returns.Count, plan.WaitingList.Count, plan.Exhibitions.Count, $"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " + $"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " + - $"{photosImported} Fotos."); + $"{photosImported} Fotos, {plan.Enclosures.Count} Gehege, {plan.Acquisitions.Count} Erwerbe, " + + $"{plan.Reservations.Count} Reservierungen, {plan.Returns.Count} Rücknahmen, " + + $"{plan.WaitingList.Count} Wartelisten-Einträge, {plan.Exhibitions.Count} Ausstellungen."); } // ───────────────────────── PLAN-AUFBAU ───────────────────────── @@ -287,6 +338,15 @@ namespace GerbilManagerWebAPI.Import.Rpro3 public List Health { get; } = new(); public List Weights { get; } = new(); public Dictionary> PhotoFiles { get; } = new(); + + // Feature-Tabellen. + public Dictionary Enclosures { get; } = new(); + public Dictionary GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId + public List Acquisitions { get; } = new(); + public List Reservations { get; } = new(); + public List Returns { get; } = new(); + public List WaitingList { get; } = new(); + public List Exhibitions { get; } = new(); } private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup) @@ -448,9 +508,224 @@ namespace GerbilManagerWebAPI.Import.Rpro3 if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames; } + // 10) Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung). + BuildFeatureTables(data, plan, dedup, pupBySid); + return plan; } + /// + /// Baut die 6 Feature-Entities aus den RPRO3-Feature-Tabellen. Alle Referenzen werden über + /// die bereits aufgebauten Gerbil-/Contact-GUIDs aufgelöst; tids können stamm ("N") oder + /// wurftier ("XjY") sein — Welpen-tids werden über _SID auf das resultierende Tier abgebildet. + /// + private void BuildFeatureTables( + Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary pupBySid) + { + string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid; + + // tid ("N" oder "XjY") → (GerbilId?, erfasster Name?). Welpen ohne kept-_SID liefern null-Id. + (Guid? id, string? name) ResolveGerbil(string? tid) + { + if (string.IsNullOrWhiteSpace(tid)) return (null, null); + string rid = tid; + if (tid.Contains('j')) + { + // Welpe: über _SID auf das behaltene stamm-Tier abbilden, falls vorhanden. + if (data.Pups.TryGetValue(tid, out var pup) && !string.IsNullOrWhiteSpace(pup.Sid) && pup.Sid != "0") + rid = pup.Sid!; + else + { + var pupName = data.Pups.TryGetValue(tid, out var p) ? p.Name : null; + return (null, pupName); + } + } + var gid = Rpro3Guid.Gerbil(RootOf(rid)); + return plan.Gerbils.TryGetValue(gid, out var gp) ? (gid, gp.Name) : (null, data.ResolveName(rid)); + } + + // abn-id → (ContactId?, erfasster Name?). Legt den Kontakt bei Bedarf an (als Abnehmer). + (Guid? id, string? name) ResolveAbn(int? abnId) + { + if (abnId is null) return (null, null); + if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return (null, null); + var id = EnsureContact(plan, c, isBreeder: false, isReceiver: true); + return (id, c.DisplayName); + } + + var now = DateTimeOffset.UtcNow; + static DateTime? AsDt(DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + + // ── Becken → Enclosure (+ stamm._BECKEN → Gerbil-Zuordnung) ── + var beckenById = new Dictionary(); + foreach (var b in data.Becken) + { + var id = Rpro3Guid.Enclosure(b.Id); + beckenById[b.Id] = id; + plan.Enclosures[id] = new Enclosure + { + Id = id, + Name = string.IsNullOrWhiteSpace(b.Name) ? $"Becken {b.Id}" : b.Name!, + Size = string.IsNullOrWhiteSpace(b.Size) ? null : b.Size, + Capacity = b.Capacity is > 0 ? b.Capacity : null, + LastCleanedDate = b.LastCleaned, + CleaningCycleDays = b.CycleDays is > 0 ? b.CycleDays : null, + }; + } + foreach (var (rid, beckenId) in data.StammBecken) + { + if (!beckenById.TryGetValue(beckenId, out var encId)) continue; + var gid = Rpro3Guid.Gerbil(RootOf(rid)); + if (plan.Gerbils.ContainsKey(gid)) plan.GerbilEnclosure[gid] = encId; + } + + // ── herktier_tb → AcquisitionRecord ── + foreach (var a in data.Acquisitions) + { + var (gid, _) = ResolveGerbil(a.Tid); + if (gid is null) continue; // kein Tier → kein Erwerb + // Herkunfts-Kontakt steht bereits auf Gerbil.OriginContactId; hier Datum/Preis/Notiz. + Guid? sourceContactId = plan.Gerbils.TryGetValue(gid.Value, out var gp) ? gp.OriginContactId : null; + plan.Acquisitions.Add(new AcquisitionRecord + { + Id = Rpro3Guid.Acquisition(a.Id), + GerbilId = gid, + SourceContactId = sourceContactId, + Date = a.Date, + Price = a.Price is > 0 ? a.Price : null, + Note = NullIfEmpty(a.Note), + CreatedAt = now, + }); + } + + // ── abgeben_tb + abstat_tb → SaleReservation (eine Zeile je Tier) ── + // abgeben = Vormerkung/Reservierung (oft Welpen-tid), abstat = Abgabe-Abschluss (stamm-tid). + // Beide werden über das Tier zusammengeführt; ist beides vorhanden, gewinnt „abgegeben". + var reservationByGerbil = new Dictionary(); + SaleReservation Res(Guid gid, string? name, string tidKey) + { + if (reservationByGerbil.TryGetValue(gid, out var ex)) return ex; + var res = new SaleReservation + { + Id = Rpro3Guid.Reservation(tidKey), + GerbilId = gid, + GerbilName = name, + Status = "verfuegbar", + CreatedAt = now, + UpdatedAt = now, + }; + reservationByGerbil[gid] = res; + plan.Reservations.Add(res); + return res; + } + foreach (var ab in data.Abgeben) + { + var (gid, name) = ResolveGerbil(ab.Tid); + if (gid is null) continue; + var res = Res(gid.Value, name, ab.Tid); + var (cid, cname) = ResolveAbn(ab.AbnId); + if (res.Status != "abgegeben") res.Status = ab.Reserved ? "reserviert" : res.Status; + res.ReservedForContactId ??= cid; + res.ContactName ??= cname; + res.AppointmentDate ??= AsDt(ab.Appointment); + res.Note ??= NullIfEmpty(ab.Note); + } + foreach (var st in data.Abstat) + { + var (gid, name) = ResolveGerbil(st.Tid); + if (gid is null) continue; + var res = Res(gid.Value, name, st.Tid); + var (cid, cname) = ResolveAbn(st.AbnId); + res.Status = "abgegeben"; + res.ReservedForContactId ??= cid; + res.ContactName ??= cname; + res.HandedOverDate ??= AsDt(st.HandedOver); + if (st.Price is > 0) res.Price ??= st.Price; + res.Note ??= NullIfEmpty(st.Note); + res.UpdatedAt = now; + } + + // ── getback_tb → ReturnRecord ── + foreach (var gb in data.Getback) + { + var (gid, name) = ResolveGerbil(gb.Tid); + var (cid, cname) = ResolveAbn(gb.AbnId); + plan.Returns.Add(new ReturnRecord + { + Id = Rpro3Guid.Return(gb.Id), + GerbilId = gid, + GerbilName = name, + ReturnDate = AsDt(gb.ReturnDate), + ReturnPrice = gb.ReturnPrice is > 0 ? gb.ReturnPrice : null, + OriginalPrice = gb.OriginalPrice is > 0 ? gb.OriginalPrice : null, + OriginalSaleDate = AsDt(gb.OriginalSaleDate), + FromContactId = cid, + FromContactName = cname, + Note = NullIfEmpty(gb.Note), + CreatedAt = now, + }); + } + + // ── nachfrage_tb → WaitingListEntry ── + foreach (var nf in data.Nachfrage) + { + var (cid, cname) = ResolveAbn(nf.AbnId); + plan.WaitingList.Add(new WaitingListEntry + { + Id = Rpro3Guid.WaitingList(nf.Id), + ContactId = cid, + ContactName = cname, + WishColor = NullIfEmpty(nf.WishColor), + WishGender = nf.WishGender switch + { + Gender.male => "male", + Gender.female => "female", + _ => null, + }, + RequestedAt = AsDt(nf.RequestedAt), + Status = MapWaitlistStatus(nf.Status), + Note = NullIfEmpty(nf.Note), + CreatedAt = now, + }); + } + + // ── ausz_tb → ExhibitionResult (in vielen Backups leer; trotzdem voll verdrahtet) ── + foreach (var ex in data.Ausstellungen) + { + var (gid, name) = ResolveGerbil(ex.Tid); + var noteParts = new[] { ex.Place, ex.Juror is { Length: > 0 } ? $"Juror: {ex.Juror}" : null, ex.Note } + .Where(s => !string.IsNullOrWhiteSpace(s)); + var note = string.Join(" · ", noteParts); + plan.Exhibitions.Add(new ExhibitionResult + { + Id = Rpro3Guid.Exhibition(ex.Id), + GerbilId = gid, + EntityName = name, + EventName = string.IsNullOrWhiteSpace(ex.EventName) ? "Ausstellung" : ex.EventName!, + Date = AsDt(ex.Date), + Placement = NullIfEmpty(ex.Placement), + Award = NullIfEmpty(ex.Award), + Note = string.IsNullOrWhiteSpace(note) ? null : note, + CreatedAt = now, + }); + } + } + + private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + /// RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert). + private static string MapWaitlistStatus(string? raw) + { + var s = (raw ?? "").Trim().ToLowerInvariant(); + return s switch + { + "" or "offen" or "0" => "offen", + "erfuellt" or "erfüllt" or "1" => "erfuellt", + "storniert" or "2" => "storniert", + _ => "offen", + }; + } + private Guid? ResolveParentGuid(string? rid, ImportPlan plan) { if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null; diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs index a537c6b..1339f8d 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs @@ -120,6 +120,96 @@ namespace GerbilManagerWebAPI.Import.Rpro3 public List FileNames { get; } = new(); } + // ── Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz) ── + + /// becken_tb: Gehege/Becken (id, _BEZ Name, _SIZE Maße, _MENGE Kapazität, + /// _CLEANED letzte Reinigung JDN, _CYCLUS Reinigungsintervall Tage). + public sealed class Rpro3Becken + { + public required int Id { get; init; } + public string? Name { get; set; } + public string? Size { get; set; } + public int? Capacity { get; set; } + public DateOnly? LastCleaned { get; set; } + public int? CycleDays { get; set; } + } + + /// herktier_tb: Erwerb eines Tieres (_TID stamm-id, _DATE Kaufdatum JDN, + /// _PRICE Kaufpreis, _BEM Notiz). + public sealed class Rpro3Acquisition + { + public required int Id { get; init; } + public string? Tid { get; set; } // stamm-id + public DateOnly? Date { get; set; } + public decimal? Price { get; set; } + public string? Note { get; set; } + } + + /// abgeben_tb: Reservierung/Vormerkung (tid stamm- oder wurftier-id, _RES reserviert?, + /// _TERMIN Abgabetermin JDN, _ABN Abnehmer-Kontakt, _BEM Notiz). + public sealed class Rpro3Abgeben + { + public required string Tid { get; init; } // stamm-id ("N") oder wurftier-id ("XjY") + public bool Reserved { get; set; } + public DateOnly? Appointment { get; set; } + public int? AbnId { get; set; } + public string? Note { get; set; } + } + + /// abstat_tb: Abgabe-Abschluss (tid stamm-id, _IDABN Abnehmer-Kontakt, + /// _AM Abgabedatum JDN, _PRICE Abgabepreis, _BEM Notiz). + public sealed class Rpro3Abstat + { + public required string Tid { get; init; } // stamm-id + public int? AbnId { get; set; } + public DateOnly? HandedOver { get; set; } + public decimal? Price { get; set; } + public string? Note { get; set; } + } + + /// getback_tb: Rücknahme (_TID stamm-/wurftier-id, _ZAM Rücknahmedatum JDN, + /// _ZPREIS Rücknahmepreis, _AM ursprüngliches Abgabedatum JDN, _PREIS ursprünglicher Preis, + /// _ABN Kontakt, von dem zurückkam, _BEM Notiz). + public sealed class Rpro3Getback + { + public required int Id { get; init; } + public string? Tid { get; set; } // stamm- oder wurftier-id + public DateOnly? ReturnDate { get; set; } + public decimal? ReturnPrice { get; set; } + public DateOnly? OriginalSaleDate { get; set; } + public decimal? OriginalPrice { get; set; } + public int? AbnId { get; set; } + public string? Note { get; set; } + } + + /// nachfrage_tb: Warteliste/Nachfrage (_COLOR Wunschfarbe, _SEX Wunschgeschlecht, + /// _ABN Interessenten-Kontakt, _VOM Anfragedatum JDN, _STATUS Status, _BEM/_CODE Notiz). + public sealed class Rpro3Nachfrage + { + public required int Id { get; init; } + public string? WishColor { get; set; } + public Gender WishGender { get; set; } + public int? AbnId { get; set; } + public DateOnly? RequestedAt { get; set; } + public string? Status { get; set; } + public string? Note { get; set; } + } + + /// ausz_tb: Ausstellungsergebnis (_TID stamm-/wurftier-id, _VERANSTALTUNG Veranstaltung, + /// _DATE Datum JDN, _ORT Ort, _PLATZ Platzierung, _AUSZ Auszeichnung, _JUROR/_EXTRA/_BEM Notiz). + public sealed class Rpro3Ausstellung + { + public required int Id { get; init; } + public string? Tid { get; set; } + public string? EventName { get; set; } + public DateOnly? Date { get; set; } + public string? Place { get; set; } // _ORT + public string? Placement { get; set; } // _PLATZ + public string? Award { get; set; } // _AUSZ + public string? Juror { get; set; } + public string? Note { get; set; } + } + /// Vollständig geparster RPRO3-Backup-Inhalt. public sealed class Rpro3Data { @@ -133,6 +223,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3 public List Diary { get; } = new(); public Dictionary Photos { get; } = new(); + // Feature-Tabellen. + public List Becken { get; } = new(); + public List Acquisitions { get; } = new(); + public List Abgeben { get; } = new(); + public List Abstat { get; } = new(); + public List Getback { get; } = new(); + public List Nachfrage { get; } = new(); + public List Ausstellungen { get; } = new(); + + /// stamm-id (string) → becken_tb.id (Gehege-Zuordnung aus stamm_tb._BECKEN). + /// Nur positive, gültige Becken-IDs. + public Dictionary StammBecken { get; } = new(); + // Roh-Zählungen (vor Dedup) für den Report. public int ColorStammCount { get; set; } public int ColorExtCount { get; set; } diff --git a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs index f84fb53..38332e6 100644 --- a/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs +++ b/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs @@ -213,7 +213,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3 // stamm_tb (eigene Tiere) Query(conn, - "SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE FROM stamm_tb", + "SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE,_BECKEN FROM stamm_tb", (r, ord) => { var sid = IntOrNull(r, ord(r, "id")); @@ -221,6 +221,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3 var rid = sid.Value.ToString(); var name = Str(r, ord(r, "_NAME")); stammName[sid.Value] = name; + var beckenId = IntOrNull(r, ord(r, "_BECKEN")); + if (beckenId is > 0) data.StammBecken[rid] = beckenId.Value; var (farbe, fcode) = ColorFor(rid); baum.TryGetValue(rid, out var par); var herkId = IntOrNull(r, ord(r, "_HERKUNFT")); @@ -406,9 +408,133 @@ namespace GerbilManagerWebAPI.Import.Rpro3 if (set.FileNames.Count > 0) data.Photos[set.Tid] = set; }); + ParseFeatureTables(conn, data); + return data; } + /// Parst die Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz). + /// Jede Tabelle ist optional — fehlt sie in einem älteren Backup, wird sie still übersprungen. + private void ParseFeatureTables(SqliteConnection conn, Rpro3Data data) + { + // becken_tb: Gehege. + QueryOptional(conn, "SELECT id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS FROM becken_tb", (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + data.Becken.Add(new Rpro3Becken + { + Id = id.Value, + Name = Str(r, ord(r, "_BEZ")), + Size = Str(r, ord(r, "_SIZE")), + Capacity = IntOrNull(r, ord(r, "_MENGE")), + LastCleaned = JdnToDate(Real(r, ord(r, "_CLEANED"))), + CycleDays = IntOrNull(r, ord(r, "_CYCLUS")), + }); + }); + + // herktier_tb: Erwerb. + QueryOptional(conn, "SELECT id,_TID,_DATE,_PRICE,_BEM FROM herktier_tb", (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + data.Acquisitions.Add(new Rpro3Acquisition + { + Id = id.Value, + Tid = Str(r, ord(r, "_TID")), + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Price = DecimalOrNull(r, ord(r, "_PRICE")), + Note = Str(r, ord(r, "_BEM")), + }); + }); + + // abgeben_tb: Reservierung/Vormerkung. + QueryOptional(conn, "SELECT tid,_RES,_TERMIN,_ABN,_BEM FROM abgeben_tb", (r, ord) => + { + var tid = Str(r, ord(r, "tid")); + if (string.IsNullOrWhiteSpace(tid)) return; + data.Abgeben.Add(new Rpro3Abgeben + { + Tid = tid, + Reserved = (IntOrNull(r, ord(r, "_RES")) ?? 0) != 0, + Appointment = JdnToDate(Real(r, ord(r, "_TERMIN"))), + AbnId = IntOrNull(r, ord(r, "_ABN")), + Note = Str(r, ord(r, "_BEM")), + }); + }); + + // abstat_tb: Abgabe-Abschluss. + QueryOptional(conn, "SELECT tid,_IDABN,_AM,_PRICE,_BEM FROM abstat_tb", (r, ord) => + { + var tid = Str(r, ord(r, "tid")); + if (string.IsNullOrWhiteSpace(tid)) return; + data.Abstat.Add(new Rpro3Abstat + { + Tid = tid, + AbnId = IntOrNull(r, ord(r, "_IDABN")), + HandedOver = JdnToDate(Real(r, ord(r, "_AM"))), + Price = DecimalOrNull(r, ord(r, "_PRICE")), + Note = Str(r, ord(r, "_BEM")), + }); + }); + + // getback_tb: Rücknahme. + QueryOptional(conn, "SELECT id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM FROM getback_tb", (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + data.Getback.Add(new Rpro3Getback + { + Id = id.Value, + Tid = Str(r, ord(r, "_TID")), + ReturnDate = JdnToDate(Real(r, ord(r, "_ZAM"))), + ReturnPrice = DecimalOrNull(r, ord(r, "_ZPREIS")), + OriginalSaleDate = JdnToDate(Real(r, ord(r, "_AM"))), + OriginalPrice = DecimalOrNull(r, ord(r, "_PREIS")), + AbnId = IntOrNull(r, ord(r, "_ABN")), + Note = Str(r, ord(r, "_BEM")), + }); + }); + + // nachfrage_tb: Warteliste. _VOM ist das Anfragedatum (_DATE ist hier i. d. R. 0). + // Hat keine eigene _BEM-Spalte; _CODE (z. B. "egal") dient als Zusatznotiz. + QueryOptional(conn, "SELECT id,_COLOR,_SEX,_ABN,_VOM,_DATE,_STATUS,_CODE FROM nachfrage_tb", (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + var code = Str(r, ord(r, "_CODE")); + data.Nachfrage.Add(new Rpro3Nachfrage + { + Id = id.Value, + WishColor = Str(r, ord(r, "_COLOR")), + WishGender = ParseGender(Str(r, ord(r, "_SEX"))), + AbnId = IntOrNull(r, ord(r, "_ABN")), + RequestedAt = JdnToDate(Real(r, ord(r, "_VOM"))) ?? JdnToDate(Real(r, ord(r, "_DATE"))), + Status = Str(r, ord(r, "_STATUS")), + Note = string.IsNullOrWhiteSpace(code) || code == "egal" ? null : code, + }); + }); + + // ausz_tb: Ausstellungsergebnisse (in vielen Backups leer — robust gegen 0 Zeilen). + QueryOptional(conn, "SELECT id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM FROM ausz_tb", (r, ord) => + { + var id = IntOrNull(r, ord(r, "id")); + if (id is null) return; + data.Ausstellungen.Add(new Rpro3Ausstellung + { + Id = id.Value, + Tid = Str(r, ord(r, "_TID")), + EventName = Str(r, ord(r, "_VERANSTALTUNG")), + Date = JdnToDate(Real(r, ord(r, "_DATE"))), + Place = Str(r, ord(r, "_ORT")), + Placement = Str(r, ord(r, "_PLATZ")), + Award = Str(r, ord(r, "_AUSZ")), + Juror = Str(r, ord(r, "_JUROR")), + Note = Str(r, ord(r, "_BEM")), + }); + }); + } + private static Dictionary ReadColors(SqliteConnection conn, string table) { var dict = new Dictionary(); @@ -468,6 +594,16 @@ namespace GerbilManagerWebAPI.Import.Rpro3 onRow(r, ord); } } + + /// Wie , ignoriert aber fehlende Tabellen/Spalten + /// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt. + private static void QueryOptional( + SqliteConnection conn, string sql, + Action> onRow) + { + try { Query(conn, sql, onRow); } + catch (SqliteException) { /* Tabelle/Spalte fehlt in diesem Backup → überspringen */ } + } } public sealed class Rpro3FormatException : Exception