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:
2026-06-23 07:05:01 +02:00
parent 00dc724605
commit c7b054e909
6 changed files with 661 additions and 8 deletions

View File

@@ -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<HealthRecord> Health { get; } = new();
public List<WeightRecord> Weights { 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)
@@ -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;
}
/// <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)
{
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;