diff --git a/GerbilManager.Tests/ImportDocxServiceTests.cs b/GerbilManager.Tests/ImportDocxServiceTests.cs new file mode 100644 index 0000000..2959b43 --- /dev/null +++ b/GerbilManager.Tests/ImportDocxServiceTests.cs @@ -0,0 +1,230 @@ +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManager.Tests +{ + /// + /// FEAT-8d-CREATE: docx importer CREATE path tests. + /// Uses SQLite (not InMemory) so FK constraints are enforced. + /// + public class ImportDocxServiceTests : IDisposable + { + private readonly string _dir; + + public ImportDocxServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + // One open connection keeps the :memory: SQLite DB alive across calls. + private (ApplicationContext db, SqliteConnection conn) NewSqliteDb() + { + var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; + var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + return (db, conn); + } + + private void WriteLitters(object litters) => + File.WriteAllText(Path.Combine(_dir, "docx_litters.json"), + System.Text.Json.JsonSerializer.Serialize(litters)); + + private void WriteAnimals(object animals) => + File.WriteAllText(Path.Combine(_dir, "docx_animals.json"), + System.Text.Json.JsonSerializer.Serialize(animals)); + + // ── Test 1: dry-run shows correct counts without writing ───────────────── + + [Fact] + public async Task DryRun_counts_new_animal_without_writing() + { + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + // Litter in DB with PairingCode matching the docx WS-code + var litter = new Litter + { + Id = Guid.NewGuid(), + Name = "Testwurf WS1", + Date = new DateOnly(2023, 5, 1), + PairingCode = "WS1" + }; + db.Litters.Add(litter); + await db.SaveChangesAsync(); + + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female", + owner = "Max Mustermann", abgabeDate = "01.07.2023", + deathDate = "", deathCause = "", farbschlag = "" } + }); + + var report = await new ImportDocxService(db, _dir).RunAsync(execute: false); + + Assert.False(report.Executed); + Assert.Equal(1, report.Created); + Assert.Equal(1, report.LitterLinked); + Assert.Equal(1, report.GoHomeFilled); // new animal will have GoHomeDate set + Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written + Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written + } + } + + // ── Test 2: execute creates animal with all fields + FK constraints ─────── + + [Fact] + public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate() + { + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + var litter = new Litter + { + Id = Guid.NewGuid(), + Name = "Testwurf WS1", + Date = new DateOnly(2023, 5, 1), + PairingCode = "WS1" + }; + db.Litters.Add(litter); + await db.SaveChangesAsync(); + + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female", + owner = "Max Mustermann", abgabeDate = "01.07.2023", + deathDate = "", deathCause = "", farbschlag = "" } + }); + + var report = await new ImportDocxService(db, _dir).RunAsync(execute: true); + + Assert.True(report.Executed); + Assert.Equal(1, report.Created); + Assert.Equal(1, report.ContactsCreated); + + var gerbil = await db.Gerbils.SingleAsync(); + Assert.Equal("Pepper", gerbil.Name); + Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth); + Assert.Equal(Gender.female, gerbil.Gender); + Assert.Equal(GerbilStatus.GivenAway, gerbil.Status); + Assert.Equal(litter.Id, gerbil.LitterId); + Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate); + Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder); + Assert.False(gerbil.IsResident); + Assert.Equal("docx", gerbil.ImportSource); + Assert.StartsWith("docx-", gerbil.ExternalRef); + + var contact = await db.Contacts.SingleAsync(); + Assert.Equal("Max Mustermann", contact.Name); + Assert.Equal(contact.Id, gerbil.ReceiverContactId); + } + } + + // ── Test 3: idempotency — second run creates zero ──────────────────────── + + [Fact] + public async Task Execute_is_idempotent_second_run_creates_zero() + { + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + new { wsCode = "WS2", litterDob = "15.03.2023", name = "Flash", gender = "male", + owner = "", abgabeDate = "01.05.2023", + deathDate = "", deathCause = "", farbschlag = "" } + }); + + var first = await new ImportDocxService(db, _dir).RunAsync(execute: true); + Assert.Equal(1, first.Created); + + var second = await new ImportDocxService(db, _dir).RunAsync(execute: true); + Assert.Equal(0, second.Created); // ExternalRef path, no dupe + Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row + } + } + + // ── Test 4: existing main-import animal → enrich only, no duplicate ────── + + [Fact] + public async Task Execute_enriches_existing_animal_does_not_duplicate() + { + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + // Animal already in DB (from main import, ExternalRef ≠ "docx-…") + var existing = new Gerbil + { + Id = Guid.NewGuid(), + Name = "Eddie", + DateOfBirth = new DateOnly(2022, 8, 10), + Gender = Gender.male, + ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref + IsResident = false, + }; + db.Gerbils.Add(existing); + await db.SaveChangesAsync(); + + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + // Same name+dob → enrich, not create + new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male", + owner = "Anna Beispiel", abgabeDate = "01.10.2022", + deathDate = "", deathCause = "", farbschlag = "" } + }); + + var report = await new ImportDocxService(db, _dir).RunAsync(execute: true); + + Assert.Equal(0, report.Created); + Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one + + // GoHomeDate was enriched + var updated = await db.Gerbils.SingleAsync(); + Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate); + } + } + + // ── Test 5: Deceased status when deathDate set ─────────────────────────── + + [Fact] + public async Task Execute_sets_Deceased_status_when_deathDate_provided() + { + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + new { wsCode = "WS3", litterDob = "01.01.2022", name = "Ghost", gender = "male", + owner = "", abgabeDate = "", + deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" } + }); + + await new ImportDocxService(db, _dir).RunAsync(execute: true); + + var gerbil = await db.Gerbils.SingleAsync(); + Assert.Equal(GerbilStatus.Deceased, gerbil.Status); + Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath); + Assert.Equal("Tumor", gerbil.CauseOfDeath); + } + } + } +} diff --git a/GerbilManagerWebAPI/Import/ImportDocxService.cs b/GerbilManagerWebAPI/Import/ImportDocxService.cs index eccb2e0..df83e2f 100644 --- a/GerbilManagerWebAPI/Import/ImportDocxService.cs +++ b/GerbilManagerWebAPI/Import/ImportDocxService.cs @@ -6,14 +6,19 @@ namespace GerbilManagerWebAPI.Import { /// /// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json + - /// docx_animals.json (produced by extract_docx.py) and enriches the database: + /// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals. /// /// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT): - /// - Litter link: match docx WS-code to Litters.PairingCode → set Gerbil.LitterId - /// for animals matched by normalize(name) + litter birth date. - /// - ReceiverContact: lookup-or-create Contact by owner name → set ReceiverContactId. - /// - GoHomeDate, DateOfDeath, CauseOfDeath: fill if currently null (fill-NULL-only). - /// - NEVER overwrites a manually-set non-null value. + /// Match order per docx row: + /// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only) + /// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only) + /// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts. + /// + /// Created animals: Status=GivenAway (or Deceased), IsResident=false, + /// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx", + /// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}" key (idempotent). + /// + /// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields). /// /// Idempotent: running multiple times is safe. Each run resolves whatever is still null. /// Execute is gated by the endpoint; this service only acts when asked. @@ -48,10 +53,10 @@ namespace GerbilManagerWebAPI.Import { notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " + "extract_docx.py zuerst ausführen."); - return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, notes); + return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes); } - // Build lookup: PairingCode → Litter.Id (WS-code normalised: spaces removed) + // PairingCode → Litters (WS-code normalised: spaces removed) var littersInDb = await _db.Litters .Where(l => l.PairingCode != null) .Select(l => new { l.Id, l.Date, l.PairingCode }) @@ -60,17 +65,24 @@ namespace GerbilManagerWebAPI.Import .GroupBy(l => l.PairingCode!.Replace(" ", "")) .ToDictionary(g => g.Key, g => g.ToList()); - // Build animal lookup: normalize(name) + litter_dob → Gerbil (for litter-link) + // normalize(name)+litterDob → Gerbil snapshot (main-import enrich path) var gerbilsInDb = await _db.Gerbils .Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId, - g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath }) + g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath, + g.ExternalRef }) .ToListAsync(); var gerbilByKey = gerbilsInDb .Where(g => g.DateOfBirth is not null) .GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value)) .ToDictionary(g => g.Key, g => g.ToList()); - // Contact lookup: normalized name → existing Contact + // ExternalRef → snapshot for previously docx-created animals (idempotency across runs) + var docxExternalRefs = gerbilsInDb + .Where(g => g.ExternalRef?.StartsWith("docx-") == true) + .ToDictionary(g => g.ExternalRef!, + g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId }); + + // Contact lookup: normalized name → existing Contact.Id var contactsInDb = await _db.Contacts .Select(c => new { c.Id, c.Name }) .ToListAsync(); @@ -78,49 +90,53 @@ namespace GerbilManagerWebAPI.Import .GroupBy(c => NormalizeName(c.Name)) .ToDictionary(g => g.Key, g => g.First().Id); - int litterLinked = 0, goHomeFilled = 0, deathFilled = 0; + // ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching) + var colorVarietyByName = (await _db.ColorVarieties + .Select(cv => new { cv.Id, cv.Name }) + .ToListAsync()) + .GroupBy(cv => NormalizeName(cv.Name)) + .ToDictionary(g => g.Key, g => g.First().Id); + + int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0; int ownerLinked = 0, ownerCreated = 0, skipped = 0; foreach (var da in docxAnimals) { if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; } - // Resolve the litter by WS-code + approximate birth date + var externalRef = DocxExternalRef(da); + + // Resolve litter by WS-code + approximate birth date Guid? litterId = null; if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob)) { var litterDob = ParseDate(da.LitterDob); if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands)) { - // Pick the litter whose date matches (within ±5 days for rounding) var match = cands.FirstOrDefault(l => - Math.Abs((l.Date.DayNumber - litterDob.Value.DayNumber)) <= 5); + Math.Abs(l.Date.DayNumber - litterDob.Value.DayNumber) <= 5); litterId = match?.Id; } } - // Resolve the gerbil by name + litter birth date + // Animal DOB = litter birth date (docx has no per-animal DOB) var animalDob = litterId is not null ? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync()) : ParseDate(da.LitterDob); if (animalDob is null) { skipped++; continue; } - var key = NameDobKey(da.Name, animalDob.Value); - if (!gerbilByKey.TryGetValue(key, out var gerbilCands)) { skipped++; continue; } + var goHomeDate = ParseDate(da.AbgabeDate); + var deathDate = ParseDate(da.DeathDate); - // If multiple gerbils match (same name+dob), take the one without a litter link first - var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null) - ?? gerbilCands.First(); - - // Resolve receiver contact (lookup-or-create) + // Resolve receiver contact (lookup-or-create; shared by all paths) Guid? receiverId = null; if (!string.IsNullOrWhiteSpace(da.Owner)) { var normOwner = NormalizeName(da.Owner); - if (contactByNorm.TryGetValue(normOwner, out var existingId)) + if (contactByNorm.TryGetValue(normOwner, out var existingContactId)) { - receiverId = existingId; + receiverId = existingContactId; ownerLinked++; } else @@ -130,55 +146,117 @@ namespace GerbilManagerWebAPI.Import { var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() }; _db.Contacts.Add(newContact); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(); // persist before any Gerbil FK reference receiverId = newContact.Id; contactByNorm[normOwner] = receiverId.Value; } } } - var goHomeDate = ParseDate(da.AbgabeDate); - var deathDate = ParseDate(da.DeathDate); + // ── PATH 1: previously docx-created animal (idempotent re-run) ────────── + if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap)) + { + bool willLink = litterId is not null && prevSnap.LitterId is null; + bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null; + bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null; - // Count what will change - bool willLinkLitter = litterId is not null && gerbilSnap.LitterId is null; - bool willFillGoHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null; - bool willFillDeath = deathDate is not null && gerbilSnap.DateOfDeath is null; + if (willLink) litterLinked++; + if (willHome) goHomeFilled++; + if (willDeath) deathFilled++; - if (willLinkLitter) litterLinked++; - if (willFillGoHome) goHomeFilled++; - if (willFillDeath) deathFilled++; + if (execute) + { + var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id); + if (row is null) continue; + if (willLink) row.LitterId = litterId; + if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId; + if (willHome) row.GoHomeDate = goHomeDate; + if (willDeath) + { + row.DateOfDeath = deathDate; + if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null) + row.CauseOfDeath = da.DeathCause.Trim(); + } + } + continue; + } + + // ── PATH 2: main-import animal matched by name+dob ─────────────────────── + var nameKey = NameDobKey(da.Name, animalDob.Value); + if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands)) + { + var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null) + ?? gerbilCands.First(); + + bool willLink = litterId is not null && gerbilSnap.LitterId is null; + bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null; + bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null; + + if (willLink) litterLinked++; + if (willHome) goHomeFilled++; + if (willDeath) deathFilled++; + + if (execute) + { + var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id); + if (row is null) continue; + if (willLink) row.LitterId = litterId; + if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId; + if (willHome) row.GoHomeDate = goHomeDate; + if (willDeath) + { + row.DateOfDeath = deathDate; + if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null) + row.CauseOfDeath = da.DeathCause.Trim(); + } + } + continue; + } + + // ── PATH 3: no match → CREATE ──────────────────────────────────────────── + animalsCreated++; + if (litterId is not null) litterLinked++; + if (goHomeDate is not null) goHomeFilled++; + if (deathDate is not null) deathFilled++; if (execute) { - var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id); - if (row is null) continue; + colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId); - if (willLinkLitter) row.LitterId = litterId; - if (receiverId is not null && row.ReceiverContactId is null) - row.ReceiverContactId = receiverId; - if (willFillGoHome) row.GoHomeDate = goHomeDate; - if (willFillDeath) + _db.Gerbils.Add(new Gerbil { - row.DateOfDeath = deathDate; - if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null) - row.CauseOfDeath = da.DeathCause.Trim(); - } + Id = Guid.NewGuid(), + Name = da.Name.Trim(), + DateOfBirth = animalDob, + Gender = ParseGender(da.Gender), + Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway, + LitterId = litterId, + ReceiverContactId = receiverId, + GoHomeDate = goHomeDate, + DateOfDeath = deathDate, + CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(), + ColorVarietyId = cvId == default ? null : cvId, + OriginBreeder = "Zucht der Kleinen Chaoten", + IsResident = false, + ImportSource = "docx", + ExternalRef = externalRef, + }); } } - if (execute && (litterLinked + goHomeFilled + deathFilled + ownerLinked + ownerCreated) > 0) + if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0) await _db.SaveChangesAsync(); notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx."); - notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (WS-Code → PairingCode)."); + notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen)."); + notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet."); notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt."); notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen."); notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen."); - notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein DB-Match)."); + notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum)."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen."); - return new ImportDocxReport(execute, litterLinked, ownerLinked + ownerCreated, + return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated, goHomeFilled, deathFilled, ownerCreated, skipped, notes); } @@ -215,6 +293,27 @@ namespace GerbilManagerWebAPI.Import n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim(); return n; } + + /// + /// Stable ExternalRef key for a docx animal row — used for idempotency across re-runs. + /// Format: "docx-{wsCode}-{normname}-{litterDob}" (lowercased, spaces/punctuation stripped). + /// + internal static string DocxExternalRef(DocxAnimal da) + { + var ws = da.WsCode.Replace(" ", "").ToLowerInvariant(); + var name = System.Text.RegularExpressions.Regex.Replace( + (da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", ""); + return $"docx-{ws}-{name}-{da.LitterDob}"; + } + + private static Gender ParseGender(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return Gender.unknown; + var lower = s.ToLowerInvariant(); + if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male; + if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female; + return Gender.unknown; + } } // ---- Source shapes (from extract_docx.py output) ---- @@ -249,6 +348,7 @@ namespace GerbilManagerWebAPI.Import public sealed record ImportDocxReport( bool Executed, + int Created, int LitterLinked, int OwnerLinked, int GoHomeFilled,