From d5e8946d7d1710e5b89d981a9b4bbebfd7ff1f6a Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 23:31:46 +0200 Subject: [PATCH] =?UTF-8?q?FIX-8D-DEDUP:=20P0=20duplicate=20ExternalRef=20?= =?UTF-8?q?+=20WsCode=E2=86=92litter=20mismatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX-1 DEDUP (P0): Two same-name siblings in one litter produced identical ExternalRefs ('docx-{ws}-{name}-{dob}'), crashing execute on IX_Gerbils_ExternalRef. Fix: ordinal counter per base-ref within the batch → first occurrence keeps the base ref, subsequent ones get -2, -3 … suffix. Deterministic (JSON-order) → idempotent re-runs find existing rows via ExternalRef path (PATH 1). HashSet guard added as a belt-and-suspenders check. FIX-2 TRANSACTION: execute now opens a single BeginTransactionAsync before the loop and commits after the final SaveChangesAsync. Eager contact saves (within the tx) and the gerbil batch save are fully atomic — crash → full rollback, no partial state. FIX-3 LITTER LOOKUP: docx WsCode is a litter-size fraction ('4/4', '/5') — NOT a PairingCode like 'G01/ZdkC'. The previous WsCode→PairingCode lookup was always a no-op (hence litterLinked=0 in every dry-run). Fix: look up DB litters by LitterDob (±5 days); link only when exactly ONE candidate exists (unambiguous, no false links). P0 REGRESSION TEST (Test 6): SQLite + EnsureCreated → unique index enforced. Two animals same name+litter → execute succeeds, both created, distinct ExternalRefs, re-run = 0 new. This test would have caught the live crash. Gate: 164/164 tests, has-pending=No, no schema change. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- GerbilManager.Tests/ImportDocxServiceTests.cs | 76 ++++- .../Import/ImportDocxService.cs | 310 ++++++++++-------- 2 files changed, 239 insertions(+), 147 deletions(-) diff --git a/GerbilManager.Tests/ImportDocxServiceTests.cs b/GerbilManager.Tests/ImportDocxServiceTests.cs index 2959b43..3fdcc67 100644 --- a/GerbilManager.Tests/ImportDocxServiceTests.cs +++ b/GerbilManager.Tests/ImportDocxServiceTests.cs @@ -6,8 +6,9 @@ using Microsoft.EntityFrameworkCore; namespace GerbilManager.Tests { /// - /// FEAT-8d-CREATE: docx importer CREATE path tests. - /// Uses SQLite (not InMemory) so FK constraints are enforced. + /// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests. + /// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced — + /// this is the only reliable way to catch duplicate-ExternalRef crashes. /// public class ImportDocxServiceTests : IDisposable { @@ -31,7 +32,7 @@ namespace GerbilManager.Tests conn.Open(); var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; var db = new ApplicationContext(opts); - db.Database.EnsureCreated(); + db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef return (db, conn); } @@ -52,13 +53,12 @@ namespace GerbilManager.Tests await using (conn) await using (db) { - // Litter in DB with PairingCode matching the docx WS-code + // Litter in DB — date-only lookup (±5 days, exactly one candidate) var litter = new Litter { Id = Guid.NewGuid(), - Name = "Testwurf WS1", + Name = "Testwurf", Date = new DateOnly(2023, 5, 1), - PairingCode = "WS1" }; db.Litters.Add(litter); await db.SaveChangesAsync(); @@ -66,7 +66,7 @@ namespace GerbilManager.Tests WriteLitters(Array.Empty()); WriteAnimals(new[] { - new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female", + new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female", owner = "Max Mustermann", abgabeDate = "01.07.2023", deathDate = "", deathCause = "", farbschlag = "" } }); @@ -75,8 +75,8 @@ namespace GerbilManager.Tests 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(1, report.LitterLinked); // date-only match finds the one litter + Assert.Equal(1, report.GoHomeFilled); Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written } @@ -94,9 +94,8 @@ namespace GerbilManager.Tests var litter = new Litter { Id = Guid.NewGuid(), - Name = "Testwurf WS1", + Name = "Testwurf", Date = new DateOnly(2023, 5, 1), - PairingCode = "WS1" }; db.Litters.Add(litter); await db.SaveChangesAsync(); @@ -104,7 +103,7 @@ namespace GerbilManager.Tests WriteLitters(Array.Empty()); WriteAnimals(new[] { - new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female", + new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female", owner = "Max Mustermann", abgabeDate = "01.07.2023", deathDate = "", deathCause = "", farbschlag = "" } }); @@ -120,7 +119,7 @@ namespace GerbilManager.Tests 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(litter.Id, gerbil.LitterId); // date-only link worked Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate); Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder); Assert.False(gerbil.IsResident); @@ -145,7 +144,7 @@ namespace GerbilManager.Tests WriteLitters(Array.Empty()); WriteAnimals(new[] { - new { wsCode = "WS2", litterDob = "15.03.2023", name = "Flash", gender = "male", + new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male", owner = "", abgabeDate = "01.05.2023", deathDate = "", deathCause = "", farbschlag = "" } }); @@ -213,7 +212,7 @@ namespace GerbilManager.Tests WriteLitters(Array.Empty()); WriteAnimals(new[] { - new { wsCode = "WS3", litterDob = "01.01.2022", name = "Ghost", gender = "male", + new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male", owner = "", abgabeDate = "", deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" } }); @@ -226,5 +225,52 @@ namespace GerbilManager.Tests Assert.Equal("Tumor", gerbil.CauseOfDeath); } } + + // ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─ + + [Fact] + public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs() + { + // P0 regression: two animals in the same litter with the same normalized name + // previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash. + // Fix: ordinal disambiguation (-2) ensures uniqueness within the batch. + // The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes + // this test an authoritative regression gate. + var (db, conn) = NewSqliteDb(); + await using (conn) + await using (db) + { + WriteLitters(Array.Empty()); + WriteAnimals(new[] + { + new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female", + owner = "Eva Müller", abgabeDate = "10.08.2022", + deathDate = "", deathCause = "", farbschlag = "" }, + // Identical name+wsCode+litterDob → base ExternalRef collision + new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female", + owner = "Lena Braun", abgabeDate = "11.08.2022", + deathDate = "", deathCause = "", farbschlag = "" }, + }); + + // Must NOT throw unique-key violation + var report = await new ImportDocxService(db, _dir).RunAsync(execute: true); + + Assert.Equal(2, report.Created); + Assert.Equal(2, await db.Gerbils.CountAsync()); + + var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList(); + // Both start with the docx- prefix + Assert.All(refs, r => Assert.StartsWith("docx-", r)); + // Must be distinct (unique index enforces this in SQLite) + Assert.Equal(2, refs.Distinct().Count()); + // Second occurrence gets the -2 suffix + Assert.Single(refs, r => r.EndsWith("-2")); + + // Idempotent re-run: zero new, still 2 in DB + var second = await new ImportDocxService(db, _dir).RunAsync(execute: true); + Assert.Equal(0, second.Created); + Assert.Equal(2, await db.Gerbils.CountAsync()); + } + } } } diff --git a/GerbilManagerWebAPI/Import/ImportDocxService.cs b/GerbilManagerWebAPI/Import/ImportDocxService.cs index df83e2f..a2fceb6 100644 --- a/GerbilManagerWebAPI/Import/ImportDocxService.cs +++ b/GerbilManagerWebAPI/Import/ImportDocxService.cs @@ -16,11 +16,13 @@ namespace GerbilManagerWebAPI.Import /// /// Created animals: Status=GivenAway (or Deceased), IsResident=false, /// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx", - /// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}" key (idempotent). + /// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent; + /// -N ordinal suffix disambiguates same-name siblings in a litter). /// /// 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. + /// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef. + /// Execute wraps all writes in a single transaction (atomic: crash → full rollback). /// Execute is gated by the endpoint; this service only acts when asked. /// public sealed class ImportDocxService @@ -56,13 +58,15 @@ namespace GerbilManagerWebAPI.Import return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes); } - // PairingCode → Litters (WS-code normalised: spaces removed) + // Litter lookup by birth date (DayNumber) → list of matching DB litters. + // NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode. + // Date-only lookup with uniqueness guard avoids false links (only link when + // exactly one DB litter falls within ±5 days of the docx litter DOB). var littersInDb = await _db.Litters - .Where(l => l.PairingCode != null) - .Select(l => new { l.Id, l.Date, l.PairingCode }) + .Select(l => new { l.Id, l.Date }) .ToListAsync(); - var litterByWs = littersInDb - .GroupBy(l => l.PairingCode!.Replace(" ", "")) + var littersByDayNumber = littersInDb + .GroupBy(l => l.Date.DayNumber) .ToDictionary(g => g.Key, g => g.ToList()); // normalize(name)+litterDob → Gerbil snapshot (main-import enrich path) @@ -100,156 +104,198 @@ namespace GerbilManagerWebAPI.Import int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0; int ownerLinked = 0, ownerCreated = 0, skipped = 0; - foreach (var da in docxAnimals) + // Ordinal counter for collision-free ExternalRef within this batch. + // Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes. + var externalRefOrdinals = new Dictionary(); + + // Belt-and-suspenders: guard against adding the same ExternalRef twice in one run. + var batchRefs = new HashSet(); + + // --- Planning pass (dry-run counts + execute writes) --- + // Execute path is wrapped in a single transaction for atomicity. + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; + if (execute) + tx = await _db.Database.BeginTransactionAsync(); + + try { - if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; } - - var externalRef = DocxExternalRef(da); - - // Resolve litter by WS-code + approximate birth date - Guid? litterId = null; - if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob)) + foreach (var da in docxAnimals) { - var litterDob = ParseDate(da.LitterDob); - if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands)) + if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; } + + // Collision-free ExternalRef: ordinal suffix for same-name siblings + var baseRef = DocxExternalRefBase(da); + externalRefOrdinals.TryGetValue(baseRef, out var ord); + ord++; + externalRefOrdinals[baseRef] = ord; + var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}"; + + // Resolve litter: date ±5 days, unambiguous (exactly one candidate) + Guid? litterId = null; + if (!string.IsNullOrWhiteSpace(da.LitterDob)) { - var match = cands.FirstOrDefault(l => - Math.Abs(l.Date.DayNumber - litterDob.Value.DayNumber) <= 5); - litterId = match?.Id; + var litterDob = ParseDate(da.LitterDob); + if (litterDob is not null) + { + var candidates = new List(); + for (int delta = -5; delta <= 5; delta++) + { + if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl)) + candidates.AddRange(cl.Select(l => l.Id)); + } + if (candidates.Count == 1) + litterId = candidates[0]; + // If 0 or >1 candidates: no link (avoid false links) + } } - } - // 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); + // 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; } + if (animalDob is null) { skipped++; continue; } - var goHomeDate = ParseDate(da.AbgabeDate); - var deathDate = ParseDate(da.DeathDate); + var goHomeDate = ParseDate(da.AbgabeDate); + var deathDate = ParseDate(da.DeathDate); - // 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 existingContactId)) + // Resolve receiver contact (lookup-or-create; shared by all paths) + Guid? receiverId = null; + if (!string.IsNullOrWhiteSpace(da.Owner)) { - receiverId = existingContactId; - ownerLinked++; + var normOwner = NormalizeName(da.Owner); + if (contactByNorm.TryGetValue(normOwner, out var existingContactId)) + { + receiverId = existingContactId; + ownerLinked++; + } + else + { + ownerCreated++; + if (execute) + { + var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() }; + _db.Contacts.Add(newContact); + await _db.SaveChangesAsync(); // flush within the outer tx + receiverId = newContact.Id; + contactByNorm[normOwner] = receiverId.Value; + } + } } - else + + // ── PATH 1: previously docx-created animal (idempotent re-run) ────────── + if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap)) { - ownerCreated++; + 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; + + if (willLink) litterLinked++; + if (willHome) goHomeFilled++; + if (willDeath) deathFilled++; + if (execute) { - var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() }; - _db.Contacts.Add(newContact); - await _db.SaveChangesAsync(); // persist before any Gerbil FK reference - receiverId = newContact.Id; - contactByNorm[normOwner] = receiverId.Value; + 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 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; + // ── 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(); - if (willLink) litterLinked++; - if (willHome) goHomeFilled++; - if (willDeath) deathFilled++; + 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 ──────────────────────────────────────────── + // Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway + if (!batchRefs.Add(externalRef)) { skipped++; continue; } + + 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 == 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) + colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId); + + _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, + }); } - 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(); + // Flush all gerbil inserts + enrich updates in one shot (within the tx) + if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0) + await _db.SaveChangesAsync(); - 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) - { - colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId); - - _db.Gerbils.Add(new Gerbil - { - 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 (tx is not null) await tx.CommitAsync(); + } + catch + { + // tx.DisposeAsync (in finally) rolls back if not committed + throw; + } + finally + { + if (tx is not null) await tx.DisposeAsync(); } - - 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($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen)."); - notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet."); + notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig)."); 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."); @@ -295,12 +341,12 @@ namespace GerbilManagerWebAPI.Import } /// - /// Stable ExternalRef key for a docx animal row — used for idempotency across re-runs. - /// Format: "docx-{wsCode}-{normname}-{litterDob}" (lowercased, spaces/punctuation stripped). + /// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same + /// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates. /// - internal static string DocxExternalRef(DocxAnimal da) + internal static string DocxExternalRefBase(DocxAnimal da) { - var ws = da.WsCode.Replace(" ", "").ToLowerInvariant(); + 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}";