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}";