feat(import): RPRO3-Importer um 6 Feature-Tabellen erweitern
becken→Gehege-Reinigung, herktier→Erwerb, abgeben/abstat→Reservierung, getback→Rücknahmen, nachfrage→Warteliste, ausz→Ausstellungen. Deterministische GUIDs, idempotenter Re-Import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,97 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { File.Delete(dbPath); } catch { } }
|
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<ApplicationContext>().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 ──
|
// ── Helpers ──
|
||||||
|
|
||||||
private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) =>
|
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 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 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 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 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 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 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 krank_tb (_TID, _DATE, _BEM, _MED)");
|
||||||
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
|
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
|
||||||
Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)");
|
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
|
// 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 (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.
|
// 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 maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
|
||||||
var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich");
|
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();
|
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("$id", id);
|
||||||
c.Parameters.AddWithValue("$n", name);
|
c.Parameters.AddWithValue("$n", name);
|
||||||
c.Parameters.AddWithValue("$s", sex);
|
c.Parameters.AddWithValue("$s", sex);
|
||||||
c.Parameters.AddWithValue("$b", birth);
|
c.Parameters.AddWithValue("$b", birth);
|
||||||
c.Parameters.AddWithValue("$h", herk);
|
c.Parameters.AddWithValue("$h", herk);
|
||||||
c.Parameters.AddWithValue("$z", zb);
|
c.Parameters.AddWithValue("$z", zb);
|
||||||
|
c.Parameters.AddWithValue("$bk", becken);
|
||||||
c.ExecuteNonQuery();
|
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");
|
InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009");
|
||||||
|
|
||||||
// Genotyp (Bracket-Notation) für Blacky.
|
// 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 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','')");
|
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();
|
conn.Close();
|
||||||
SqliteConnection.ClearAllPools();
|
SqliteConnection.ClearAllPools();
|
||||||
return path;
|
return path;
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
int Contacts, // Kontakte (Herkunft + Abnehmer)
|
int Contacts, // Kontakte (Herkunft + Abnehmer)
|
||||||
int Genotypes, // hinterlegte Genotypen (eigen + extern)
|
int Genotypes, // hinterlegte Genotypen (eigen + extern)
|
||||||
int DuplicatesMerged, // weggefallene interne Dubletten
|
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)
|
||||||
|
|
||||||
/// <summary>Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin).</summary>
|
/// <summary>Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin).</summary>
|
||||||
public record Rpro3AmbiguousVariant(
|
public record Rpro3AmbiguousVariant(
|
||||||
@@ -50,5 +56,11 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
int HealthRecordsImported,
|
int HealthRecordsImported,
|
||||||
int WeightRecordsImported,
|
int WeightRecordsImported,
|
||||||
int PhotosImported,
|
int PhotosImported,
|
||||||
|
int EnclosuresImported,
|
||||||
|
int AcquisitionsImported,
|
||||||
|
int ReservationsImported,
|
||||||
|
int ReturnsImported,
|
||||||
|
int WaitingListImported,
|
||||||
|
int ExhibitionsImported,
|
||||||
string Message);
|
string Message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Health(string tid, string disc, int seq) => For("health", $"{tid}:{seq}:{disc}");
|
||||||
public static Guid Weight(string tid, int seq) => For("weight", $"{tid}:{seq}");
|
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());
|
||||||
|
|
||||||
/// <summary>Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte).</summary>
|
/// <summary>Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte).</summary>
|
||||||
public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}";
|
public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}";
|
||||||
public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}";
|
public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}";
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
|
Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
|
||||||
Genotypes: data.ColorStammCount + data.ColorExtCount,
|
Genotypes: data.ColorStammCount + data.ColorExtCount,
|
||||||
DuplicatesMerged: dedup.DuplicatesRemoved,
|
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
|
var topMerges = dedup.MergeClusters.Values
|
||||||
.OrderByDescending(v => v.Count)
|
.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.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync();
|
||||||
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).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).
|
// 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände).
|
||||||
int contactsImported = 0;
|
int contactsImported = 0;
|
||||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||||
@@ -161,6 +184,21 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
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).
|
// 3. Litters (Pass 1: ohne Eltern-FKs).
|
||||||
foreach (var l in plan.Litters.Values)
|
foreach (var l in plan.Litters.Values)
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
@@ -183,6 +221,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
|
GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
|
||||||
Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
|
Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
|
||||||
Provenance = g.Provenance, LitterId = null,
|
Provenance = g.Provenance, LitterId = null,
|
||||||
|
EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null,
|
||||||
};
|
};
|
||||||
GerbilStatusService.Apply(gerbil, today);
|
GerbilStatusService.Apply(gerbil, today);
|
||||||
_db.Gerbils.Add(gerbil);
|
_db.Gerbils.Add(gerbil);
|
||||||
@@ -233,12 +272,24 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
await _db.SaveChangesAsync();
|
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(
|
return new Rpro3ExecuteResult(
|
||||||
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
|
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
|
||||||
plan.Health.Count, plan.Weights.Count, photosImported,
|
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, " +
|
$"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " +
|
||||||
$"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " +
|
$"{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 ─────────────────────────
|
// ───────────────────────── PLAN-AUFBAU ─────────────────────────
|
||||||
@@ -287,6 +338,15 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
public List<HealthRecord> Health { get; } = new();
|
public List<HealthRecord> Health { get; } = new();
|
||||||
public List<WeightRecord> Weights { get; } = new();
|
public List<WeightRecord> Weights { get; } = new();
|
||||||
public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();
|
public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();
|
||||||
|
|
||||||
|
// Feature-Tabellen.
|
||||||
|
public Dictionary<Guid, Enclosure> Enclosures { get; } = new();
|
||||||
|
public Dictionary<Guid, Guid> GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId
|
||||||
|
public List<AcquisitionRecord> Acquisitions { get; } = new();
|
||||||
|
public List<SaleReservation> Reservations { get; } = new();
|
||||||
|
public List<ReturnRecord> Returns { get; } = new();
|
||||||
|
public List<WaitingListEntry> WaitingList { get; } = new();
|
||||||
|
public List<ExhibitionResult> Exhibitions { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup)
|
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;
|
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;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void BuildFeatureTables(
|
||||||
|
Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary<string, Rpro3Pup> 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<int, Guid>();
|
||||||
|
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<Guid, SaleReservation>();
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert).</summary>
|
||||||
|
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)
|
private Guid? ResolveParentGuid(string? rid, ImportPlan plan)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
|
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
|
||||||
|
|||||||
@@ -120,6 +120,96 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
public List<string> FileNames { get; } = new();
|
public List<string> FileNames { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz) ──
|
||||||
|
|
||||||
|
/// <summary>becken_tb: Gehege/Becken (id, _BEZ Name, _SIZE Maße, _MENGE Kapazität,
|
||||||
|
/// _CLEANED letzte Reinigung JDN, _CYCLUS Reinigungsintervall Tage).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>herktier_tb: Erwerb eines Tieres (_TID stamm-id, _DATE Kaufdatum JDN,
|
||||||
|
/// _PRICE Kaufpreis, _BEM Notiz).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>abgeben_tb: Reservierung/Vormerkung (tid stamm- oder wurftier-id, _RES reserviert?,
|
||||||
|
/// _TERMIN Abgabetermin JDN, _ABN Abnehmer-Kontakt, _BEM Notiz).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>abstat_tb: Abgabe-Abschluss (tid stamm-id, _IDABN Abnehmer-Kontakt,
|
||||||
|
/// _AM Abgabedatum JDN, _PRICE Abgabepreis, _BEM Notiz).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>nachfrage_tb: Warteliste/Nachfrage (_COLOR Wunschfarbe, _SEX Wunschgeschlecht,
|
||||||
|
/// _ABN Interessenten-Kontakt, _VOM Anfragedatum JDN, _STATUS Status, _BEM/_CODE Notiz).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ausz_tb: Ausstellungsergebnis (_TID stamm-/wurftier-id, _VERANSTALTUNG Veranstaltung,
|
||||||
|
/// _DATE Datum JDN, _ORT Ort, _PLATZ Platzierung, _AUSZ Auszeichnung, _JUROR/_EXTRA/_BEM Notiz).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Vollständig geparster RPRO3-Backup-Inhalt.</summary>
|
/// <summary>Vollständig geparster RPRO3-Backup-Inhalt.</summary>
|
||||||
public sealed class Rpro3Data
|
public sealed class Rpro3Data
|
||||||
{
|
{
|
||||||
@@ -133,6 +223,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
public List<Rpro3Diary> Diary { get; } = new();
|
public List<Rpro3Diary> Diary { get; } = new();
|
||||||
public Dictionary<string, Rpro3PhotoSet> Photos { get; } = new();
|
public Dictionary<string, Rpro3PhotoSet> Photos { get; } = new();
|
||||||
|
|
||||||
|
// Feature-Tabellen.
|
||||||
|
public List<Rpro3Becken> Becken { get; } = new();
|
||||||
|
public List<Rpro3Acquisition> Acquisitions { get; } = new();
|
||||||
|
public List<Rpro3Abgeben> Abgeben { get; } = new();
|
||||||
|
public List<Rpro3Abstat> Abstat { get; } = new();
|
||||||
|
public List<Rpro3Getback> Getback { get; } = new();
|
||||||
|
public List<Rpro3Nachfrage> Nachfrage { get; } = new();
|
||||||
|
public List<Rpro3Ausstellung> Ausstellungen { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>stamm-id (string) → becken_tb.id (Gehege-Zuordnung aus stamm_tb._BECKEN).
|
||||||
|
/// Nur positive, gültige Becken-IDs.</summary>
|
||||||
|
public Dictionary<string, int> StammBecken { get; } = new();
|
||||||
|
|
||||||
// Roh-Zählungen (vor Dedup) für den Report.
|
// Roh-Zählungen (vor Dedup) für den Report.
|
||||||
public int ColorStammCount { get; set; }
|
public int ColorStammCount { get; set; }
|
||||||
public int ColorExtCount { get; set; }
|
public int ColorExtCount { get; set; }
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
|
|
||||||
// stamm_tb (eigene Tiere)
|
// stamm_tb (eigene Tiere)
|
||||||
Query(conn,
|
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) =>
|
(r, ord) =>
|
||||||
{
|
{
|
||||||
var sid = IntOrNull(r, ord(r, "id"));
|
var sid = IntOrNull(r, ord(r, "id"));
|
||||||
@@ -221,6 +221,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
var rid = sid.Value.ToString();
|
var rid = sid.Value.ToString();
|
||||||
var name = Str(r, ord(r, "_NAME"));
|
var name = Str(r, ord(r, "_NAME"));
|
||||||
stammName[sid.Value] = 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);
|
var (farbe, fcode) = ColorFor(rid);
|
||||||
baum.TryGetValue(rid, out var par);
|
baum.TryGetValue(rid, out var par);
|
||||||
var herkId = IntOrNull(r, ord(r, "_HERKUNFT"));
|
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;
|
if (set.FileNames.Count > 0) data.Photos[set.Tid] = set;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ParseFeatureTables(conn, data);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
|
private static Dictionary<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
|
||||||
{
|
{
|
||||||
var dict = new Dictionary<string, (string, string)>();
|
var dict = new Dictionary<string, (string, string)>();
|
||||||
@@ -468,6 +594,16 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
onRow(r, ord);
|
onRow(r, ord);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wie <see cref="Query"/>, ignoriert aber fehlende Tabellen/Spalten
|
||||||
|
/// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt.</summary>
|
||||||
|
private static void QueryOptional(
|
||||||
|
SqliteConnection conn, string sql,
|
||||||
|
Action<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
|
||||||
|
{
|
||||||
|
try { Query(conn, sql, onRow); }
|
||||||
|
catch (SqliteException) { /* Tabelle/Spalte fehlt in diesem Backup → überspringen */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Rpro3FormatException : Exception
|
public sealed class Rpro3FormatException : Exception
|
||||||
|
|||||||
Reference in New Issue
Block a user