FEAT-8d-CREATE: docx importer CREATE path for ~194 abgegebene Jungtiere
ImportDocxService gains a three-way match-or-create loop:
Path 1 (ExternalRef 'docx-…'): previously-created docx animals → enrich fill-NULL-only (idempotency)
Path 2 (name+dob): existing main-import animals → enrich fill-NULL-only (as before)
Path 3 (no match): CREATE new Gerbil — abgegebene Jungtiere not in pedigree charts
Status=GivenAway (or Deceased), IsResident=false,
OriginBreeder='Zucht der Kleinen Chaoten', ImportSource='docx',
ExternalRef='docx-{ws}-{normname}-{litterDob}' (stable idempotent key),
Gender/ColorVariety/LitterId/ReceiverContact/GoHomeDate/DateOfDeath all set.
ImportDocxReport gains Created field; contact-creation eagerly saved before gerbil batch.
5 SQLite-backed tests: dry-run counts, create+FK, idempotency, enrich-not-duplicate, Deceased status.
Gate: 163/163 tests, has-pending=No, no schema change.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
230
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
230
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d-CREATE: docx importer CREATE path tests.
|
||||||
|
/// Uses SQLite (not InMemory) so FK constraints are enforced.
|
||||||
|
/// </summary>
|
||||||
|
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<ApplicationContext>().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<object>());
|
||||||
|
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<object>());
|
||||||
|
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<object>());
|
||||||
|
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<object>());
|
||||||
|
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<object>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,19 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
/// 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):
|
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||||
/// - Litter link: match docx WS-code to Litters.PairingCode → set Gerbil.LitterId
|
/// Match order per docx row:
|
||||||
/// for animals matched by normalize(name) + litter birth date.
|
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||||
/// - ReceiverContact: lookup-or-create Contact by owner name → set ReceiverContactId.
|
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||||
/// - GoHomeDate, DateOfDeath, CauseOfDeath: fill if currently null (fill-NULL-only).
|
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||||
/// - NEVER overwrites a manually-set non-null value.
|
///
|
||||||
|
/// 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.
|
/// 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.
|
/// 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). " +
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||||
"extract_docx.py zuerst ausführen.");
|
"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
|
var littersInDb = await _db.Litters
|
||||||
.Where(l => l.PairingCode != null)
|
.Where(l => l.PairingCode != null)
|
||||||
.Select(l => new { l.Id, l.Date, l.PairingCode })
|
.Select(l => new { l.Id, l.Date, l.PairingCode })
|
||||||
@@ -60,17 +65,24 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
|
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.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
|
var gerbilsInDb = await _db.Gerbils
|
||||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
.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();
|
.ToListAsync();
|
||||||
var gerbilByKey = gerbilsInDb
|
var gerbilByKey = gerbilsInDb
|
||||||
.Where(g => g.DateOfBirth is not null)
|
.Where(g => g.DateOfBirth is not null)
|
||||||
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.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
|
var contactsInDb = await _db.Contacts
|
||||||
.Select(c => new { c.Id, c.Name })
|
.Select(c => new { c.Id, c.Name })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -78,49 +90,53 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.GroupBy(c => NormalizeName(c.Name))
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
.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;
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
foreach (var da in docxAnimals)
|
foreach (var da in docxAnimals)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
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;
|
Guid? litterId = null;
|
||||||
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
|
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
|
||||||
{
|
{
|
||||||
var litterDob = ParseDate(da.LitterDob);
|
var litterDob = ParseDate(da.LitterDob);
|
||||||
if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands))
|
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 =>
|
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;
|
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
|
var animalDob = litterId is not null
|
||||||
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||||
: ParseDate(da.LitterDob);
|
: ParseDate(da.LitterDob);
|
||||||
|
|
||||||
if (animalDob is null) { skipped++; continue; }
|
if (animalDob is null) { skipped++; continue; }
|
||||||
|
|
||||||
var key = NameDobKey(da.Name, animalDob.Value);
|
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||||
if (!gerbilByKey.TryGetValue(key, out var gerbilCands)) { skipped++; continue; }
|
var deathDate = ParseDate(da.DeathDate);
|
||||||
|
|
||||||
// If multiple gerbils match (same name+dob), take the one without a litter link first
|
// Resolve receiver contact (lookup-or-create; shared by all paths)
|
||||||
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
|
||||||
?? gerbilCands.First();
|
|
||||||
|
|
||||||
// Resolve receiver contact (lookup-or-create)
|
|
||||||
Guid? receiverId = null;
|
Guid? receiverId = null;
|
||||||
if (!string.IsNullOrWhiteSpace(da.Owner))
|
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||||
{
|
{
|
||||||
var normOwner = NormalizeName(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++;
|
ownerLinked++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -130,55 +146,117 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
{
|
{
|
||||||
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||||
_db.Contacts.Add(newContact);
|
_db.Contacts.Add(newContact);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync(); // persist before any Gerbil FK reference
|
||||||
receiverId = newContact.Id;
|
receiverId = newContact.Id;
|
||||||
contactByNorm[normOwner] = receiverId.Value;
|
contactByNorm[normOwner] = receiverId.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var goHomeDate = ParseDate(da.AbgabeDate);
|
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
|
||||||
var deathDate = ParseDate(da.DeathDate);
|
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
|
if (willLink) litterLinked++;
|
||||||
bool willLinkLitter = litterId is not null && gerbilSnap.LitterId is null;
|
if (willHome) goHomeFilled++;
|
||||||
bool willFillGoHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
if (willDeath) deathFilled++;
|
||||||
bool willFillDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
|
||||||
|
|
||||||
if (willLinkLitter) litterLinked++;
|
if (execute)
|
||||||
if (willFillGoHome) goHomeFilled++;
|
{
|
||||||
if (willFillDeath) deathFilled++;
|
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)
|
if (execute)
|
||||||
{
|
{
|
||||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
|
||||||
if (row is null) continue;
|
|
||||||
|
|
||||||
if (willLinkLitter) row.LitterId = litterId;
|
_db.Gerbils.Add(new Gerbil
|
||||||
if (receiverId is not null && row.ReceiverContactId is null)
|
|
||||||
row.ReceiverContactId = receiverId;
|
|
||||||
if (willFillGoHome) row.GoHomeDate = goHomeDate;
|
|
||||||
if (willFillDeath)
|
|
||||||
{
|
{
|
||||||
row.DateOfDeath = deathDate;
|
Id = Guid.NewGuid(),
|
||||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
Name = da.Name.Trim(),
|
||||||
row.CauseOfDeath = da.DeathCause.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();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
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($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
notes.Add($"Tod-Datum: {deathFilled} Todesdaten 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.");
|
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);
|
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +293,27 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable ExternalRef key for a docx animal row — used for idempotency across re-runs.
|
||||||
|
/// Format: "docx-{wsCode}-{normname}-{litterDob}" (lowercased, spaces/punctuation stripped).
|
||||||
|
/// </summary>
|
||||||
|
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) ----
|
// ---- Source shapes (from extract_docx.py output) ----
|
||||||
@@ -249,6 +348,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
public sealed record ImportDocxReport(
|
public sealed record ImportDocxReport(
|
||||||
bool Executed,
|
bool Executed,
|
||||||
|
int Created,
|
||||||
int LitterLinked,
|
int LitterLinked,
|
||||||
int OwnerLinked,
|
int OwnerLinked,
|
||||||
int GoHomeFilled,
|
int GoHomeFilled,
|
||||||
|
|||||||
Reference in New Issue
Block a user