Compare commits
10 Commits
4403574f3d
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| a42966ad8e | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 |
@@ -9,10 +9,10 @@
|
|||||||
# 2. npm test + npm run build (Frontend)
|
# 2. npm test + npm run build (Frontend)
|
||||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||||
#
|
#
|
||||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
# Registry: 192.168.2.115:13000 (internes Gitea Container Registry)
|
||||||
# Images:
|
# Images:
|
||||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
|
||||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: truenas:13000
|
REGISTRY: 192.168.2.115:13000
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
|
|||||||
276
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
276
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </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(); // schema WITH unique index on ExternalRef
|
||||||
|
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 — date-only lookup (±5 days, exactly one candidate)
|
||||||
|
var litter = new Litter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Testwurf",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", 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); // 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", 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); // 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);
|
||||||
|
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 = "2/3", 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 = "1/5", 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<object>());
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,54 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
|
||||||
|
{
|
||||||
|
// Regression for "Unable to save changes because a circular dependency was detected":
|
||||||
|
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
|
||||||
|
// Triggered when a gerbil is both parent (in one synth litter) and offspring
|
||||||
|
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
|
||||||
|
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
|
||||||
|
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
|
||||||
|
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
// must NOT throw InvalidOperationException (circular dependency)
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
|
||||||
|
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
|
||||||
|
// both should be litter-linked
|
||||||
|
Assert.NotNull(alpha.LitterId);
|
||||||
|
Assert.NotNull(beta.LitterId);
|
||||||
|
// deferred FK update must have set the litter parents correctly
|
||||||
|
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
|
||||||
|
Assert.Equal(beta.Id, alphaLitter.MotherId);
|
||||||
|
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
|
||||||
|
Assert.Equal(alpha.Id, betaLitter.MotherId);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ 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.
|
|
||||||
///
|
///
|
||||||
/// Idempotent: running multiple times is safe. Each run resolves whatever is still null.
|
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||||
|
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||||
|
/// 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. 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.
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportDocxService
|
public sealed class ImportDocxService
|
||||||
@@ -48,29 +55,38 @@ 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)
|
// 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
|
var littersInDb = await _db.Litters
|
||||||
.Where(l => l.PairingCode != null)
|
.Select(l => new { l.Id, l.Date })
|
||||||
.Select(l => new { l.Id, l.Date, l.PairingCode })
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var litterByWs = littersInDb
|
var littersByDayNumber = littersInDb
|
||||||
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
|
.GroupBy(l => l.Date.DayNumber)
|
||||||
.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 +94,79 @@ 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;
|
||||||
|
|
||||||
|
// 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<string, int>();
|
||||||
|
|
||||||
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
{
|
||||||
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
|
// 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;
|
Guid? litterId = null;
|
||||||
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
|
if (!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)
|
||||||
{
|
{
|
||||||
// Pick the litter whose date matches (within ±5 days for rounding)
|
var candidates = new List<Guid>();
|
||||||
var match = cands.FirstOrDefault(l =>
|
for (int delta = -5; delta <= 5; delta++)
|
||||||
Math.Abs((l.Date.DayNumber - litterDob.Value.DayNumber)) <= 5);
|
{
|
||||||
litterId = match?.Id;
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +176,133 @@ 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(); // flush within the outer tx
|
||||||
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 (willFillGoHome) goHomeFilled++;
|
|
||||||
if (willFillDeath) deathFilled++;
|
|
||||||
|
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||||
if (row is null) continue;
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
if (willLinkLitter) row.LitterId = litterId;
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
if (receiverId is not null && row.ReceiverContactId is null)
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
row.ReceiverContactId = receiverId;
|
if (willDeath)
|
||||||
if (willFillGoHome) row.GoHomeDate = goHomeDate;
|
|
||||||
if (willFillDeath)
|
|
||||||
{
|
{
|
||||||
row.DateOfDeath = deathDate;
|
row.DateOfDeath = deathDate;
|
||||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
row.CauseOfDeath = da.DeathCause.Trim();
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (execute && (litterLinked + goHomeFilled + deathFilled + ownerLinked + ownerCreated) > 0)
|
// ── 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 ────────────────────────────────────────────
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
||||||
|
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
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 (DOB-Match ±5 Tage, eindeutig).");
|
||||||
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 +339,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>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static string DocxExternalRefBase(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 +394,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,
|
||||||
|
|||||||
@@ -289,10 +289,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
litterParents[lid] = (f, m);
|
litterParents[lid] = (f, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
|
||||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
|
||||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
|
||||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
|
||||||
|
|
||||||
|
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
|
||||||
|
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
|
||||||
|
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
|
||||||
|
// which would cause EF's topo-sort to throw "circular dependency detected".
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||||
@@ -316,13 +321,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
derivedLitters--;
|
derivedLitters--;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
|
||||||
|
// in this same batch. Setting them now causes EF circular dependency
|
||||||
|
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
|
||||||
|
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = sl.Id,
|
Id = sl.Id,
|
||||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||||
Date = sl.Date ?? default,
|
Date = sl.Date ?? default,
|
||||||
FatherId = sl.Father,
|
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
MotherId = sl.Mother,
|
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -437,6 +446,20 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
|
||||||
|
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
|
||||||
|
if (execute && synthLitterPendingParents.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (litId, (f, m)) in synthLitterPendingParents)
|
||||||
|
{
|
||||||
|
var row = await _db.Litters.FindAsync(litId);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (f is not null) row.FatherId = f;
|
||||||
|
if (m is not null) row.MotherId = m;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,3 +79,34 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
|
|||||||
// Karte selbst zeigt '(ohne Namen)' statt leer
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const hints = page.locator('.stammbaum-hints')
|
||||||
|
await expect(hints).toBeVisible()
|
||||||
|
await expect(hints).toContainText(t.tapHint)
|
||||||
|
await expect(hints).toContainText(t.hintName)
|
||||||
|
await expect(hints).toContainText(t.hintExpand)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Einpassen, damit die 4. Generation (Emil mit +) im Viewport liegt.
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
// +-Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
|
||||||
|
const expandBtn = page.getByRole('button', { name: t.expand }).first()
|
||||||
|
await expect(expandBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf +: Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
|
||||||
|
await expandBtn.click({ force: true })
|
||||||
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ALL_TRAITS } from '../format/traits'
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import './charakterbogen.css'
|
import './charakterbogen.css'
|
||||||
|
|
||||||
export interface CharakterbogenProps {
|
export interface CharakterbogenProps {
|
||||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
|||||||
onNoteChange: (note: string) => void
|
onNoteChange: (note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
|
||||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
|
||||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
|
||||||
* de.character.traits; the stored value is the trait KEY.
|
|
||||||
*/
|
|
||||||
export default function Charakterbogen({
|
export default function Charakterbogen({
|
||||||
traits,
|
traits,
|
||||||
note,
|
note,
|
||||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
|||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(key)) next.delete(key)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(key)
|
else next.add(key)
|
||||||
// Preserve the vocabulary order for stable output.
|
// Preserve vocabulary (category) order for stable output.
|
||||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="charakterbogen">
|
<div className="charakterbogen">
|
||||||
|
{TRAIT_CATEGORIES.map((cat) => (
|
||||||
|
<section key={cat.category} className="trait-category">
|
||||||
|
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||||
<ul className="trait-grid">
|
<ul className="trait-grid">
|
||||||
{ALL_TRAITS.map((tr) => (
|
{cat.traits.map((tr) => (
|
||||||
<li key={tr.key}>
|
<li key={tr.key}>
|
||||||
<label className="trait-chip">
|
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(tr.key)}
|
checked={selected.has(tr.key)}
|
||||||
onChange={() => toggle(tr.key)}
|
onChange={() => toggle(tr.key)}
|
||||||
/>
|
/>
|
||||||
<span>{tr.label}</span>
|
<span>{tr.label}</span>
|
||||||
|
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.noteLabel}</span>
|
<span>{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
|
||||||
|
|
||||||
|
.trait-category {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-category__heading {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.charakterbogen .trait-grid {
|
.charakterbogen .trait-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -39,3 +52,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trait-chip--warn {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-warn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
|
||||||
|
|
||||||
|
describe('trait catalog', () => {
|
||||||
|
it('has 4 categories', () => {
|
||||||
|
expect(TRAIT_CATEGORIES).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('category names match spec', () => {
|
||||||
|
const names = TRAIT_CATEGORIES.map((c) => c.category)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'Sozialverhalten',
|
||||||
|
'Eignung & Umgang',
|
||||||
|
'Hobbys & Eigenarten',
|
||||||
|
'Wesen & Temperament',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ALL_TRAITS flattens all categories', () => {
|
||||||
|
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
|
||||||
|
expect(ALL_TRAITS).toHaveLength(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all keys are unique', () => {
|
||||||
|
const keys = ALL_TRAITS.map((t) => t.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing 15 keys are still present (stored on live animals)', () => {
|
||||||
|
const legacy = [
|
||||||
|
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
|
||||||
|
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
|
||||||
|
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of legacy) {
|
||||||
|
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new keys are present', () => {
|
||||||
|
const newKeys = [
|
||||||
|
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
|
||||||
|
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
|
||||||
|
'schredder', 'nestbauer', 'territorial',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of newKeys) {
|
||||||
|
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
|
||||||
|
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
|
||||||
|
expect(isWarnTrait('territorial')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-warn traits return false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('zutraulich')).toBe(false)
|
||||||
|
expect(isWarnTrait('dominant')).toBe(false)
|
||||||
|
expect(isWarnTrait('beobachtungstier')).toBe(false)
|
||||||
|
expect(isWarnTrait('schreckhaft')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown key returns false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('not-a-real-key')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns German label for known key', () => {
|
||||||
|
expect(traitLabel('zutraulich')).toBe('zutraulich')
|
||||||
|
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
|
||||||
|
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
|
||||||
|
expect(traitLabel('territorial')).toBe('territorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns the key itself for unknown key', () => {
|
||||||
|
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels maps a list of keys to labels', () => {
|
||||||
|
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
|
||||||
|
'zutraulich',
|
||||||
|
'dominant (Leittier)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels handles null and undefined gracefully', () => {
|
||||||
|
expect(traitLabels(null)).toEqual([])
|
||||||
|
expect(traitLabels(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
export const ALL_TRAITS = de.character.traits
|
export interface TraitEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
warn?: true
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
export interface TraitCategory {
|
||||||
|
category: string
|
||||||
|
traits: readonly TraitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
|
||||||
|
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
|
||||||
|
|
||||||
|
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
export function traitLabel(key: string): string {
|
export function traitLabel(key: string): string {
|
||||||
return LABEL_BY_KEY.get(key) ?? key
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
|
|||||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
return (keys ?? []).map(traitLabel)
|
return (keys ?? []).map(traitLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWarnTrait(key: string): boolean {
|
||||||
|
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -335,7 +335,11 @@ export default function StammbaumPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="stammbaum-hint">{t.tapHint}</p>
|
<ul className="stammbaum-hints">
|
||||||
|
<li>{t.tapHint}</li>
|
||||||
|
<li>{t.hintName}</li>
|
||||||
|
<li>{t.hintExpand}</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||||
|
|||||||
@@ -34,10 +34,15 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stammbaum-hint {
|
.stammbaum-hints {
|
||||||
font-size: 0.8rem;
|
list-style: none;
|
||||||
color: var(--color-text-muted);
|
padding: 0;
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
@@ -175,20 +180,27 @@
|
|||||||
.pedigree-card__expand {
|
.pedigree-card__expand {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--color-accent);
|
border: none;
|
||||||
background: var(--color-accent-soft);
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-card__expand:hover {
|
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 5px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__expand:hover,
|
||||||
|
.pedigree-card__expand:focus-visible {
|
||||||
|
opacity: 0.82;
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
||||||
|
|||||||
@@ -355,6 +355,9 @@ export const de = {
|
|||||||
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
||||||
expand: 'Vorfahren laden',
|
expand: 'Vorfahren laden',
|
||||||
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
||||||
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
zoomIn: 'Vergrößern',
|
zoomIn: 'Vergrößern',
|
||||||
zoomOut: 'Verkleinern',
|
zoomOut: 'Verkleinern',
|
||||||
zoomFit: 'Ansicht einpassen',
|
zoomFit: 'Ansicht einpassen',
|
||||||
@@ -784,9 +787,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||||
character: {
|
character: {
|
||||||
sectionTitle: 'Charakter & Eigenschaften',
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
noteLabel: 'Notizen zum Charakter',
|
noteLabel: 'Notizen zum Charakter',
|
||||||
@@ -794,6 +797,40 @@ export const de = {
|
|||||||
save: 'Charakter speichern',
|
save: 'Charakter speichern',
|
||||||
saved: 'Charakter gespeichert.',
|
saved: 'Charakter gespeichert.',
|
||||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
|
warnLabel: '⚠ Warnsignal',
|
||||||
|
traitCategories: [
|
||||||
|
{
|
||||||
|
category: 'Sozialverhalten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||||
|
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||||
|
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||||
|
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Eignung & Umgang',
|
||||||
|
traits: [
|
||||||
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
|
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' },
|
||||||
|
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
|
||||||
|
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Hobbys & Eigenarten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'schredder', label: 'Schredder-Meister' },
|
||||||
|
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
|
||||||
|
{ key: 'territorial', label: 'territorial', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Wesen & Temperament',
|
||||||
traits: [
|
traits: [
|
||||||
{ key: 'zutraulich', label: 'zutraulich' },
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
{ key: 'handzahm', label: 'handzahm' },
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
@@ -803,15 +840,12 @@ export const de = {
|
|||||||
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
{ key: 'verschmust', label: 'verschmust' },
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
{ key: 'eigenstaendig', label: 'eigenständig' },
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
|
||||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
|
||||||
{ key: 'buddelt', label: 'buddelt gern' },
|
|
||||||
{ key: 'klettert', label: 'klettert gern' },
|
|
||||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
|
||||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
filterPanel: {
|
filterPanel: {
|
||||||
toggleButton: 'Filter',
|
toggleButton: 'Filter',
|
||||||
|
|||||||
Reference in New Issue
Block a user