Compare commits
146 Commits
feature/fo
...
cb2ee03a14
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2ee03a14 | |||
| 41ff973f9a | |||
| d2993418b6 | |||
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 | |||
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 | |||
| 5732398e60 | |||
| 0185446bfe | |||
| 04d189bd2f | |||
| 7d8166c555 | |||
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| a42966ad8e | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| c452b69dd6 | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| f20a42a94b |
@@ -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: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||||
# Images:
|
# Images:
|
||||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: truenas:13000
|
REGISTRY: git.rismer.de
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
@@ -48,6 +48,26 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||||
|
|
||||||
|
- name: dotnet-ef Tool installieren
|
||||||
|
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||||
|
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||||
|
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||||
|
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||||
|
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||||
|
run: |
|
||||||
|
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||||
|
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: DB-3 EF Migrations Drift-Check
|
||||||
|
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||||
|
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||||
|
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||||
|
run: >
|
||||||
|
dotnet ef migrations has-pending-model-changes
|
||||||
|
--project GerbilManagerWebAPI
|
||||||
|
--startup-project GerbilManagerWebAPI
|
||||||
|
--no-build --configuration Release
|
||||||
|
|
||||||
- name: Tests ausfuehren
|
- name: Tests ausfuehren
|
||||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||||
|
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -131,6 +131,12 @@ $RECYCLE.BIN/
|
|||||||
# MemPalace per-project files (issue #185)
|
# MemPalace per-project files (issue #185)
|
||||||
mempalace.yaml
|
mempalace.yaml
|
||||||
entities.json
|
entities.json
|
||||||
|
|
||||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||||
GerbilManagerWebAPI/photo-storage/
|
GerbilManagerWebAPI/photo-storage/
|
||||||
|
|
||||||
|
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||||
|
GerbilManagerWebAPI/.data-protection-keys/
|
||||||
|
|
||||||
|
# node_modules — never commit, installed locally per npm install
|
||||||
|
node_modules/
|
||||||
|
|||||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||||
|
public class CmsPublishTests
|
||||||
|
{
|
||||||
|
private static string TempRoot() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["index.html"] = "<html>start</html>",
|
||||||
|
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||||
|
["assets/site.css"] = "body {}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||||
|
Assert.Equal("<html>start</html>",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||||
|
Assert.Equal("version-2", content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_mehrfach_ohne_Fehler()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("v3",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(
|
||||||
|
Path.Combine(root, "live", "index.html"),
|
||||||
|
System.Text.Encoding.UTF8);
|
||||||
|
Assert.Equal(german, content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||||
// a non-ForSale gerbil must NOT appear
|
// a non-ForSale gerbil must NOT appear
|
||||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||||
|
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = "Krümel",
|
Name = "Krümel",
|
||||||
Gender = Gender.female,
|
Gender = Gender.female,
|
||||||
Status = GerbilStatus.Active,
|
Status = GerbilStatus.Breeding,
|
||||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||||
LitterId = litter.Id,
|
LitterId = litter.Id,
|
||||||
EnclosureId = enclosure.Id,
|
EnclosureId = enclosure.Id,
|
||||||
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
|
|||||||
var row = lines[1];
|
var row = lines[1];
|
||||||
Assert.Contains("Krümel", row);
|
Assert.Contains("Krümel", row);
|
||||||
Assert.Contains("Weiblich", row);
|
Assert.Contains("Weiblich", row);
|
||||||
Assert.Contains("Aktiv", row);
|
Assert.Contains("Zucht", row);
|
||||||
Assert.Contains("12.03.2025", row);
|
Assert.Contains("12.03.2025", row);
|
||||||
Assert.Contains("Großbecken", row);
|
Assert.Contains("Großbecken", row);
|
||||||
Assert.Contains("Wurf K", row);
|
Assert.Contains("Wurf K", row);
|
||||||
|
|||||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
|
||||||
|
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
|
||||||
|
public GerbilProfilePhotoTests(ApiFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid ExtractId(string json) =>
|
||||||
|
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||||
|
|
||||||
|
private static string? GetStr(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString() : null;
|
||||||
|
|
||||||
|
private async Task<Guid> CreateGerbil(string name)
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||||
|
foreach (var (fn, so) in photos)
|
||||||
|
db.GerbilPhotos.Add(new GerbilPhoto
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), GerbilId = gerbilId,
|
||||||
|
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
|
||||||
|
{
|
||||||
|
var id = await CreateGerbil("NoPhoto");
|
||||||
|
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||||
|
var el = JsonDocument.Parse(json).RootElement;
|
||||||
|
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
|
||||||
|
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
|
||||||
|
{
|
||||||
|
var id = await CreateGerbil("PhotoGerbil");
|
||||||
|
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
|
||||||
|
|
||||||
|
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||||
|
var el = JsonDocument.Parse(json).RootElement;
|
||||||
|
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
|
||||||
|
{
|
||||||
|
var id = await CreateGerbil("ListPhoto");
|
||||||
|
InsertPhotos(id, ("list-photo.jpg", 0));
|
||||||
|
|
||||||
|
var json = await _client.GetStringAsync("/gerbils");
|
||||||
|
var root = JsonDocument.Parse(json).RootElement;
|
||||||
|
var items = root.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
|
||||||
|
Assert.NotEqual(default, gerbil);
|
||||||
|
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
|
||||||
|
public class GerbilSearchTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
gender = "female",
|
||||||
|
originBreeder,
|
||||||
|
});
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
|
||||||
|
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
|
||||||
|
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
|
||||||
|
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
|
||||||
|
Assert.Equal(expected, GerbilSearch.Normalize(input));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NameSearch_filter_matches_across_separators()
|
||||||
|
{
|
||||||
|
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||||
|
|
||||||
|
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
|
||||||
|
// The client normalizes the user's term the same way the column is normalized.
|
||||||
|
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
|
||||||
|
Assert.Contains("Clan-Kleine-Chaoten", json);
|
||||||
|
|
||||||
|
// a term that doesn't normalize-match must NOT return it
|
||||||
|
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
|
||||||
|
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
|
||||||
|
{
|
||||||
|
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
|
||||||
|
|
||||||
|
// distinct-values dropdown source
|
||||||
|
var breeders = await _client.GetStringAsync("/gerbils/breeders");
|
||||||
|
Assert.Contains("Zucht der kleinen Chaoten", breeders);
|
||||||
|
|
||||||
|
// Gridify filter on the field
|
||||||
|
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
|
||||||
|
Assert.Contains("Herkunftstier", filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||||
|
public class GerbilStatusTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||||
|
|
||||||
|
// 1) DateOfDeath → Deceased, always overrides user input
|
||||||
|
[Fact]
|
||||||
|
public void DateOfDeath_set_returns_Deceased()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateOfDeath wins even when also Abgegeben
|
||||||
|
[Fact]
|
||||||
|
public void DateOfDeath_wins_over_Abgegeben()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||||
|
isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Abgegeben without death date → GivenAway
|
||||||
|
[Fact]
|
||||||
|
public void ReceiverContact_set_returns_GivenAway()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||||
|
[Fact]
|
||||||
|
public void OlderThan6y_without_death_returns_Deceased()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
|
||||||
|
[Fact]
|
||||||
|
public void ExactlyMaxAge_is_not_Deceased()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
|
||||||
|
// (threshold = "older than" so >= means Deceased)
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One day before threshold is alive
|
||||||
|
[Fact]
|
||||||
|
public void OneDayBeforeMaxAge_is_alive()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||||
|
[Fact]
|
||||||
|
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-7);
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Default → Breeding
|
||||||
|
[Fact]
|
||||||
|
public void No_special_condition_returns_Breeding()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4a) User sets Pet → kept
|
||||||
|
[Fact]
|
||||||
|
public void User_Pet_is_preserved()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Pet, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b) User sets ForSale → kept
|
||||||
|
[Fact]
|
||||||
|
public void User_ForSale_is_preserved()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.ForSale, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateOfBirth null → age rule does not fire
|
||||||
|
[Fact]
|
||||||
|
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
|
||||||
|
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 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_works_under_retrying_execution_strategy()
|
||||||
|
{
|
||||||
|
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||||
|
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||||
|
// InvalidOperationException when it detects a user-initiated transaction
|
||||||
|
// that was NOT opened through the strategy. This test wires the same check
|
||||||
|
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||||
|
// without a live Npgsql instance.
|
||||||
|
//
|
||||||
|
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||||
|
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||||
|
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||||
|
// → OnFirstExecution: no tx yet → OK
|
||||||
|
var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseSqlite(conn)
|
||||||
|
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||||
|
.Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||||
|
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.True(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||||
|
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||||
|
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||||
|
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||||
|
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||||
|
: IExecutionStrategyFactory
|
||||||
|
{
|
||||||
|
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(2, await db.Litters.CountAsync());
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||||
|
{
|
||||||
|
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||||
|
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||||
|
using var db = NewDb();
|
||||||
|
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
// ExternalRef is set on created litters
|
||||||
|
var litters = await db.Litters.ToListAsync();
|
||||||
|
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||||
|
|
||||||
|
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||||
|
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||||
|
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||||
|
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Litters.Created);
|
||||||
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||||
{
|
{
|
||||||
@@ -236,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()
|
||||||
{
|
{
|
||||||
@@ -548,27 +621,32 @@ namespace GerbilManager.Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||||
{
|
{
|
||||||
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
|
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
|
||||||
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
|
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
|
||||||
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
|
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
|
||||||
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
|
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
|
||||||
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
|
|
||||||
|
|
||||||
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
|
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||||
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||||
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||||
Assert.NotNull(cpFuchs);
|
Assert.NotNull(cpFuchs);
|
||||||
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||||
|
|
||||||
// New entries at IDs 62-66 exist with correct names
|
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||||
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||||
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||||
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||||
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||||
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||||
|
|
||||||
// Total count is exactly 66
|
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
|
||||||
Assert.Equal(66, db.ColorVarieties.Count());
|
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
|
||||||
|
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
|
||||||
|
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
|
||||||
|
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
|
||||||
|
|
||||||
|
// Total count is exactly 70
|
||||||
|
Assert.Equal(70, db.ColorVarieties.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -580,6 +658,74 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(new DateOnly(y, m, d), date);
|
Assert.Equal(new DateOnly(y, m, d), date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||||
|
{
|
||||||
|
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||||
|
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||||
|
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||||
|
// stable ExternalRef without throwing.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
// Run 1: load the animal normally
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||||
|
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||||
|
g.Name = "Umbenannt Tier";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||||
|
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||||
|
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||||
|
{
|
||||||
|
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||||
|
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||||
|
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
// Exact Agouti genotype, no explicit Farbschlag name
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||||
|
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||||
|
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||||
|
Assert.NotNull(tier.ColorVarietyId);
|
||||||
|
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||||
|
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||||
|
Assert.Equal("Agouti", variety!.Name);
|
||||||
|
// Report counter should reflect the genotype derivation
|
||||||
|
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
// ---- fixtures ----
|
// ---- fixtures ----
|
||||||
private const string LittersJson = """
|
private const string LittersJson = """
|
||||||
[
|
[
|
||||||
|
|||||||
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
public class IngestResolvedServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
private readonly string _resolvedJsonPath;
|
||||||
|
|
||||||
|
public IngestResolvedServiceTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
||||||
|
|
||||||
|
var contactId = Guid.NewGuid();
|
||||||
|
var fatherId = Guid.NewGuid();
|
||||||
|
var motherId = Guid.NewGuid();
|
||||||
|
var litterId = Guid.NewGuid();
|
||||||
|
var photoId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
Contacts = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = contactId,
|
||||||
|
Name = "Test Breeder",
|
||||||
|
ZuchtName = "Test Zucht",
|
||||||
|
City = "Test City",
|
||||||
|
Email = "test@example.com",
|
||||||
|
Homepage = "",
|
||||||
|
Phone = "",
|
||||||
|
Address = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Litters = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = litterId,
|
||||||
|
Name = "Wurf A",
|
||||||
|
Date = "2026-01-01",
|
||||||
|
TotalBorn = 5,
|
||||||
|
DeathsWithin8Weeks = 0,
|
||||||
|
FatherId = fatherId,
|
||||||
|
MotherId = motherId,
|
||||||
|
ExpectedGoHomeDate = (string?)null,
|
||||||
|
Notes = "Test litter notes",
|
||||||
|
PairingCode = "PC01",
|
||||||
|
ExternalRef = "ext-litter-1",
|
||||||
|
LitterLetter = "A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Gerbils = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = fatherId,
|
||||||
|
Name = "Papa",
|
||||||
|
Gender = "male",
|
||||||
|
Status = "Breeding",
|
||||||
|
LitterId = (Guid?)null,
|
||||||
|
OriginContactId = contactId,
|
||||||
|
ReceiverContactId = (Guid?)null,
|
||||||
|
EnclosureId = (Guid?)null,
|
||||||
|
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||||
|
DateOfBirth = "2025-01-01",
|
||||||
|
DateOfDeath = (string?)null,
|
||||||
|
CauseOfDeath = (string?)null,
|
||||||
|
GoHomeDate = (string?)null,
|
||||||
|
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||||
|
Notes = "",
|
||||||
|
ImportSource = "docx-export",
|
||||||
|
ExternalRef = "ext-papa",
|
||||||
|
RawImportData = "{}",
|
||||||
|
OriginBreeder = "Test Zucht",
|
||||||
|
NameSearch = "papa",
|
||||||
|
CharacterTraits = new string[] {},
|
||||||
|
CharacterNote = (string?)null,
|
||||||
|
IsDeaf = false,
|
||||||
|
IsResident = true
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = motherId,
|
||||||
|
Name = "Mama",
|
||||||
|
Gender = "female",
|
||||||
|
Status = "Breeding",
|
||||||
|
LitterId = (Guid?)null,
|
||||||
|
OriginContactId = contactId,
|
||||||
|
ReceiverContactId = (Guid?)null,
|
||||||
|
EnclosureId = (Guid?)null,
|
||||||
|
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||||
|
DateOfBirth = "2025-01-01",
|
||||||
|
DateOfDeath = (string?)null,
|
||||||
|
CauseOfDeath = (string?)null,
|
||||||
|
GoHomeDate = (string?)null,
|
||||||
|
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||||
|
Notes = "",
|
||||||
|
ImportSource = "docx-export",
|
||||||
|
ExternalRef = "ext-mama",
|
||||||
|
RawImportData = "{}",
|
||||||
|
OriginBreeder = "Test Zucht",
|
||||||
|
NameSearch = "mama",
|
||||||
|
CharacterTraits = new string[] {},
|
||||||
|
CharacterNote = (string?)null,
|
||||||
|
IsDeaf = false,
|
||||||
|
IsResident = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GerbilPhotos = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = photoId,
|
||||||
|
GerbilId = fatherId,
|
||||||
|
FileName = "photo1.jpg",
|
||||||
|
SortOrder = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationContext NewDb()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Import:SourcePath", _dir }
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var service = new IngestResolvedService(db, config, null!);
|
||||||
|
var result = await service.RunAsync();
|
||||||
|
|
||||||
|
Assert.Contains("Ingestion successful!", result);
|
||||||
|
|
||||||
|
// Verify counts
|
||||||
|
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync());
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
||||||
|
|
||||||
|
// Verify relations
|
||||||
|
var litter = await db.Litters.SingleAsync();
|
||||||
|
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
||||||
|
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
||||||
|
var photo = await db.GerbilPhotos.SingleAsync();
|
||||||
|
|
||||||
|
Assert.Equal(father.Id, litter.FatherId);
|
||||||
|
Assert.Equal(mother.Id, litter.MotherId);
|
||||||
|
Assert.Equal(father.Id, photo.GerbilId);
|
||||||
|
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
|
||||||
|
public class LitterMortalityTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
|
||||||
|
? v.GetInt32() : null;
|
||||||
|
|
||||||
|
private async Task<JsonElement> CreateAndGet(object body)
|
||||||
|
{
|
||||||
|
var post = await _client.PostAsJsonAsync("/litters", body);
|
||||||
|
post.EnsureSuccessStatusCode();
|
||||||
|
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
|
||||||
|
.GetProperty("id").GetString()!;
|
||||||
|
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeathsWithin8Weeks_round_trips()
|
||||||
|
{
|
||||||
|
var el = await CreateAndGet(new
|
||||||
|
{
|
||||||
|
name = "Wurf Mortalitätstest",
|
||||||
|
date = "2026-05-01",
|
||||||
|
totalBorn = 6,
|
||||||
|
deathsWithin8Weeks = 2,
|
||||||
|
});
|
||||||
|
Assert.Equal(6, GetInt(el, "totalBorn"));
|
||||||
|
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeathsWithin8Weeks_defaults_null()
|
||||||
|
{
|
||||||
|
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
|
||||||
|
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
|
||||||
|
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||||
|
{
|
||||||
|
name = "Ungültig",
|
||||||
|
date = "2026-05-03",
|
||||||
|
totalBorn = 4,
|
||||||
|
deathsWithin8Weeks = 5,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("TotalBorn", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Negative_DeathsWithin8Weeks_returns_400()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||||
|
{
|
||||||
|
name = "Negativ",
|
||||||
|
date = "2026-05-04",
|
||||||
|
deathsWithin8Weeks = -1,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("negativ", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
|
||||||
|
{
|
||||||
|
var el = await CreateAndGet(new
|
||||||
|
{
|
||||||
|
name = "Kein TotalBorn",
|
||||||
|
date = "2026-05-05",
|
||||||
|
deathsWithin8Weeks = 3,
|
||||||
|
});
|
||||||
|
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
|
||||||
|
}
|
||||||
|
}
|
||||||
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||||
|
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||||
|
/// </summary>
|
||||||
|
public class NameSuggestionTests
|
||||||
|
{
|
||||||
|
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("reinen JSON-Array", prompt);
|
||||||
|
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||||
|
Assert.Contains("name", prompt);
|
||||||
|
Assert.Contains("meaning", prompt);
|
||||||
|
Assert.Contains("origin", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("thematischen Kategorien", prompt);
|
||||||
|
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||||
|
Assert.Contains("6", prompt);
|
||||||
|
Assert.Contains("\"A\"", prompt);
|
||||||
|
Assert.Contains("weibliche", prompt);
|
||||||
|
// codes are mapped to German descriptions
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Griechische Mythologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||||
|
Assert.Contains("5", prompt);
|
||||||
|
Assert.DoesNotContain("Buchstaben", prompt);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||||
|
Assert.Equal(expectedDescription, entry.Description);
|
||||||
|
Assert.True(entry.Thematic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||||
|
Assert.Contains(expectedDescription, prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
|
Assert.Contains("Disney-Charaktere", prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||||
|
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||||
|
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||||
|
{
|
||||||
|
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(json);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Astrid", result[0].Name);
|
||||||
|
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||||
|
Assert.Equal("Altnordisch", result[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Aiko", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Luna", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||||
|
{
|
||||||
|
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Sol", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, "key", "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", null, "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", "key", null)]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||||
|
string? baseUrl, string? apiKey, string? model)
|
||||||
|
{
|
||||||
|
var service = CreateService(baseUrl, apiKey, model,
|
||||||
|
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 5);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal(2, result.Suggestions!.Count);
|
||||||
|
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var handler = new StubHandler(_ => Canned(fenced));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NameSuggestionService CreateService(
|
||||||
|
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||||
|
{
|
||||||
|
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||||
|
return new NameSuggestionService(new HttpClient(handler), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Canned(string content)
|
||||||
|
{
|
||||||
|
var completion = new
|
||||||
|
{
|
||||||
|
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||||
|
};
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(respond(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,19 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||||
{
|
{
|
||||||
|
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||||
|
// when multiple tests in the same fixture share the SQLite connection.
|
||||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
gender = "female",
|
gender = "female",
|
||||||
genotype = "aa CC DD ee GG PP spsp rere",
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
originBreeder = "Zucht der Kleinen Chaoten",
|
originBreeder = "Zucht der kleinen Chaoten",
|
||||||
isDeaf = false,
|
isDeaf = false,
|
||||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
characterNote = "Liebling der Familie",
|
characterNote = "Liebling der Familie",
|
||||||
notes = "Eine Notiz",
|
notes = "Eine Notiz",
|
||||||
externalRef = "ext-001",
|
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||||
}));
|
}));
|
||||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
@@ -65,9 +67,9 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||||
Assert.Equal("female", GetStr(json, "gender"));
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -89,13 +91,13 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
Assert.Equal("Active", GetStr(json, "status"));
|
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||||
Assert.Equal("female", GetStr(json, "gender"));
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -121,8 +123,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
|
|
||||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -136,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
|
|
||||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||||
Assert.Equal("Active", GetStr(json, "status"));
|
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||||
// characterTraits defaults to [] on create when not provided
|
// characterTraits defaults to [] on create when not provided
|
||||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
|||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||||
{
|
{
|
||||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||||
new { name, gender = "female", status, originBreeder });
|
new { name, gender = "female", status, originBreeder });
|
||||||
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
|||||||
public async Task Combined_status_and_name_filter()
|
public async Task Combined_status_and_name_filter()
|
||||||
{
|
{
|
||||||
await Create("KombiVerkauf", status: "ForSale");
|
await Create("KombiVerkauf", status: "ForSale");
|
||||||
await Create("KombiAktiv", status: "Active");
|
await Create("KombiAktiv", status: "Breeding");
|
||||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||||
Assert.Contains("KombiVerkauf", hits);
|
Assert.Contains("KombiVerkauf", hits);
|
||||||
Assert.DoesNotContain("KombiAktiv", hits);
|
Assert.DoesNotContain("KombiAktiv", hits);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||||
|
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||||
@@ -49,6 +50,13 @@ public class ApplicationContext : DbContext
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||||
|
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||||
|
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||||
|
// support custom collation names and would fail EnsureCreated on the test host.
|
||||||
|
const string DeIcu = "de-x-icu";
|
||||||
|
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||||
|
|
||||||
modelBuilder.Entity<Gerbil>(e =>
|
modelBuilder.Entity<Gerbil>(e =>
|
||||||
{
|
{
|
||||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||||
@@ -58,6 +66,14 @@ public class ApplicationContext : DbContext
|
|||||||
// Residency defaults to true (own stock unless explicitly marked external).
|
// Residency defaults to true (own stock unless explicitly marked external).
|
||||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||||
|
|
||||||
|
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
@@ -81,6 +97,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(g => g.ColorVariety).WithMany()
|
e.HasOne(g => g.ColorVariety).WithMany()
|
||||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||||
|
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||||
|
e.HasIndex(g => g.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Litter>(e =>
|
modelBuilder.Entity<Litter>(e =>
|
||||||
@@ -89,6 +111,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasOne(l => l.Mother).WithMany()
|
e.HasOne(l => l.Mother).WithMany()
|
||||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// DB-5: ExternalRef = source litter id from extract.py.
|
||||||
|
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||||
|
e.HasIndex(l => l.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<HealthRecord>(e =>
|
modelBuilder.Entity<HealthRecord>(e =>
|
||||||
@@ -106,6 +134,10 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasOne<Gerbil>().WithMany()
|
e.HasOne<Gerbil>().WithMany()
|
||||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||||
|
|
||||||
|
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||||
|
e.HasOne<Enclosure>().WithMany()
|
||||||
|
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||||
|
|
||||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||||
modelBuilder.Entity<SaleContract>(e =>
|
modelBuilder.Entity<SaleContract>(e =>
|
||||||
{
|
{
|
||||||
@@ -170,6 +202,15 @@ public class ApplicationContext : DbContext
|
|||||||
modelBuilder.Entity<MailSettings>()
|
modelBuilder.Entity<MailSettings>()
|
||||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||||
|
|
||||||
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ColorVariety>()
|
||||||
|
.Property(v => v.Name).UseCollation(DeIcu);
|
||||||
|
modelBuilder.Entity<Contact>()
|
||||||
|
.Property(c => c.Name).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
SeedColorVarieties(modelBuilder);
|
SeedColorVarieties(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,79 +287,97 @@ public class ApplicationContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
|
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
|
||||||
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
|
// ID = array_index + 1 (stable, never changes — live FKs safe).
|
||||||
// SortOrder for new entries is appended; Kevin's frontend catalog handles
|
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
|
||||||
// the interleaved display order via its own sortOrder values.
|
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
|
||||||
(string Name, string Genotype)[] catalog =
|
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
|
||||||
|
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
|
||||||
|
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||||
{
|
{
|
||||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Zobel / colourpoint dark (ID 4) ---
|
||||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
|
||||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
// --- Schimmel family (IDs 5) ---
|
||||||
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
|
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
|
||||||
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
|
// --- Standard colours (IDs 6-15) ---
|
||||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
|
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
|
||||||
("Blau", "aa CC dd EE GG PP spsp rere"),
|
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
|
||||||
("Gold", "AA CC DD EE GG pp spsp rere"),
|
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
|
||||||
("Platin", "aa CC DD EE GG pp spsp rere"),
|
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
|
||||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
|
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
|
||||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
|
("Blau", "aa CC dd EE GG PP spsp rere", 10),
|
||||||
("dd Gold", "AA CC dd EE GG pp spsp rere"),
|
("Gold", "AA CC DD EE GG pp spsp rere", 11),
|
||||||
("dd Platin", "aa CC dd EE GG pp spsp rere"),
|
("Platin", "aa CC DD EE GG pp spsp rere", 12),
|
||||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
|
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
|
||||||
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
|
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
|
||||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
|
// --- Dilute (dd) standard (IDs 16-17) ---
|
||||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
|
||||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
|
||||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
|
||||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
|
||||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
|
||||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
|
||||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
|
||||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||||
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
|
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
|
||||||
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
|
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
|
||||||
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
|
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
|
||||||
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
|
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
|
||||||
|
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
|
||||||
|
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
|
||||||
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
|
||||||
|
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
|
||||||
|
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
|
||||||
|
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
|
||||||
|
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
|
||||||
|
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
|
||||||
|
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
|
||||||
|
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
|
||||||
|
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
|
||||||
|
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
|
||||||
|
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
|
||||||
|
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
|
||||||
|
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
|
||||||
|
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
|
||||||
};
|
};
|
||||||
|
|
||||||
var rows = new ColorVariety[catalog.Length];
|
var rows = new ColorVariety[catalog.Length];
|
||||||
@@ -326,11 +385,11 @@ public class ApplicationContext : DbContext
|
|||||||
{
|
{
|
||||||
rows[i] = new ColorVariety
|
rows[i] = new ColorVariety
|
||||||
{
|
{
|
||||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||||
Name = catalog[i].Name,
|
Name = catalog[i].Name,
|
||||||
CanonicalGenotype = catalog[i].Genotype,
|
CanonicalGenotype = catalog[i].Genotype,
|
||||||
SortOrder = i,
|
SortOrder = catalog[i].SortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||||
|
|||||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Cms
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||||
|
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||||
|
/// In production this volume is shared with the publicsite-nginx container.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublicSiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PublicSite";
|
||||||
|
|
||||||
|
public string? RootPath { get; set; }
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
|
|||||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||||
|
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
|
||||||
|
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
|
||||||
public sealed record ContractAnimal(
|
public sealed record ContractAnimal(
|
||||||
string Name,
|
string Name,
|
||||||
string Geschlecht,
|
string Geschlecht,
|
||||||
DateOnly? Geburtsdatum = null,
|
DateOnly? Geburtsdatum = null,
|
||||||
string? Farbschlag = null);
|
string? Farbschlag = null,
|
||||||
|
byte[]? Photo = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||||
|
|||||||
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using QuestPDF.Fluent;
|
||||||
|
using QuestPDF.Helpers;
|
||||||
|
using QuestPDF.Infrastructure;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
|
||||||
|
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
|
||||||
|
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
|
||||||
|
/// 4–8 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
|
||||||
|
/// </summary>
|
||||||
|
public static class ContractPdfGenerator
|
||||||
|
{
|
||||||
|
static ContractPdfGenerator()
|
||||||
|
{
|
||||||
|
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
private static string Price(decimal p) => p.ToString("N2", German) + " €";
|
||||||
|
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
|
||||||
|
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
|
||||||
|
|
||||||
|
private const string Accent = "#A85F2E";
|
||||||
|
private const string Ink = "#3D2E23";
|
||||||
|
private const string Line = "#D9CCB4";
|
||||||
|
|
||||||
|
public static byte[] Generate(ContractData data)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
|
|
||||||
|
return Document.Create(doc =>
|
||||||
|
{
|
||||||
|
doc.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.A4);
|
||||||
|
page.Margin(1.8f, Unit.Centimetre);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
|
||||||
|
|
||||||
|
page.Header().PaddingBottom(8).Column(h =>
|
||||||
|
{
|
||||||
|
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
|
||||||
|
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
|
||||||
|
});
|
||||||
|
|
||||||
|
page.Content().PaddingTop(6).Column(col =>
|
||||||
|
{
|
||||||
|
col.Spacing(12);
|
||||||
|
Parties(col, data);
|
||||||
|
Animals(col, data);
|
||||||
|
Subject(col, data);
|
||||||
|
Clauses(col);
|
||||||
|
Signatures(col, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(t =>
|
||||||
|
{
|
||||||
|
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
|
||||||
|
t.Span("Seite ");
|
||||||
|
t.CurrentPageNumber();
|
||||||
|
t.Span(" / ");
|
||||||
|
t.TotalPages();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).GeneratePdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Heading(ColumnDescriptor col, string text) =>
|
||||||
|
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
|
||||||
|
|
||||||
|
private static void Kv(ColumnDescriptor col, string label, string value) =>
|
||||||
|
col.Item().Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
|
||||||
|
r.RelativeItem().Text(value).SemiBold();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void Bullet(ColumnDescriptor col, string text) =>
|
||||||
|
col.Item().Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(14).Text("•").FontColor(Accent);
|
||||||
|
r.RelativeItem().Text(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void Parties(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
var s = data.Seller;
|
||||||
|
var b = data.Buyer;
|
||||||
|
Heading(col, "1. Vertragspartner");
|
||||||
|
col.Item().Text("Verkäufer / Züchter").SemiBold();
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(2);
|
||||||
|
Kv(c, "Zuchtname:", Or(s.ZuchtName));
|
||||||
|
Kv(c, "Vor- und Nachname:", Or(s.Name));
|
||||||
|
Kv(c, "Adresse:", Or(s.Address));
|
||||||
|
Kv(c, "Telefon:", Or(s.Phone, ""));
|
||||||
|
Kv(c, "E-Mail:", Or(s.Email, ""));
|
||||||
|
Kv(c, "Homepage:", Or(s.Homepage, ""));
|
||||||
|
});
|
||||||
|
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(2);
|
||||||
|
Kv(c, "Vor- und Nachname:", Or(b.Name));
|
||||||
|
Kv(c, "Adresse:", Or(b.Address));
|
||||||
|
Kv(c, "Telefon:", Or(b.Phone, ""));
|
||||||
|
Kv(c, "E-Mail:", Or(b.Email, ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Animals(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
Heading(col, "2. Angaben zu den Tieren");
|
||||||
|
col.Item().Column(list =>
|
||||||
|
{
|
||||||
|
list.Spacing(10);
|
||||||
|
foreach (var a in data.Animals)
|
||||||
|
{
|
||||||
|
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
|
||||||
|
// wie im Mustervertrag (das große Rechteck links).
|
||||||
|
list.Item().Border(1).BorderColor(Line).Row(row =>
|
||||||
|
{
|
||||||
|
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
|
||||||
|
.Padding(4).Element(box =>
|
||||||
|
{
|
||||||
|
if (a.Photo is { Length: > 0 } bytes)
|
||||||
|
box.AlignMiddle().Image(bytes).FitArea();
|
||||||
|
else
|
||||||
|
box.AlignMiddle().AlignCenter().Text("Kein Foto")
|
||||||
|
.FontSize(8).FontColor("#B8AC97");
|
||||||
|
});
|
||||||
|
|
||||||
|
row.RelativeItem(3f).Column(kv =>
|
||||||
|
{
|
||||||
|
AnimalRow(kv, "Name:", Or(a.Name, ""));
|
||||||
|
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
|
||||||
|
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
|
||||||
|
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
|
||||||
|
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
|
||||||
|
{
|
||||||
|
var cell = col.Item();
|
||||||
|
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
|
||||||
|
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
|
||||||
|
r.RelativeItem().Text(value).SemiBold();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Subject(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
Heading(col, "3. Gegenstand des Vertrages");
|
||||||
|
col.Item().Text(
|
||||||
|
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
|
||||||
|
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
|
||||||
|
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
|
||||||
|
$"von dem Züchter übergeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Clauses(ColumnDescriptor col)
|
||||||
|
{
|
||||||
|
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
|
||||||
|
col.Item().Text(
|
||||||
|
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
|
||||||
|
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
|
||||||
|
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
|
||||||
|
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
|
||||||
|
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
|
||||||
|
|
||||||
|
Heading(col, "5. Pflichten des Käufers");
|
||||||
|
col.Item().Text(
|
||||||
|
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
|
||||||
|
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
|
||||||
|
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
|
||||||
|
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
|
||||||
|
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
|
||||||
|
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
|
||||||
|
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
|
||||||
|
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "6. Rechte des Käufers");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
|
||||||
|
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
|
||||||
|
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "7. Haftung / Zuwiderhandlung");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
|
||||||
|
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
|
||||||
|
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
|
||||||
|
col.Item().Text(
|
||||||
|
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
|
||||||
|
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
|
||||||
|
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
|
||||||
|
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
|
||||||
|
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Signatures(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
|
||||||
|
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
|
||||||
|
col.Item().PaddingTop(28).Row(r =>
|
||||||
|
{
|
||||||
|
r.RelativeItem().Column(c =>
|
||||||
|
{
|
||||||
|
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
|
||||||
|
});
|
||||||
|
r.ConstantItem(40);
|
||||||
|
r.RelativeItem().Column(c =>
|
||||||
|
{
|
||||||
|
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? CauseOfDeath,
|
string? CauseOfDeath,
|
||||||
DateOnly? GoHomeDate,
|
DateOnly? GoHomeDate,
|
||||||
string? Genotype,
|
string? Genotype,
|
||||||
|
string? SpottingType,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
@@ -29,19 +30,22 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
List<string> CharacterTraits,
|
List<string> CharacterTraits,
|
||||||
string? CharacterNote,
|
string? CharacterNote,
|
||||||
bool? IsDeaf,
|
bool? IsDeaf,
|
||||||
bool IsResident);
|
bool IsResident,
|
||||||
|
string? ProfilePhotoUrl,
|
||||||
|
bool IsCastrated);
|
||||||
|
|
||||||
public record LitterDto(
|
public record LitterDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
DateOnly Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
|
int? DeathsWithin8Weeks,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes);
|
||||||
|
|
||||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
|
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||||
|
|
||||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? CauseOfDeath,
|
string? CauseOfDeath,
|
||||||
DateOnly? GoHomeDate,
|
DateOnly? GoHomeDate,
|
||||||
string? Genotype,
|
string? Genotype,
|
||||||
|
string? SpottingType,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
@@ -79,18 +84,20 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
List<string>? CharacterTraits,
|
List<string>? CharacterTraits,
|
||||||
string? CharacterNote,
|
string? CharacterNote,
|
||||||
bool? IsDeaf,
|
bool? IsDeaf,
|
||||||
bool? IsResident);
|
bool? IsResident,
|
||||||
|
bool? IsCastrated);
|
||||||
|
|
||||||
public record LitterInput(
|
public record LitterInput(
|
||||||
string Name,
|
string Name,
|
||||||
DateOnly Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
|
int? DeathsWithin8Weeks,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes);
|
||||||
|
|
||||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
|
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||||
|
|
||||||
public record EnclosureInput(string Name, string? Notes);
|
public record EnclosureInput(string Name, string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
List<Guid> GerbilIds,
|
List<Guid> GerbilIds,
|
||||||
decimal Price,
|
decimal Price,
|
||||||
DateOnly HandoverDate,
|
DateOnly HandoverDate,
|
||||||
DateOnly? ContractDate);
|
DateOnly? ContractDate,
|
||||||
|
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
|
||||||
|
Dictionary<Guid, Guid>? AnimalPhotos = null);
|
||||||
|
|
||||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||||
public record BreederProfileDto(
|
public record BreederProfileDto(
|
||||||
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string Phone,
|
string Phone,
|
||||||
string Email,
|
string Email,
|
||||||
string Homepage,
|
string Homepage,
|
||||||
string City);
|
string City,
|
||||||
|
string NameSuffix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using GerbilManagerWebAPI.Cms;
|
using GerbilManagerWebAPI.Cms;
|
||||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
{
|
{
|
||||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||||
|
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||||
|
{
|
||||||
|
if (!opts.Value.IsConfigured)
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||||
|
statusCode: 503,
|
||||||
|
title: "PublicSite nicht konfiguriert");
|
||||||
|
|
||||||
|
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||||
|
var files = SiteRenderer.Render(snapshot);
|
||||||
|
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||||
|
return Results.Ok(new { filesPublished = files.Count });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||||
|
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task PublishToDirectoryAsync(
|
||||||
|
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||||
|
{
|
||||||
|
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||||
|
var liveDir = Path.Combine(rootPath, "live");
|
||||||
|
var oldDir = Path.Combine(rootPath, "_old");
|
||||||
|
|
||||||
|
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||||
|
Directory.CreateDirectory(stagingDir);
|
||||||
|
|
||||||
|
foreach (var (relativePath, content) in files)
|
||||||
|
{
|
||||||
|
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap: _staging_new → live
|
||||||
|
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||||
|
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||||
|
Directory.Move(stagingDir, liveDir);
|
||||||
|
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||||
|
catch { /* non-fatal — old dir gone on next publish */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||||
private static string PreviewContentType(string path) =>
|
private static string PreviewContentType(string path) =>
|
||||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
|
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||||
db.Contacts.Add(c);
|
db.Contacts.Add(c);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||||
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (c is null) return TypedResults.NotFound();
|
if (c is null) return TypedResults.NotFound();
|
||||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||||
|
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
|
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
|||||||
using GerbilManagerWebAPI.Contracts;
|
using GerbilManagerWebAPI.Contracts;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
private const string DocxContentType =
|
private const string DocxContentType =
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
private const string PdfContentType = "application/pdf";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
@@ -49,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var errors = new Dictionary<string, string[]>();
|
var errors = new Dictionary<string, string[]>();
|
||||||
|
|
||||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||||
|
var photoChoice = input.AnimalPhotos ?? [];
|
||||||
if (gerbilIds.Count == 0)
|
if (gerbilIds.Count == 0)
|
||||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||||
if (input.Price < 0)
|
if (input.Price < 0)
|
||||||
@@ -99,16 +102,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
ContractDate = contractDate,
|
ContractDate = contractDate,
|
||||||
FileName = fileName,
|
FileName = fileName,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
Animals = gerbilIds.Select(id => new SaleContractAnimal
|
||||||
|
{
|
||||||
|
GerbilId = id,
|
||||||
|
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
||||||
|
}).ToList(),
|
||||||
};
|
};
|
||||||
db.SaleContracts.Add(entity);
|
db.SaleContracts.Add(entity);
|
||||||
|
|
||||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
foreach (var g in gerbils)
|
foreach (var g in gerbils)
|
||||||
{
|
{
|
||||||
g.ReceiverContactId = contact.Id;
|
g.ReceiverContactId = contact.Id;
|
||||||
g.GoHomeDate = input.HandoverDate;
|
g.GoHomeDate = input.HandoverDate;
|
||||||
g.Status = GerbilStatus.GivenAway;
|
GerbilStatusService.Apply(g, today);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -142,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
||||||
|
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var c = await db.SaleContracts.AsNoTracking()
|
||||||
|
.Include(x => x.Contact)
|
||||||
|
.Include(x => x.Animals)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
||||||
|
var gerbils = await db.Gerbils.AsNoTracking()
|
||||||
|
.Include(g => g.ColorVariety)
|
||||||
|
.Where(g => gerbilIds.Contains(g.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
var settings = await db.BreederSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||||
|
?? new BreederSettings();
|
||||||
|
|
||||||
|
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
||||||
|
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
||||||
|
var photoByGerbil = c.Animals
|
||||||
|
.Where(a => a.PhotoId is not null)
|
||||||
|
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
||||||
|
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
||||||
|
|
||||||
|
var data = new ContractData(
|
||||||
|
Seller: ToSeller(settings),
|
||||||
|
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
||||||
|
Animals: gerbils
|
||||||
|
.Select(g => new ContractAnimal(
|
||||||
|
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
||||||
|
photoBytes.GetValueOrDefault(g.Id)))
|
||||||
|
.ToList(),
|
||||||
|
Price: c.Price,
|
||||||
|
HandoverDate: c.HandoverDate,
|
||||||
|
ContractDate: c.ContractDate);
|
||||||
|
|
||||||
|
var pdf = ContractPdfGenerator.Generate(data);
|
||||||
|
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
||||||
|
return TypedResults.File(pdf, PdfContentType, download);
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
@@ -164,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||||
|
|
||||||
|
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
||||||
|
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||||
|
|
||||||
|
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
||||||
|
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
||||||
|
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
||||||
|
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, byte[]>();
|
||||||
|
if (photoByGerbil.Count == 0) return result;
|
||||||
|
|
||||||
|
var photoIds = photoByGerbil.Values.ToList();
|
||||||
|
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||||
|
.Where(p => photoIds.Contains(p.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var (gerbilId, photoId) in photoByGerbil)
|
||||||
|
{
|
||||||
|
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
||||||
|
if (photo is null) continue;
|
||||||
|
var path = Path.Combine(photoRoot, photo.FileName);
|
||||||
|
if (File.Exists(path))
|
||||||
|
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||||
{
|
{
|
||||||
ZuchtName = s.ZuchtName,
|
ZuchtName = s.ZuchtName,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using GerbilManagerWebAPI.Common;
|
using GerbilManagerWebAPI.Common;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Gridify;
|
using Gridify;
|
||||||
|
using Gridify.EntityFramework;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
{
|
||||||
.ToPagedResultAsync(query, ToDto)));
|
var q = query.ToQuery();
|
||||||
|
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
||||||
|
var ids = paging.Data.Select(g => g.Id).ToList();
|
||||||
|
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
||||||
|
await db.GerbilPhotos.AsNoTracking()
|
||||||
|
.Where(p => ids.Contains(p.GerbilId))
|
||||||
|
.GroupBy(p => p.GerbilId)
|
||||||
|
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
||||||
|
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
||||||
|
var items = paging.Data
|
||||||
|
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
||||||
|
.ToList();
|
||||||
|
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
||||||
|
});
|
||||||
|
|
||||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||||
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
if (g is null) return TypedResults.NotFound();
|
||||||
|
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
||||||
|
.Where(p => p.GerbilId == id)
|
||||||
|
.OrderBy(p => p.SortOrder)
|
||||||
|
.Select(p => p.FileName)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
||||||
|
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /gerbils
|
// POST /gerbils
|
||||||
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||||
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||||
g.LitterId = i.LitterId ?? g.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
@@ -97,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype ?? g.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
|
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||||
g.Notes = i.Notes ?? g.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
@@ -105,12 +129,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
|
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||||
|
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
public static class ImportDocxEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||||
|
|
||||||
|
// POST /import/docx/dry-run — analyse without writing
|
||||||
|
group.MapPost("/dry-run",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||||
|
|
||||||
|
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||||
|
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||||
|
group.MapPost("/execute",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(report);
|
return TypedResults.Ok(report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||||
|
return TypedResults.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||||
if (err is not null) return TypedResults.BadRequest(err);
|
if (err is not null) return TypedResults.BadRequest(err);
|
||||||
|
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||||
|
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||||
|
|
||||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||||
Apply(l, input);
|
Apply(l, input);
|
||||||
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (l is null) return TypedResults.NotFound();
|
if (l is null) return TypedResults.NotFound();
|
||||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||||
if (err is not null) return TypedResults.BadRequest(err);
|
if (err is not null) return TypedResults.BadRequest(err);
|
||||||
|
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||||
|
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||||
l.Name = input.Name;
|
l.Name = input.Name;
|
||||||
l.Date = input.Date;
|
l.Date = input.Date;
|
||||||
Apply(l, input);
|
Apply(l, input);
|
||||||
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
private static void Apply(Litter l, LitterInput i)
|
private static void Apply(Litter l, LitterInput i)
|
||||||
{
|
{
|
||||||
l.TotalBorn = i.TotalBorn;
|
l.TotalBorn = i.TotalBorn;
|
||||||
|
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||||
l.FatherId = i.FatherId;
|
l.FatherId = i.FatherId;
|
||||||
l.MotherId = i.MotherId;
|
l.MotherId = i.MotherId;
|
||||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||||
l.Notes = i.Notes;
|
l.Notes = i.Notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||||
|
{
|
||||||
|
if (deaths is null) return null;
|
||||||
|
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
|
||||||
|
if (totalBorn is not null && deaths > totalBorn)
|
||||||
|
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static LitterDto ToDto(Litter l) => new(
|
private static LitterDto ToDto(Litter l) => new(
|
||||||
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||||
|
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||||
|
|||||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||||
|
///
|
||||||
|
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||||
|
/// frontend maps exactly this code to its German disabled-state hint.
|
||||||
|
/// </summary>
|
||||||
|
public static class NamesEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/names/suggest", async (
|
||||||
|
string? letter,
|
||||||
|
string? gender,
|
||||||
|
string? usages,
|
||||||
|
int count,
|
||||||
|
NameSuggestionService service,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||||
|
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||||
|
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||||
|
_ => Results.Json(
|
||||||
|
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.WithTags("Names");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
}).WithTags("Photos");
|
}).WithTags("Photos");
|
||||||
|
|
||||||
|
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||||
|
app.MapGet("/enclosures/{id:guid}/photos",
|
||||||
|
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||||
|
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||||
|
.Where(p => p.EnclosureId == id)
|
||||||
|
.OrderBy(p => p.SortOrder)
|
||||||
|
.ToListAsync();
|
||||||
|
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||||
|
}).WithTags("Photos");
|
||||||
|
|
||||||
|
app.MapPost("/enclosures/{id:guid}/photos",
|
||||||
|
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||||
|
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||||
|
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName);
|
||||||
|
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||||
|
var root = PhotoRoot(config, env);
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
|
||||||
|
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||||
|
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||||
|
|
||||||
|
var photo = new EnclosurePhoto
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EnclosureId = id,
|
||||||
|
FileName = fileName,
|
||||||
|
Caption = caption,
|
||||||
|
SortOrder = nextSort,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.EnclosurePhotos.Add(photo);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||||
|
}).WithTags("Photos").DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||||
|
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
if (photo is null) return TypedResults.NotFound();
|
||||||
|
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
db.EnclosurePhotos.Remove(photo);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}).WithTags("Photos");
|
||||||
|
|
||||||
app.MapGet("/photos/files/{fileName}",
|
app.MapGet("/photos/files/{fileName}",
|
||||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
{
|
{
|
||||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||||
|
|
||||||
|
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||||
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
s.Email = input.Email ?? "";
|
s.Email = input.Email ?? "";
|
||||||
s.Homepage = input.Homepage ?? "";
|
s.Homepage = input.Homepage ?? "";
|
||||||
s.City = input.City ?? "";
|
s.City = input.City ?? "";
|
||||||
|
s.NameSuffix = input.NameSuffix ?? "";
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
|||||||
|
|
||||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||||
{
|
{
|
||||||
[GerbilStatus.Active] = "Aktiv",
|
[GerbilStatus.Breeding] = "Zucht",
|
||||||
[GerbilStatus.Deceased] = "Verstorben",
|
[GerbilStatus.Deceased] = "Verstorben",
|
||||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||||
|
[GerbilStatus.ForSale] = "Abzugeben",
|
||||||
|
[GerbilStatus.Pet] = "Liebhaber",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
<ItemGroup>
|
||||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
</PackageReference>
|
||||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
</ItemGroup>
|
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
<ItemGroup>
|
</ItemGroup>
|
||||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
|
||||||
</ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||||
<ItemGroup>
|
</ItemGroup>
|
||||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
|
||||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||||
|
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||||
<ItemGroup>
|
</ItemGroup>
|
||||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
|
||||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||||
|
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||||
</Project>
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|||||||
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||||
|
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
|
||||||
|
///
|
||||||
|
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||||
|
/// Match order per docx row:
|
||||||
|
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||||
|
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||||
|
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||||
|
///
|
||||||
|
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||||
|
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||||
|
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-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 via CreateExecutionStrategy() so that
|
||||||
|
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||||
|
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||||
|
/// to re-run on transient-failure retry.
|
||||||
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportDocxService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||||
|
: this(db,
|
||||||
|
config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_sourceDir = sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||||
|
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||||
|
|
||||||
|
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
|
||||||
|
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
|
||||||
|
|
||||||
|
int docxLitCount = docxLitters.Count;
|
||||||
|
int docxAnimCount = docxAnimals.Count;
|
||||||
|
int pdfLitCount = pdfLitters.Count;
|
||||||
|
int pdfAnimCount = pdfAnimals.Count;
|
||||||
|
|
||||||
|
docxLitters.AddRange(pdfLitters);
|
||||||
|
docxAnimals.AddRange(pdfAnimals);
|
||||||
|
|
||||||
|
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||||
|
{
|
||||||
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
|
||||||
|
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
|
||||||
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
.Select(l => new { l.Id, l.Date })
|
||||||
|
.ToListAsync();
|
||||||
|
var littersByDayNumber = littersInDb
|
||||||
|
.Where(l => l.Date.HasValue)
|
||||||
|
.GroupBy(l => l.Date!.Value.DayNumber)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||||
|
var gerbilsInDb = await _db.Gerbils
|
||||||
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||||
|
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
|
||||||
|
g.ExternalRef })
|
||||||
|
.ToListAsync();
|
||||||
|
var gerbilByKey = gerbilsInDb
|
||||||
|
.Where(g => g.DateOfBirth is not null)
|
||||||
|
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
|
||||||
|
var docxExternalRefs = gerbilsInDb
|
||||||
|
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
|
||||||
|
.ToDictionary(g => g.ExternalRef!,
|
||||||
|
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
|
||||||
|
|
||||||
|
// Contact lookup: normalized name → existing Contact.Id
|
||||||
|
var contactsInDb = await _db.Contacts
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
var contactByNorm = contactsInDb
|
||||||
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
// Snapshot of DB contacts before any writes.
|
||||||
|
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||||
|
// but would remain in the in-memory dict without this reset).
|
||||||
|
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||||
|
var externalRefOrdinals = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
|
// Inner loop — shared by dry-run and execute paths.
|
||||||
|
// All local variables above are captured by reference (C# closure), so the strategy
|
||||||
|
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||||
|
async Task RunLoopAsync()
|
||||||
|
{
|
||||||
|
foreach (var da in docxAnimals)
|
||||||
|
{
|
||||||
|
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 litterDob = ParseDate(da.LitterDob);
|
||||||
|
if (litterDob is not null)
|
||||||
|
{
|
||||||
|
var candidates = new List<Guid>();
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (animalDob is null) { skipped++; continue; }
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
|
||||||
|
var nameKey = NameDobKey(da.Name, animalDob.Value);
|
||||||
|
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
|
||||||
|
{
|
||||||
|
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||||
|
?? gerbilCands.First();
|
||||||
|
|
||||||
|
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
|
||||||
|
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||||
|
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||||
|
// 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 = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Dry-run: just count, no writes, no transaction needed.
|
||||||
|
await RunLoopAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||||
|
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||||
|
// The lambda resets all mutable state at the top so retries start clean.
|
||||||
|
var strategy = _db.Database.CreateExecutionStrategy();
|
||||||
|
await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Reset mutable state — idempotent on strategy retry
|
||||||
|
_db.ChangeTracker.Clear();
|
||||||
|
externalRefOrdinals.Clear();
|
||||||
|
batchRefs.Clear();
|
||||||
|
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||||
|
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||||
|
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||||
|
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
await RunLoopAsync();
|
||||||
|
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
|
||||||
|
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($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
|
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||||
|
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
|
||||||
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||||
|
|
||||||
|
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
|
||||||
|
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Load<T>(string file)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_sourceDir, file);
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||||
|
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||||
|
if (!m.Success) return null;
|
||||||
|
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||||
|
int y = int.Parse(m.Groups[3].Value);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameDobKey(string name, DateOnly dob)
|
||||||
|
{
|
||||||
|
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"{n}|{dob:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
var n = (name ?? "").ToLowerInvariant();
|
||||||
|
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
|
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) ----
|
||||||
|
|
||||||
|
public sealed class DocxLitter
|
||||||
|
{
|
||||||
|
public string LitterId { get; set; } = "";
|
||||||
|
public string Dob { get; set; } = "";
|
||||||
|
public string MotherName { get; set; } = "";
|
||||||
|
public string FatherName { get; set; } = "";
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DocxAnimal
|
||||||
|
{
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string LitterDob { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Farbschlag { get; set; } = "";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
public string AbgabeDate { get; set; } = "";
|
||||||
|
public string AbgabeWeight { get; set; } = "";
|
||||||
|
public string DeathDate { get; set; } = "";
|
||||||
|
public string DeathCause { get; set; } = "";
|
||||||
|
public string PartnerName { get; set; } = "";
|
||||||
|
public string PartnerDob { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report ----
|
||||||
|
|
||||||
|
public sealed record ImportDocxReport(
|
||||||
|
bool Executed,
|
||||||
|
int Created,
|
||||||
|
int LitterLinked,
|
||||||
|
int OwnerLinked,
|
||||||
|
int GoHomeFilled,
|
||||||
|
int DeathFilled,
|
||||||
|
int ContactsCreated,
|
||||||
|
int Skipped,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
}
|
||||||
@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int AlreadyImported,
|
int AlreadyImported,
|
||||||
QuarantineSummary Quarantined,
|
QuarantineSummary Quarantined,
|
||||||
int ParentLinksFromChart = 0,
|
int ParentLinksFromChart = 0,
|
||||||
int ConflictsResolvedByDecision = 0);
|
int ConflictsResolvedByDecision = 0,
|
||||||
|
int FarbschlagDerivedFromGenotype = 0);
|
||||||
|
|
||||||
public sealed record QuarantineSummary(
|
public sealed record QuarantineSummary(
|
||||||
int Conflicts,
|
int Conflicts,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Import
|
namespace GerbilManagerWebAPI.Import
|
||||||
@@ -71,17 +72,21 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
else if (conf == "niedrig") dateOnly++;
|
else if (conf == "niedrig") dateOnly++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
// existing rows (idempotency).
|
||||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
|
||||||
var existingGerbilExtRefs = await _db.Gerbils
|
var existingGerbilExtRefs = await _db.Gerbils
|
||||||
.Where(g => g.ExternalRef != null)
|
.Where(g => g.ExternalRef != null)
|
||||||
.Select(g => g.ExternalRef!).ToListAsync();
|
.Select(g => g.ExternalRef!).ToListAsync();
|
||||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||||
|
|
||||||
var existingLitterKeys = await _db.Litters
|
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||||
var existingLitterKeySet = existingLitterKeys
|
var existingLitterData = await _db.Litters
|
||||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||||
|
var existingLitterKeySet = existingLitterData
|
||||||
|
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
|
||||||
|
var existingLitterExtRefSet = existingLitterData
|
||||||
|
.Where(x => x.ExternalRef != null)
|
||||||
|
.Select(x => x.ExternalRef!).ToHashSet();
|
||||||
|
|
||||||
// colour-variety name -> id (case-insensitive)
|
// colour-variety name -> id (case-insensitive)
|
||||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||||
@@ -94,10 +99,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
|
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||||
|
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
// so skip them early — they can never be created or linked to animals.
|
||||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
|
||||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
|
||||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
@@ -107,7 +112,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||||
|
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||||
|
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||||
|
{ littersExisting++; continue; }
|
||||||
|
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
@@ -122,6 +130,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
|
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8)
|
if (samples.Count < 8)
|
||||||
@@ -145,25 +154,63 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||||
|
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||||
|
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||||
|
|
||||||
|
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||||
|
var varietiesWithGeno = await _db.ColorVarieties
|
||||||
|
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||||
|
|
||||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
|
int fbDerivedFromGenotype = 0;
|
||||||
foreach (var a in loadable)
|
foreach (var a in loadable)
|
||||||
{
|
{
|
||||||
bool exists = existingGerbilSet.Contains(a.Id);
|
bool exists = existingGerbilSet.Contains(a.Id);
|
||||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||||
|
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||||
|
Guid gid;
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||||
|
{
|
||||||
|
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||||
|
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else gid = Guid.NewGuid();
|
||||||
|
|
||||||
Guid? wurfLitterId = null;
|
Guid? wurfLitterId = null;
|
||||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||||
wurfLitterId = lid;
|
wurfLitterId = lid;
|
||||||
|
|
||||||
|
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||||
|
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||||
Guid? colorVarietyId = null;
|
Guid? colorVarietyId = null;
|
||||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||||
foreach (var fb in fbCandidates)
|
foreach (var fb in fbCandidates)
|
||||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||||
{ colorVarietyId = vid; break; }
|
{ colorVarietyId = vid; break; }
|
||||||
|
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||||
|
{
|
||||||
|
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||||
|
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||||
|
// would match any catalog entry via wildcards and produce false positives.
|
||||||
|
var composed = ComposeGenotype(a.Genotype);
|
||||||
|
if (!composed.Contains("??"))
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||||
|
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||||
|
}
|
||||||
|
|
||||||
var gender = InferGender(a, sireNames, damNames);
|
var gender = InferGender(a, sireNames, damNames);
|
||||||
var norm = Normalize(StripZucht(a.Name));
|
var norm = Normalize(StripZucht(a.Name));
|
||||||
@@ -243,10 +290,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.
|
||||||
@@ -255,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||||
foreach (var l in existingLitterRows)
|
foreach (var l in existingLitterRows)
|
||||||
{
|
{
|
||||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
|
||||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var sl in synthLitters.Values.ToList())
|
foreach (var sl in synthLitters.Values.ToList())
|
||||||
{
|
{
|
||||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
|
||||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||||
{
|
{
|
||||||
// remap offspring to the existing litter; don't create a duplicate.
|
// remap offspring to the existing litter; don't create a duplicate.
|
||||||
@@ -270,13 +322,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.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||||
Date = sl.Date ?? default,
|
Date = sl.Date,
|
||||||
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})",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -335,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Gerbils.Add(new Gerbil
|
var importedGerbil = new Gerbil
|
||||||
{
|
{
|
||||||
Id = p.Gid,
|
Id = p.Gid,
|
||||||
Name = p.A.Name,
|
Name = p.A.Name,
|
||||||
Gender = p.Gender,
|
Gender = p.Gender,
|
||||||
Status = GerbilStatus.Active,
|
Status = GerbilStatus.Breeding,
|
||||||
DateOfBirth = ParseDate(p.A.Dob),
|
DateOfBirth = ParseDate(p.A.Dob),
|
||||||
DateOfDeath = ParseDate(p.A.Death),
|
DateOfDeath = ParseDate(p.A.Death),
|
||||||
LitterId = litterId,
|
LitterId = litterId,
|
||||||
@@ -364,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
p.A.SourceFiles,
|
p.A.SourceFiles,
|
||||||
FarbschlagRaw = p.A.Farbschlag,
|
FarbschlagRaw = p.A.Farbschlag,
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
_db.Gerbils.Add(importedGerbil);
|
||||||
}
|
}
|
||||||
|
|
||||||
// photos
|
// photos
|
||||||
@@ -391,6 +449,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)
|
||||||
{
|
{
|
||||||
@@ -469,10 +541,37 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||||
|
|
||||||
|
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||||
|
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||||
|
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||||
|
{
|
||||||
|
var noColor = await _db.Gerbils
|
||||||
|
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||||
|
.Select(g => new { g.Id, g.Genotype })
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var g in noColor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||||
|
Guid? derivedVid = null;
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||||
|
{ derivedVid = v.Id; break; }
|
||||||
|
if (derivedVid is null) continue;
|
||||||
|
fbDerivedFromGenotype++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||||
int herkunftBackfilled = 0;
|
int herkunftBackfilled = 0;
|
||||||
{
|
{
|
||||||
var needsHerkunft = await _db.Gerbils
|
var needsHerkunft = await _db.Gerbils
|
||||||
@@ -516,14 +615,16 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||||
if (parentFksBackfilled > 0)
|
if (parentFksBackfilled > 0)
|
||||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
if (herkunftBackfilled > 0)
|
if (herkunftBackfilled > 0)
|
||||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||||
if (farbschlagWouldRebackfill > 0)
|
if (farbschlagWouldRebackfill > 0)
|
||||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
|
if (fbDerivedFromGenotype > 0)
|
||||||
|
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
@@ -532,7 +633,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
parentLinksAdded, conflictsResolvedByDecision),
|
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
@@ -570,6 +671,23 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||||
|
|
||||||
|
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||||
|
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||||
|
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||||
|
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||||
|
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||||
|
{
|
||||||
|
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (a.Length < 8 || c.Length < 8) return false;
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if (a[i] == "??" || c[i] == "??") continue;
|
||||||
|
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||||
{
|
{
|
||||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||||
|
|||||||
260
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
260
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
public sealed class IngestResolvedService
|
||||||
|
{
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _resolvedJsonPath;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
private readonly string _photoRoot;
|
||||||
|
|
||||||
|
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
|
_sourceDir = config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||||
|
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||||
|
_photoRoot = config["Photos:RootPath"]
|
||||||
|
?? Path.Combine(contentRoot, "photo-storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> RunAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_resolvedJsonPath))
|
||||||
|
{
|
||||||
|
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
|
||||||
|
}
|
||||||
|
|
||||||
|
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||||
|
var jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||||
|
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return "Error: Failed to deserialize resolved_import.json.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
||||||
|
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||||
|
{
|
||||||
|
// Break circular references first
|
||||||
|
foreach (var l in _db.Litters)
|
||||||
|
{
|
||||||
|
l.FatherId = null;
|
||||||
|
l.MotherId = null;
|
||||||
|
}
|
||||||
|
foreach (var g in _db.Gerbils)
|
||||||
|
{
|
||||||
|
g.LitterId = null;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||||
|
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||||
|
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||||
|
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||||
|
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||||
|
_db.Litters.RemoveRange(_db.Litters);
|
||||||
|
_db.Contacts.RemoveRange(_db.Contacts);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Break circular references first using ExecuteUpdateAsync
|
||||||
|
await _db.Litters.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(l => l.FatherId, (Guid?)null)
|
||||||
|
.SetProperty(l => l.MotherId, (Guid?)null));
|
||||||
|
|
||||||
|
await _db.Gerbils.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(g => g.LitterId, (Guid?)null));
|
||||||
|
|
||||||
|
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||||
|
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||||
|
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||||
|
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||||
|
await _db.Gerbils.ExecuteDeleteAsync();
|
||||||
|
await _db.Litters.ExecuteDeleteAsync();
|
||||||
|
await _db.Contacts.ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||||
|
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||||
|
var addedContactIds = new HashSet<Guid>();
|
||||||
|
int contactsAdded = 0;
|
||||||
|
int contactsUpdated = 0;
|
||||||
|
foreach (var c in data.Contacts)
|
||||||
|
{
|
||||||
|
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||||
|
{
|
||||||
|
existingContact.Name = c.Name;
|
||||||
|
existingContact.Email = c.Email;
|
||||||
|
existingContact.Phone = c.Phone;
|
||||||
|
existingContact.Address = c.Address;
|
||||||
|
existingContact.Notes = c.Notes;
|
||||||
|
existingContact.IsBreeder = c.IsBreeder;
|
||||||
|
existingContact.IsReceiver = c.IsReceiver;
|
||||||
|
contactsUpdated++;
|
||||||
|
}
|
||||||
|
else if (addedContactIds.Add(c.Id))
|
||||||
|
{
|
||||||
|
_db.Contacts.Add(c);
|
||||||
|
contactsAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
||||||
|
var littersToInsert = new List<Litter>();
|
||||||
|
foreach (var l in data.Litters)
|
||||||
|
{
|
||||||
|
littersToInsert.Add(new Litter
|
||||||
|
{
|
||||||
|
Id = l.Id,
|
||||||
|
Name = l.Name,
|
||||||
|
Date = l.Date,
|
||||||
|
TotalBorn = l.TotalBorn,
|
||||||
|
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
||||||
|
FatherId = null, // Set in Pass 2
|
||||||
|
MotherId = null, // Set in Pass 2
|
||||||
|
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
||||||
|
Notes = l.Notes,
|
||||||
|
PairingCode = l.PairingCode,
|
||||||
|
ExternalRef = l.ExternalRef,
|
||||||
|
LitterLetter = l.LitterLetter
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_db.Litters.AddRange(littersToInsert);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
||||||
|
var gerbilsToInsert = new List<Gerbil>();
|
||||||
|
foreach (var g in data.Gerbils)
|
||||||
|
{
|
||||||
|
var importedGerbil = new Gerbil
|
||||||
|
{
|
||||||
|
Id = g.Id,
|
||||||
|
Name = g.Name,
|
||||||
|
Gender = g.Gender,
|
||||||
|
Status = g.Status,
|
||||||
|
LitterId = null, // Set in Pass 2
|
||||||
|
OriginContactId = g.OriginContactId,
|
||||||
|
ReceiverContactId = g.ReceiverContactId,
|
||||||
|
EnclosureId = g.EnclosureId,
|
||||||
|
ColorVarietyId = g.ColorVarietyId,
|
||||||
|
DateOfBirth = g.DateOfBirth,
|
||||||
|
DateOfDeath = g.DateOfDeath,
|
||||||
|
CauseOfDeath = g.CauseOfDeath,
|
||||||
|
GoHomeDate = g.GoHomeDate,
|
||||||
|
Genotype = g.Genotype,
|
||||||
|
Notes = g.Notes,
|
||||||
|
ImportSource = g.ImportSource,
|
||||||
|
ExternalRef = g.ExternalRef,
|
||||||
|
RawImportData = g.RawImportData,
|
||||||
|
OriginBreeder = g.OriginBreeder,
|
||||||
|
NameSearch = g.NameSearch,
|
||||||
|
CharacterTraits = g.CharacterTraits,
|
||||||
|
CharacterNote = g.CharacterNote,
|
||||||
|
IsDeaf = g.IsDeaf,
|
||||||
|
IsResident = g.IsResident
|
||||||
|
};
|
||||||
|
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
gerbilsToInsert.Add(importedGerbil);
|
||||||
|
}
|
||||||
|
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 5. Pass 2: Set the actual foreign key relationships
|
||||||
|
// Update Litters with their parent animal IDs
|
||||||
|
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||||
|
foreach (var l in data.Litters)
|
||||||
|
{
|
||||||
|
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
|
||||||
|
{
|
||||||
|
dbLitter.FatherId = l.FatherId;
|
||||||
|
dbLitter.MotherId = l.MotherId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Gerbils with their actual LitterId
|
||||||
|
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||||
|
foreach (var g in data.Gerbils)
|
||||||
|
{
|
||||||
|
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||||
|
{
|
||||||
|
dbGerbil.LitterId = g.LitterId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 6. Insert all Photos & copy physical files
|
||||||
|
Directory.CreateDirectory(_photoRoot);
|
||||||
|
foreach (var p in data.GerbilPhotos)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(p.SourcePath))
|
||||||
|
{
|
||||||
|
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||||
|
var src = Path.Combine(_sourceDir, relPath);
|
||||||
|
if (File.Exists(src))
|
||||||
|
{
|
||||||
|
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(src, dest, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: Source photo file not found at {src}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
GerbilId = p.GerbilId,
|
||||||
|
FileName = p.FileName,
|
||||||
|
Caption = p.Caption,
|
||||||
|
SortOrder = p.SortOrder,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResolvedImportData
|
||||||
|
{
|
||||||
|
public List<Contact> Contacts { get; set; } = new();
|
||||||
|
public List<Litter> Litters { get; set; } = new();
|
||||||
|
public List<Gerbil> Gerbils { get; set; } = new();
|
||||||
|
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResolvedPhoto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid GerbilId { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("_source_path")]
|
||||||
|
public string? SourcePath { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGermanCollation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterLetter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesAR5 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Agouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Silberagouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Kohlfuchs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Anthrazit");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 48);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 49);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Topas", 52 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Blaufuchs", 53 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 66);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 67);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 68);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 69);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Agouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Silberagouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Kohlfuchs dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Anthrazit dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 32);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 33);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 34);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 35);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Topas dd", 48 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Blaufuchs dd", 49 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 52);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 53);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RenameREW : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
|
column: "Name",
|
||||||
|
value: "REW");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Pink Eyed White (PEW)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class StatusModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1) Rename string value 'Active' → 'Breeding'.
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
|
||||||
|
|
||||||
|
// 2) DateOfDeath set → Deceased (always takes precedence).
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
|
||||||
|
|
||||||
|
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||||
|
WHERE ""Status"" != 'Deceased'
|
||||||
|
AND (""ReceiverContactId"" IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||||
|
|
||||||
|
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
|
||||||
|
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
|
||||||
|
AND ""DateOfBirth"" IS NOT NULL
|
||||||
|
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterMortality : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DeathsWithin8Weeks",
|
||||||
|
table: "Litters",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeathsWithin8Weeks",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSpottingType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SpottingType",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SpottingType",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContactRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBreeder",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsReceiver",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBreeder",
|
||||||
|
table: "Contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsReceiver",
|
||||||
|
table: "Contacts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsCastrated : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCastrated",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCastrated",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBreedingNameSuffix : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "BreederSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "BreederSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||||
|
column: "NameSuffix",
|
||||||
|
value: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "Contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "BreederSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEnclosurePhotos : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EnclosurePhotos",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Caption = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
|
||||||
|
column: x => x.EnclosureId,
|
||||||
|
principalTable: "Enclosures",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EnclosurePhotos_EnclosureId",
|
||||||
|
table: "EnclosurePhotos",
|
||||||
|
column: "EnclosureId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EnclosurePhotos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeLitterDateNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<DateOnly>(
|
||||||
|
name: "Date",
|
||||||
|
table: "Litters",
|
||||||
|
type: "date",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateOnly),
|
||||||
|
oldType: "date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<DateOnly>(
|
||||||
|
name: "Date",
|
||||||
|
table: "Litters",
|
||||||
|
type: "date",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateOnly(1, 1, 1),
|
||||||
|
oldClrType: typeof(DateOnly),
|
||||||
|
oldType: "date",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContractAnimalPhoto : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "PhotoId",
|
||||||
|
table: "SaleContractAnimal",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PhotoId",
|
||||||
|
table: "SaleContractAnimal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("NameSuffix")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Email = "",
|
Email = "",
|
||||||
Homepage = "",
|
Homepage = "",
|
||||||
Name = "",
|
Name = "",
|
||||||
|
NameSuffix = "",
|
||||||
Phone = "",
|
Phone = "",
|
||||||
ZuchtName = ""
|
ZuchtName = ""
|
||||||
});
|
});
|
||||||
@@ -201,7 +206,8 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
@@ -215,7 +221,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||||
Name = "Pink Eyed White (PEW)",
|
Name = "REW",
|
||||||
SortOrder = 0
|
SortOrder = 0
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -320,14 +326,14 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Gold",
|
Name = "Dilute Gold",
|
||||||
SortOrder = 15
|
SortOrder = 15
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Platin",
|
Name = "Dilute Platin",
|
||||||
SortOrder = 16
|
SortOrder = 16
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -411,28 +417,28 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||||
Name = "Agouti dd",
|
Name = "Dilute Agouti",
|
||||||
SortOrder = 28
|
SortOrder = 28
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||||
Name = "Silberagouti dd",
|
Name = "Dilute Silberagouti",
|
||||||
SortOrder = 29
|
SortOrder = 29
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs dd",
|
Name = "Dilute Kohlfuchs",
|
||||||
SortOrder = 30
|
SortOrder = 30
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||||
Name = "Anthrazit dd",
|
Name = "Dilute Anthrazit",
|
||||||
SortOrder = 31
|
SortOrder = 31
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -440,224 +446,224 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Silberschimmel",
|
Name = "Silberschimmel",
|
||||||
SortOrder = 32
|
SortOrder = 36
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Polarfuchsschimmel",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 33
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||||
Name = "Algierfuchsschimmel",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 34
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 35
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||||
Name = "Blaufuchsschimmel",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 36
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs, hell",
|
Name = "Kohlfuchs, hell",
|
||||||
SortOrder = 37
|
SortOrder = 41
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||||
Name = "Goldfuchs, hell",
|
Name = "Goldfuchs, hell",
|
||||||
SortOrder = 38
|
SortOrder = 42
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||||
Name = "Goldfuchsschimmel",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||||
Name = "Gold-Hell",
|
Name = "Gold-Hell",
|
||||||
SortOrder = 40
|
SortOrder = 44
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||||
Name = "Blaufuchs, hell",
|
Name = "Blaufuchs, hell",
|
||||||
SortOrder = 41
|
SortOrder = 45
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||||
Name = "Rotfuchsschimmel",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 42
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||||
Name = "Polarfuchs, hell",
|
Name = "Polarfuchs, hell",
|
||||||
SortOrder = 43
|
SortOrder = 47
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel, hell",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 44
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||||
Name = "Rotfuchs, hell",
|
Name = "Rotfuchs, hell",
|
||||||
SortOrder = 45
|
SortOrder = 49
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs-Hell",
|
Name = "Kohlfuchs-Hell",
|
||||||
SortOrder = 46
|
SortOrder = 50
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||||
Name = "Algierfuchs, hell",
|
Name = "Algierfuchs, hell",
|
||||||
SortOrder = 47
|
SortOrder = 51
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "Topas dd",
|
Name = "Dilute Topas",
|
||||||
SortOrder = 48
|
SortOrder = 52
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||||
Name = "Blaufuchs dd",
|
Name = "Dilute Blaufuchs",
|
||||||
SortOrder = 49
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "Marder",
|
Name = "Marder",
|
||||||
SortOrder = 50
|
SortOrder = 54
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||||
Name = "Siam",
|
Name = "Siam",
|
||||||
SortOrder = 51
|
SortOrder = 55
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||||
Name = "Zobel-Hell",
|
Name = "Zobel-Hell",
|
||||||
SortOrder = 52
|
SortOrder = 56
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "CP-Agouti",
|
Name = "CP-Agouti",
|
||||||
SortOrder = 53
|
SortOrder = 57
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
Name = "CP-Silberagouti",
|
Name = "CP-Silberagouti",
|
||||||
SortOrder = 54
|
SortOrder = 59
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
Name = "CP-Algierfuchs",
|
Name = "CP-Algierfuchs",
|
||||||
SortOrder = 55
|
SortOrder = 61
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
Name = "CP-Polarfuchs",
|
Name = "CP-Polarfuchs",
|
||||||
SortOrder = 56
|
SortOrder = 63
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs",
|
Name = "CP-Fuchs",
|
||||||
SortOrder = 57
|
SortOrder = 65
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs-Hell",
|
Name = "CP-Fuchs-Hell",
|
||||||
SortOrder = 58
|
SortOrder = 66
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
Name = "CP-Blaufuchs",
|
Name = "CP-Blaufuchs",
|
||||||
SortOrder = 59
|
SortOrder = 67
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
SortOrder = 60
|
SortOrder = 68
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
||||||
Name = "CP-Agouti-Hell",
|
Name = "CP-Agouti-Hell",
|
||||||
SortOrder = 61
|
SortOrder = 58
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
||||||
Name = "CP-Silberagouti-Hell",
|
Name = "CP-Silberagouti-Hell",
|
||||||
SortOrder = 62
|
SortOrder = 60
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
||||||
Name = "CP-Algierfuchs-Hell",
|
Name = "CP-Algierfuchs-Hell",
|
||||||
SortOrder = 63
|
SortOrder = 62
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
@@ -671,7 +677,35 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel-Hell",
|
Name = "CP-Orangeschimmel-Hell",
|
||||||
SortOrder = 65
|
SortOrder = 69
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
|
||||||
|
Name = "Dilute Algierfuchs",
|
||||||
|
SortOrder = 32
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Goldfuchs",
|
||||||
|
SortOrder = 33
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||||
|
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Rotfuchs",
|
||||||
|
SortOrder = 34
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
|
||||||
|
Name = "Dilute Polarfuchs",
|
||||||
|
SortOrder = 35
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -687,8 +721,18 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBreeder")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReceiver")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
|
b.Property<string>("NameSuffix")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
@@ -720,6 +764,35 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("Enclosures");
|
b.ToTable("Enclosures");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Caption")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("EnclosureId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.ToTable("EnclosurePhotos");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -764,6 +837,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("ImportSource")
|
b.Property<string>("ImportSource")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCastrated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeaf")
|
b.Property<bool?>("IsDeaf")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -777,16 +853,19 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("NameSearch")
|
b.Property<string>("NameSearch")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("OriginBreeder")
|
b.Property<string>("OriginBreeder")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<Guid?>("OriginContactId")
|
b.Property<Guid?>("OriginContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -797,6 +876,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid?>("ReceiverContactId")
|
b.Property<Guid?>("ReceiverContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("SpottingType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -807,6 +889,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EnclosureId");
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("LitterId");
|
b.HasIndex("LitterId");
|
||||||
|
|
||||||
b.HasIndex("OriginContactId");
|
b.HasIndex("OriginContactId");
|
||||||
@@ -884,15 +970,24 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateOnly>("Date")
|
b.Property<DateOnly?>("Date")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<int?>("DeathsWithin8Weeks")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalRef")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("FatherId")
|
b.Property<Guid?>("FatherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LitterLetter")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("MotherId")
|
b.Property<Guid?>("MotherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -911,6 +1006,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("FatherId");
|
b.HasIndex("FatherId");
|
||||||
|
|
||||||
b.HasIndex("MotherId");
|
b.HasIndex("MotherId");
|
||||||
@@ -1172,6 +1271,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid>("GerbilId")
|
b.Property<Guid>("GerbilId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PhotoId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.HasKey("SaleContractId", "GerbilId");
|
b.HasKey("SaleContractId", "GerbilId");
|
||||||
|
|
||||||
b.HasIndex("GerbilId");
|
b.HasIndex("GerbilId");
|
||||||
@@ -1240,6 +1342,15 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EnclosureId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
|
|
||||||
public string ZuchtName { get; set; } = "";
|
public string ZuchtName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
|
||||||
|
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
|
||||||
|
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
|
||||||
|
/// </summary>
|
||||||
|
public string NameSuffix { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,14 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public bool IsBreeder { get; set; }
|
||||||
|
public bool IsReceiver { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
|
||||||
|
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||||
|
/// </summary>
|
||||||
|
public string? NameSuffix { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
|
||||||
|
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
|
||||||
|
public class EnclosurePhoto
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid EnclosureId { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public Gender Gender { get; set; }
|
public Gender Gender { get; set; }
|
||||||
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
|
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
|
||||||
|
|
||||||
// Birth litter (the litter this gerbil was born in).
|
// Birth litter (the litter this gerbil was born in).
|
||||||
public Guid? LitterId { get; set; }
|
public Guid? LitterId { get; set; }
|
||||||
@@ -41,6 +41,8 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
||||||
public string? Genotype { get; set; }
|
public string? Genotype { get; set; }
|
||||||
|
|
||||||
|
public string? SpottingType { get; set; }
|
||||||
|
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
// Provenance (for the FEAT-8 spreadsheet import).
|
// Provenance (for the FEAT-8 spreadsheet import).
|
||||||
@@ -80,6 +82,7 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||||
public bool IsResident { get; set; } = true;
|
public bool IsResident { get; set; } = true;
|
||||||
|
public bool IsCastrated { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
namespace GerbilManagerWebAPI.Models
|
namespace GerbilManagerWebAPI.Models
|
||||||
{
|
{
|
||||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
|
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
|
||||||
|
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
|
||||||
|
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
|
||||||
public enum GerbilStatus
|
public enum GerbilStatus
|
||||||
{
|
{
|
||||||
Active = 0,
|
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
|
||||||
|
Breeding = 0,
|
||||||
Deceased = 1,
|
Deceased = 1,
|
||||||
GivenAway = 2,
|
GivenAway = 2,
|
||||||
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
||||||
ForSale = 3
|
ForSale = 3,
|
||||||
|
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
|
||||||
|
Pet = 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
[Key]
|
[Key]
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly? Date { get; set; }
|
||||||
|
|
||||||
/// <summary>Total born count (was "Strength").</summary>
|
/// <summary>Total born count (was "Strength").</summary>
|
||||||
public int? TotalBorn { get; set; }
|
public int? TotalBorn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
|
||||||
|
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
|
||||||
|
public int? DeathsWithin8Weeks { get; set; }
|
||||||
|
|
||||||
public Guid? FatherId { get; set; }
|
public Guid? FatherId { get; set; }
|
||||||
public Gerbil? Father { get; set; }
|
public Gerbil? Father { get; set; }
|
||||||
public Guid? MotherId { get; set; }
|
public Guid? MotherId { get; set; }
|
||||||
@@ -25,5 +29,14 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||||
public string? PairingCode { get; set; }
|
public string? PairingCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
||||||
|
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||||
|
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||||
|
public string? ExternalRef { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||||
|
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||||
|
public string? LitterLetter { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public Guid SaleContractId { get; set; }
|
public Guid SaleContractId { get; set; }
|
||||||
public Guid GerbilId { get; set; }
|
public Guid GerbilId { get; set; }
|
||||||
public Gerbil? Gerbil { get; set; }
|
public Gerbil? Gerbil { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
|
||||||
|
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
|
||||||
|
public Guid? PhotoId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||||
|
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||||
|
}
|
||||||
162
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
162
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Ai;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
||||||
|
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
||||||
|
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
||||||
|
{
|
||||||
|
private readonly OpenAiChatClient _client = new(http, options);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Known usage codes → (German description, isThematic).
|
||||||
|
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
|
||||||
|
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["norn"] = ("Nordische/Altnordische Etymologie", false),
|
||||||
|
["japa"] = ("Japanische Etymologie", false),
|
||||||
|
["mythg"] = ("Griechische Mythologie", false),
|
||||||
|
["ger"] = ("Germanische/Deutsche Etymologie", false),
|
||||||
|
["arb"] = ("Arabische Etymologie", false),
|
||||||
|
["disney"] = ("Disney-Charaktere", true),
|
||||||
|
["pokemon"] = ("Pokémon-Namen", true),
|
||||||
|
["encities"] = ("Namen englischer Städte", true),
|
||||||
|
["hrcities"] = ("Namen kroatischer Städte", true),
|
||||||
|
["usstates"] = ("Namen von US-Bundesstaaten", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
|
string? letter, string? gender, string? usages, int count,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var aiResult = await _client.CompleteAsync(
|
||||||
|
BuildSystemPrompt(),
|
||||||
|
BuildUserPrompt(letter, gender, usages, count),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (aiResult.Status == AiCallStatus.NotConfigured)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
||||||
|
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
||||||
|
|
||||||
|
var suggestions = ParseSuggestions(aiResult.Text);
|
||||||
|
return suggestions is null
|
||||||
|
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
||||||
|
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildSystemPrompt() =>
|
||||||
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
|
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||||
|
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
|
||||||
|
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
|
||||||
|
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
|
||||||
|
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
|
||||||
|
"Erfinde KEINE Etymologie für thematische Kategorien.";
|
||||||
|
|
||||||
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
||||||
|
if (!string.IsNullOrWhiteSpace(letter))
|
||||||
|
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
||||||
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
|
||||||
|
bool hasThematic = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
|
{
|
||||||
|
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var etymDescriptions = new List<string>();
|
||||||
|
var thematicDescriptions = new List<string>();
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (UsageMap.TryGetValue(code, out var entry))
|
||||||
|
{
|
||||||
|
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
|
||||||
|
else etymDescriptions.Add(entry.Description);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
etymDescriptions.Add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (etymDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
|
||||||
|
if (thematicDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
|
||||||
|
hasThematic = thematicDescriptions.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(". ");
|
||||||
|
if (hasThematic)
|
||||||
|
{
|
||||||
|
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
|
||||||
|
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
|
||||||
|
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
|
}
|
||||||
|
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
||||||
|
/// its JSON output, then deserialises the array.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
||||||
|
{
|
||||||
|
var text = raw.Trim();
|
||||||
|
|
||||||
|
// Strip ```json ... ``` or ``` ... ``` fences.
|
||||||
|
if (text.StartsWith("```", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var firstNewline = text.IndexOf('\n');
|
||||||
|
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
||||||
|
if (text.EndsWith("```", StringComparison.Ordinal))
|
||||||
|
text = text[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the JSON array bounds defensively.
|
||||||
|
var start = text.IndexOf('[');
|
||||||
|
var end = text.LastIndexOf(']');
|
||||||
|
if (start < 0 || end <= start) return null;
|
||||||
|
text = text[start..(end + 1)];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
||||||
|
|
||||||
|
public sealed record NameSuggestionResult(
|
||||||
|
NameSuggestionStatus Status,
|
||||||
|
List<NameSuggestion>? Suggestions,
|
||||||
|
string? Error);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using GerbilManagerWebAPI.Endpoints;
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
@@ -44,9 +47,25 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
|||||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||||
|
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||||
|
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
builder.Services.AddDataProtection();
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
|
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
|
||||||
|
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
|
||||||
|
{
|
||||||
|
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
|
||||||
|
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
|
||||||
|
Directory.CreateDirectory(keyRingPath);
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
|
||||||
|
.SetApplicationName("GerbilManager");
|
||||||
|
}
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||||
@@ -67,7 +86,23 @@ app.MapScalarApiReference();
|
|||||||
if (!app.Environment.IsEnvironment("Testing"))
|
if (!app.Environment.IsEnvironment("Testing"))
|
||||||
{
|
{
|
||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
|
||||||
|
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
|
||||||
|
// since the last write. No-op if all statuses are already current.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var candidates = await db.Gerbils
|
||||||
|
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
|
||||||
|
&& g.DateOfDeath == null && g.ReceiverContactId == null
|
||||||
|
&& g.DateOfBirth != null
|
||||||
|
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
|
||||||
|
.ToListAsync();
|
||||||
|
if (candidates.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors(LanCorsPolicy);
|
app.UseCors(LanCorsPolicy);
|
||||||
@@ -84,11 +119,13 @@ app.MapInbreedingEndpoints();
|
|||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
app.MapSaleAdEndpoints();
|
app.MapSaleAdEndpoints();
|
||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
|
app.MapImportDocxEndpoints();
|
||||||
app.MapContractEndpoints();
|
app.MapContractEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
|
app.MapNamesEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
|
||||||
|
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
|
||||||
|
/// derived statuses (Deceased, GivenAway) are always consistent.
|
||||||
|
///
|
||||||
|
/// Precedence (highest wins):
|
||||||
|
/// 1) DateOfDeath set → Deceased (explicit, always)
|
||||||
|
/// 2) Abgabe (ReceiverContactId set) → GivenAway
|
||||||
|
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
|
||||||
|
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
|
||||||
|
///
|
||||||
|
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
|
||||||
|
/// a) Apply() on every write (catches the animal at write time)
|
||||||
|
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
|
||||||
|
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
|
||||||
|
/// </summary>
|
||||||
|
public static class GerbilStatusService
|
||||||
|
{
|
||||||
|
public const int MaxAgeYears = 6;
|
||||||
|
|
||||||
|
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
||||||
|
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||||
|
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
|
||||||
|
public static void Apply(Gerbil g, DateOnly today)
|
||||||
|
{
|
||||||
|
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||||
|
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
|
||||||
|
public static GerbilStatus Derive(
|
||||||
|
GerbilStatus requested,
|
||||||
|
DateOnly? dateOfBirth,
|
||||||
|
DateOnly? dateOfDeath,
|
||||||
|
bool isAbgegeben,
|
||||||
|
DateOnly today)
|
||||||
|
{
|
||||||
|
if (dateOfDeath is not null) return GerbilStatus.Deceased;
|
||||||
|
if (isAbgegeben) return GerbilStatus.GivenAway;
|
||||||
|
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
|
||||||
|
return GerbilStatus.Deceased;
|
||||||
|
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
|
||||||
|
? requested : GerbilStatus.Breeding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
|
||||||
|
today >= dob.AddYears(years);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
HUMANQUESTION.md
168
HUMANQUESTION.md
@@ -1,168 +0,0 @@
|
|||||||
# HUMANQUESTION.md — offene Fragen & benötigte Daten
|
|
||||||
|
|
||||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
|
||||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
|
||||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
|
||||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
|
||||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
|
||||||
> alles wartet geduldig, bis ihr antwortet.
|
|
||||||
|
|
||||||
_Stand: 2026-06-06._
|
|
||||||
|
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
|
||||||
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
|
|
||||||
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
|
|
||||||
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Für Julian — Konten, Schlüssel, Technik
|
|
||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
|
||||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
|
||||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
|
||||||
|
|
||||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
|
||||||
|
|
||||||
| # | Aktion | Warum |
|
|
||||||
|---|---|---|
|
|
||||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
|
||||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
|
||||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
|
||||||
|
|
||||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
|
||||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|
||||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
|
||||||
|
|
||||||
| # | Frage | Blockiert |
|
|
||||||
|---|---|---|
|
|
||||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
|
||||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
|
||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
|
||||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
|
||||||
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
|
||||||
|
|
||||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
|
||||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
|
||||||
|
|
||||||
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
|
|
||||||
|
|
||||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
|
||||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
|
||||||
|
|
||||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
|
||||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
|
||||||
|
|
||||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
|
||||||
|---|---|---|
|
|
||||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
|
||||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
|
||||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
|
||||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
|
||||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
|
||||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
|
||||||
|
|
||||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
|
||||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
|
||||||
|
|
||||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
|
||||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
|
||||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
|
||||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
|
||||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
|
||||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
|
||||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
|
||||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
|
||||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
|
||||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
|
||||||
>
|
|
||||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
|
||||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
|
||||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
|
||||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
|
||||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
|
||||||
|
|
||||||
### D5 · **Sterbedatum** widersprüchlich
|
|
||||||
| Tier | Problem |
|
|
||||||
|---|---|
|
|
||||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
|
||||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
|
||||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
|
||||||
|
|
||||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
|
||||||
|
|
||||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
|
||||||
|---|---|
|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
|
||||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
|
||||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
|
||||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
|
||||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
|
||||||
|
|
||||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
|
||||||
sagen, was ergänzt oder gestrichen werden soll:**
|
|
||||||
|
|
||||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
|
||||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
|
||||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
|
||||||
|
|
||||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
|
||||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Was hängt woran (Kurzübersicht)
|
|
||||||
|
|
||||||
| Funktion | Status | Wartet auf |
|
|
||||||
|---|---|---|
|
|
||||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
|
||||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
|
||||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
|
||||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
|
||||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
|
||||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
|
||||||
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
|
|
||||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
|
||||||
sie hier und meldet, was dadurch live geht.*
|
|
||||||
105
HUMANQUESTION.md.migrated
Normal file
105
HUMANQUESTION.md.migrated
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# HUMANQUESTION.md — offene Fragen & benötigte Daten
|
||||||
|
|
||||||
|
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||||
|
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||||
|
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
|
||||||
|
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
|
||||||
|
> das, was **noch offen** ist. Nichts geht verloren.
|
||||||
|
|
||||||
|
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
|
||||||
|
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
|
||||||
|
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
|
||||||
|
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
|
||||||
|
Feinschliff** — siehe unten._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Zugänge & Technik (für Julian)
|
||||||
|
|
||||||
|
| # | Was gebraucht wird | Schaltet frei |
|
||||||
|
|---|---|---|
|
||||||
|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||||
|
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
|
||||||
|
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
|
||||||
|
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
|
||||||
|
|
||||||
|
## B. Kleine Aktion (jederzeit)
|
||||||
|
|
||||||
|
| # | Aktion | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
|
||||||
|
|
||||||
|
## C. Genetik-Feinschliff (optional, blockiert nichts)
|
||||||
|
|
||||||
|
| # | Frage | Betrifft |
|
||||||
|
|---|---|---|
|
||||||
|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
|
||||||
|
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
|
||||||
|
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
|
|
||||||
|
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
|
||||||
|
|
||||||
|
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
|
||||||
|
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
|
||||||
|
|
||||||
|
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||||
|
|
||||||
|
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||||
|
|
||||||
|
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
|
||||||
|
|
||||||
|
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||||
|
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|
||||||
|
|---|---|---|
|
||||||
|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
|
||||||
|
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
|
||||||
|
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
|
||||||
|
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
|
||||||
|
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
|
|
||||||
|
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||||
|
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||||
|
|---|---|---|
|
||||||
|
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
|
||||||
|
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||||
|
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||||
|
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||||
|
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
|
||||||
|
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
|
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
|
||||||
|
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
|
|
||||||
|
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Was hängt woran (Kurzübersicht)
|
||||||
|
|
||||||
|
| Funktion | Status | Wartet auf |
|
||||||
|
|---|---|---|
|
||||||
|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
|
||||||
|
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
|
||||||
|
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
|
||||||
|
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
|
||||||
|
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
|
||||||
|
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Erledigt (Archiv — nichts geht verloren)
|
||||||
|
|
||||||
|
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
|
||||||
|
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
|
||||||
|
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
|
||||||
|
- **C1–C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
|
||||||
|
- **D1–D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
|
||||||
|
|
||||||
|
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*
|
||||||
126
README.md
126
README.md
@@ -1,31 +1,95 @@
|
|||||||
# GerbilManager
|
# GerbilManager
|
||||||
|
|
||||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
|
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
|
||||||
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
|
|
||||||
|
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
|
||||||
# Requirements
|
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
|
||||||
Install ef:
|
- `tools/import/` — Python import pipeline scripts and database schemas
|
||||||
|
|
||||||
dotnet tool install --global dotnet-ef
|
## Requirements
|
||||||
|
To manage database migrations, install the EF Core CLI tool globally:
|
||||||
# Used cmd´s
|
```bash
|
||||||
## Start containers
|
dotnet tool install --global dotnet-ef
|
||||||
docker compose up
|
```
|
||||||
## Create the initial database
|
|
||||||
dotnet ef database update
|
## Running the Application
|
||||||
## Create an migration script
|
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
|
||||||
dotnet ef migrations add <name>
|
```bash
|
||||||
|
dotnet run --project GerbilManager.AppHost
|
||||||
## Docker
|
```
|
||||||
### force compose rebuild
|
|
||||||
docker-compose build --no-cache
|
---
|
||||||
|
|
||||||
## Frontend (gerbil-manager-web)
|
## 📥 Data Import & Normalization Pipeline
|
||||||
cd gerbil-manager-web
|
|
||||||
npm install
|
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
|
||||||
npm run dev
|
|
||||||
|
### 1. Structure of the Pipeline
|
||||||
|
|
||||||
TODO:
|
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
|
||||||
- Backup docker volume database
|
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
|
||||||
- run "dotnet ef database update" if no database exist.
|
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
|
||||||
|
|
||||||
|
### 2. How to Run the Import
|
||||||
|
|
||||||
|
#### Automatic Way (All-in-One)
|
||||||
|
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manual Way
|
||||||
|
1. **Recompile the import data**:
|
||||||
|
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
|
||||||
|
```bash
|
||||||
|
python tools/import/merge_and_resolve.py
|
||||||
|
```
|
||||||
|
2. **Ingest into the running database**:
|
||||||
|
Ensure the application is running, then send an HTTP `POST` request to the API:
|
||||||
|
```bash
|
||||||
|
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Contact Deduplication Workflow
|
||||||
|
|
||||||
|
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
|
||||||
|
|
||||||
|
### 1. Search for Duplicate Contacts
|
||||||
|
You can run duplicate checks using two different similarity parameter models:
|
||||||
|
* **Round 1 (Clever matching on first 4 characters)**:
|
||||||
|
```bash
|
||||||
|
python tools/import/find_duplicates_v5.py
|
||||||
|
```
|
||||||
|
Creates: `tools/import/output/potential_duplicates_decision.md`
|
||||||
|
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
|
||||||
|
```bash
|
||||||
|
python tools/import/find_duplicates_round2.py
|
||||||
|
```
|
||||||
|
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
|
||||||
|
|
||||||
|
### 2. Fill out the Checklist
|
||||||
|
Open the generated markdown checklist file and edit the checkboxes:
|
||||||
|
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
|
||||||
|
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
|
||||||
|
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
|
||||||
|
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
|
||||||
|
|
||||||
|
*Example:*
|
||||||
|
```markdown
|
||||||
|
- [x] Max Mustermann <-- canonical name
|
||||||
|
- [ ] Max M. <-- merges into Max Mustermann
|
||||||
|
- [o] Moritz Mustermann <-- remains a separate standalone contact
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apply the Decisions
|
||||||
|
Once you have saved your decisions in the checklist file, run the parser:
|
||||||
|
```bash
|
||||||
|
python tools/import/parse_decisions.py
|
||||||
|
```
|
||||||
|
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
|
||||||
|
Run the compilation pipeline again to produce the final deduplicated import JSON:
|
||||||
|
```bash
|
||||||
|
python tools/import/merge_and_resolve.py
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager Produktionskonfiguration
|
# GerbilManager Produktionskonfiguration
|
||||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||||
|
|
||||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||||
@@ -7,17 +7,28 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
|||||||
# Externer Port fuer das Frontend (Standard: 80)
|
# Externer Port fuer das Frontend (Standard: 80)
|
||||||
PORT=80
|
PORT=80
|
||||||
|
|
||||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
# Container Registry (git.rismer.de/gulum)
|
||||||
REGISTRY=truenas:13000/gulum
|
REGISTRY=git.rismer.de/gulum
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||||
|
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||||
|
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||||
|
|
||||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=7
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|
||||||
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
|
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
|
||||||
ANTHROPIC_API_KEY=
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
|
||||||
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
|
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||||
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
|
AI__BaseUrl=
|
||||||
|
AI__ApiKey=
|
||||||
|
AI__Model=gemini-flash-latest
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager — TrueNAS Custom Application
|
# GerbilManager — TrueNAS Custom Application
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Vor dem ersten Start:
|
# Vor dem ersten Start:
|
||||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
|
|
||||||
# --- .NET API (GerbilManagerWebAPI) ---
|
# --- .NET API (GerbilManagerWebAPI) ---
|
||||||
api:
|
api:
|
||||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||||
@@ -40,10 +40,20 @@ services:
|
|||||||
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
||||||
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
||||||
Photos__RootPath: /data/photos
|
Photos__RootPath: /data/photos
|
||||||
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
|
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
DataProtection__KeyRingPath: /data/keys
|
||||||
|
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
|
||||||
|
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
|
||||||
|
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
||||||
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
|
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||||
|
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||||
|
PublicSite__RootPath: /data/publicsite
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
|
- keys:/data/keys
|
||||||
|
- publicsite:/data/publicsite
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -56,7 +66,7 @@ services:
|
|||||||
|
|
||||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||||
frontend:
|
frontend:
|
||||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: gerbil-manager-web/Dockerfile
|
dockerfile: gerbil-manager-web/Dockerfile
|
||||||
@@ -67,6 +77,21 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
# --- nginx Public Site (WEB-2) ---
|
||||||
|
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
|
||||||
|
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
|
||||||
|
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
|
||||||
|
publicsite:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PUBLICSITE_PORT:-8081}:80"
|
||||||
|
volumes:
|
||||||
|
- publicsite:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||||
backup:
|
backup:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -88,22 +113,37 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
|
||||||
pgdata:
|
pgdata:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||||
photos:
|
photos:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
|
||||||
|
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||||
|
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||||
|
keys:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||||
backups:
|
backups:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||||
|
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||||
|
publicsite:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||||
|
|||||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# GerbilManager — publicsite nginx (WEB-2)
|
||||||
|
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||||
|
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html/live;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/index.html =404;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS/Bilder: kurze TTL (1 Tag)
|
||||||
|
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein Zugriff auf Staging-Verzeichnisse
|
||||||
|
location ~ ^/_(staging_new|old)/ {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||||
|
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||||
|
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||||
|
#
|
||||||
|
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||||
|
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||||
|
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name <DOMAIN>;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl;
|
||||||
|
# server_name <DOMAIN>;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://127.0.0.1:8081;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||||
|
|
||||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
|||||||
|
|
||||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
||||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
|||||||
```powershell
|
```powershell
|
||||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
$env:AI__ApiKey = '<schlüssel>'
|
$env:AI__ApiKey = '<schlüssel>'
|
||||||
$env:AI__Model = 'gemini-2.0-flash'
|
$env:AI__Model = 'gemini-flash-latest'
|
||||||
```
|
```
|
||||||
|
|
||||||
oder per user-secrets im API-Projekt:
|
oder per user-secrets im API-Projekt:
|
||||||
@@ -46,5 +46,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||||
AI__Model: gemini-2.0-flash
|
AI__Model: gemini-flash-latest
|
||||||
```
|
```
|
||||||
|
|||||||
327
docs/ops.md
327
docs/ops.md
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||||
|
|
||||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||||
@@ -9,13 +9,12 @@
|
|||||||
|
|
||||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||||
2. [Voraussetzungen](#2-voraussetzungen)
|
2. [Voraussetzungen](#2-voraussetzungen)
|
||||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
|
||||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
Browser / Handy
|
Browser / Handy
|
||||||
| HTTP :80
|
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||||
v
|
v
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||||
@@ -40,9 +39,10 @@ Browser / Handy
|
|||||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||||
└──────────────────┘ │ pg_dump + tar + cron │
|
└──────────────────┘ │ pg_dump + tar + cron │
|
||||||
│ └──────────────────────┘
|
│ └──────────────────────┘
|
||||||
└─ pgdata-Volume (NAS-Dataset)
|
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
photos-Volume (NAS-Dataset)
|
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||||
backups-Volume (NAS-Dataset)
|
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||||
|
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||||
```
|
```
|
||||||
|
|
||||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||||
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
|
|||||||
|
|
||||||
| Was | Details |
|
| Was | Details |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Erstinstallation auf TrueNAS
|
## 3. Erstinstallation auf TrueNAS Goldeye
|
||||||
|
|
||||||
### 3.1 ZFS-Datasets anlegen
|
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
|
||||||
|
|
||||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||||
|
|
||||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
|
||||||
|---|---|---|
|
|
||||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
|
||||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
|
||||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
|
||||||
|
|
||||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
|
||||||
|
|
||||||
### 3.2 Repository klonen
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SSH in TrueNAS oder lokale Shell
|
# Vier Ordner anlegen
|
||||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||||
|
|
||||||
|
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
|
||||||
|
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
|
||||||
|
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
|
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
|
|
||||||
|
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
|
||||||
|
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
|
||||||
|
```
|
||||||
|
|
||||||
|
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
|
||||||
|
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
|
||||||
|
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
|
||||||
|
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
|
||||||
|
|
||||||
|
### 3.2 Registry-Login auf der NAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.rismer.de
|
||||||
|
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
|
||||||
|
|
||||||
|
### 3.3 Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
|
||||||
cd /opt/gerbilmanager
|
cd /opt/gerbilmanager
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Konfiguration anlegen
|
### 3.4 Konfiguration anlegen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||||
# Jetzt .env bearbeiten:
|
|
||||||
nano deploy/truenas/.env
|
nano deploy/truenas/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Mindestens setzen:
|
Mindestens setzen:
|
||||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
|
||||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
|
||||||
|
|
||||||
### 3.4 Images bauen und App starten
|
| Variable | Wert |
|
||||||
|
|----------|------|
|
||||||
|
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
|
||||||
|
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
|
||||||
|
| `AI__ApiKey` | Dein Gemini API-Key |
|
||||||
|
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
|
||||||
|
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
|
||||||
|
|
||||||
|
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
|
||||||
|
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
|
||||||
|
|
||||||
|
### 3.5 Images ziehen und App starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/gerbilmanager
|
cd /opt/gerbilmanager
|
||||||
docker compose -f deploy/truenas/compose.yaml build
|
docker compose -f deploy/truenas/compose.yaml pull
|
||||||
docker compose -f deploy/truenas/compose.yaml up -d
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||||
|
|
||||||
### 3.5 Prüfen
|
> **TrueNAS Goldeye Custom App (Alternative):**
|
||||||
|
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
|
||||||
|
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
|
||||||
|
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
|
||||||
|
|
||||||
|
### 3.6 Verifikation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Alle Container laufen?
|
# Alle 4 Container laufen?
|
||||||
docker compose -f deploy/truenas/compose.yaml ps
|
docker compose -f deploy/truenas/compose.yaml ps
|
||||||
|
|
||||||
# API-Healthcheck
|
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||||
curl http://localhost/api/health
|
curl -s http://localhost/api/health
|
||||||
|
|
||||||
# Webapp im Browser
|
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||||
http://<NAS-IP>/
|
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||||
|
|
||||||
|
# API-Doku (Scalar) im Browser
|
||||||
|
http://<NAS-IP>/scalar
|
||||||
|
|
||||||
|
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -125,6 +164,7 @@ http://<NAS-IP>/
|
|||||||
### Starten
|
### Starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /opt/gerbilmanager
|
||||||
docker compose -f deploy/truenas/compose.yaml up -d
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
|
|||||||
docker compose -f deploy/truenas/compose.yaml down
|
docker compose -f deploy/truenas/compose.yaml down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Aktualisieren (nach `git push` auf main)
|
### Aktualisieren (nach CI-Push auf main)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/gerbilmanager
|
cd /opt/gerbilmanager
|
||||||
git pull
|
git pull
|
||||||
docker compose -f deploy/truenas/compose.yaml build
|
docker compose -f deploy/truenas/compose.yaml pull
|
||||||
docker compose -f deploy/truenas/compose.yaml up -d
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||||
|
|
||||||
### Mit Gitea CI (wenn Actions aktiviert)
|
|
||||||
|
|
||||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
|
||||||
Danach auf der NAS:
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/truenas/compose.yaml pull
|
|
||||||
docker compose -f deploy/truenas/compose.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Backup & Wiederherstellung
|
## 5. Backup & Wiederherstellung
|
||||||
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
|||||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||||
|
|
||||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
|
||||||
|
|
||||||
```
|
```
|
||||||
/mnt/SSD/gerbil/backups/
|
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
|
||||||
2026-06-06_03-00/
|
2026-06-06_03-00/
|
||||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
|
||||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||||
backup.log (Protokoll)
|
backup.log (Protokoll)
|
||||||
```
|
```
|
||||||
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
|||||||
### Backup-Log prüfen
|
### Backup-Log prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
|
||||||
|
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
|
||||||
|
|
||||||
### Wiederherstellung — Runbook
|
### Wiederherstellung — Runbook
|
||||||
|
|
||||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
|
||||||
|
|
||||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schritt 2:** Restore ausführen
|
**Schritt 2:** Restore ausführen
|
||||||
```bash
|
|
||||||
# Neuestes Backup wiederherstellen:
|
|
||||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
|
||||||
/bin/sh /scripts/restore.sh
|
|
||||||
|
|
||||||
# Bestimmtes Backup wiederherstellen:
|
```bash
|
||||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
# Neuestes Backup automatisch wählen und bestätigen:
|
||||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||||
|
/bin/sh /scripts/restore.sh latest -f
|
||||||
|
|
||||||
|
# Bestimmtes Backup (Datum aus Verzeichnisname):
|
||||||
|
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||||
|
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Das Skript:
|
||||||
|
1. Trennt alle offenen DB-Verbindungen
|
||||||
|
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
|
||||||
|
3. Entpackt das Foto-Archiv nach `/data/photos`
|
||||||
|
|
||||||
**Schritt 3:** API neu starten
|
**Schritt 3:** API neu starten
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schritt 4:** Prüfen
|
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
# Tier-Anzahl prüfen
|
||||||
# Erwarteter Wert: 73
|
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||||
|
|
||||||
|
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
|
||||||
|
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
|
||||||
|
|
||||||
|
# Foto stichprobenartig prüfen
|
||||||
|
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restore-Nachweis (Round-Trip-Test)
|
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
|
||||||
|
|
||||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
|
||||||
```
|
```
|
||||||
73 ColorVarieties vorhanden
|
73 ColorVarieties vorhanden
|
||||||
→ DELETE 12 Zeilen → 61 verbleibend
|
→ DELETE 12 Zeilen → 61 verbleibend
|
||||||
→ pg_restore eingespielt
|
→ psql < dump.sql eingespielt
|
||||||
→ 73 ColorVarieties bestätigt
|
→ 73 ColorVarieties bestätigt
|
||||||
Exit-Code: 0
|
Exit-Code: 0
|
||||||
```
|
```
|
||||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
|
||||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
|
||||||
|
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
|||||||
|
|
||||||
### Empfohlene Snapshot-Konfiguration
|
### Empfohlene Snapshot-Konfiguration
|
||||||
|
|
||||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||||
|
|
||||||
| Dataset | Häufigkeit | Aufbewahrung |
|
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||||
|---------|-----------|--------------|
|
|-------------|-----------|--------------|
|
||||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
| `.../photos` | Stündlich | 24 Stunden |
|
||||||
| `gerbil/photos` | Täglich | 30 Tage |
|
| `.../photos` | Täglich | 30 Tage |
|
||||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
| `.../pgdata` | Täglich | 30 Tage |
|
||||||
| `gerbil/backups` | Täglich | 90 Tage |
|
| `.../backups` | Täglich | 90 Tage |
|
||||||
|
|
||||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||||
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
|
|||||||
### Snapshot manuell erstellen (z. B. vor Update)
|
### Snapshot manuell erstellen (z. B. vor Update)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# TrueNAS CLI
|
# Pool-/Dataset-Name anpassen falls nötig
|
||||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Snapshot auflisten
|
# Snapshots auflisten
|
||||||
zfs list -t snapshot SSD/gerbil/photos
|
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||||
|
|
||||||
# Datei aus Snapshot kopieren
|
# Einzelne Datei aus Snapshot kopieren
|
||||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||||
|
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. CI/CD via Gitea Actions
|
## 7. CI/CD via Gitea Actions
|
||||||
|
|
||||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
|
||||||
|
`git.rismer.de/gulum/gerbilmanager-frontend`.
|
||||||
|
|
||||||
### Aktivierung
|
### Registry-Secrets in Gitea
|
||||||
|
|
||||||
1. **Gitea Actions aktivieren:**
|
Gitea → Repository → Einstellungen → Secrets:
|
||||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
|
||||||
|
|
||||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
| Secret | Wert |
|
||||||
```bash
|
|--------|------|
|
||||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||||
docker run -d --name gitea-runner \
|
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-v /opt/gitea-runner:/data \
|
|
||||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
|
||||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
|
||||||
gitea/act_runner:latest
|
|
||||||
```
|
|
||||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
|
||||||
|
|
||||||
3. **Registry-Secrets konfigurieren:**
|
### Update nach CI-Push
|
||||||
Gitea → Repository → Einstellungen → Secrets:
|
|
||||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
|
||||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
|
||||||
|
|
||||||
### Workflow nach Aktivierung
|
```bash
|
||||||
|
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||||
```
|
cd /opt/gerbilmanager
|
||||||
git push origin main
|
git pull
|
||||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
docker compose -f deploy/truenas/compose.yaml pull
|
||||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
→ Auf NAS: docker compose pull + up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Offene Fragen
|
## 8. Fehlerbehebung
|
||||||
|
|
||||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
|
||||||
|
|
||||||
| # | Frage | Auswirkung |
|
|
||||||
|---|-------|-----------|
|
|
||||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
|
||||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
|
||||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
|
||||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
|
||||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Fehlerbehebung
|
|
||||||
|
|
||||||
### App startet nicht
|
### App startet nicht
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Logs aller Container
|
|
||||||
docker compose -f deploy/truenas/compose.yaml logs
|
docker compose -f deploy/truenas/compose.yaml logs
|
||||||
|
|
||||||
# Logs eines bestimmten Containers
|
|
||||||
docker compose -f deploy/truenas/compose.yaml logs api
|
docker compose -f deploy/truenas/compose.yaml logs api
|
||||||
docker compose -f deploy/truenas/compose.yaml logs db
|
docker compose -f deploy/truenas/compose.yaml logs db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Port 80 belegt
|
||||||
|
|
||||||
|
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In deploy/truenas/.env:
|
||||||
|
PORT=8080
|
||||||
|
# Dann neu starten:
|
||||||
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Postgres startet nicht (Permission denied auf pgdata)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
|
||||||
|
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
|
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||||
|
docker compose -f deploy/truenas/compose.yaml restart db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registry-Pull schlägt fehl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neu einloggen:
|
||||||
|
docker login git.rismer.de
|
||||||
|
# Dann pull wiederholen:
|
||||||
|
docker compose -f deploy/truenas/compose.yaml pull
|
||||||
|
```
|
||||||
|
|
||||||
### Datenbank nicht erreichbar
|
### Datenbank nicht erreichbar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB-Container läuft?
|
|
||||||
docker compose -f deploy/truenas/compose.yaml ps db
|
docker compose -f deploy/truenas/compose.yaml ps db
|
||||||
|
|
||||||
# Verbindung testen
|
|
||||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||||
psql -U postgres -d gerbilmanager -c "\dt"
|
psql -U postgres -d gerbilmanager -c "\dt"
|
||||||
```
|
```
|
||||||
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
|||||||
### Backup-Fehler
|
### Backup-Fehler
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backup-Log prüfen
|
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||||
|
|
||||||
# Backup manuell starten (mit Fehlerausgabe)
|
|
||||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
|
||||||
/bin/sh /scripts/backup.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fotos werden nicht angezeigt
|
### Fotos werden nicht angezeigt
|
||||||
|
|
||||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||||
```
|
```
|
||||||
|
|
||||||
### Container-Status zurücksetzen (Neustart)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/truenas/compose.yaml restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kompletter Neustart (Daten bleiben erhalten)
|
### Kompletter Neustart (Daten bleiben erhalten)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||||
|
|
||||||
|
> **Zielgruppe:** Julian.
|
||||||
|
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||||
|
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
| HTTPS/HTTP
|
||||||
|
v
|
||||||
|
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||||
|
| proxy_pass http://127.0.0.1:8081
|
||||||
|
v
|
||||||
|
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||||
|
| (Shared Volume, read-only)
|
||||||
|
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||||
|
v
|
||||||
|
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||||
|
| (Shared Volume, read-write)
|
||||||
|
v
|
||||||
|
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||||
|
```
|
||||||
|
|
||||||
|
**SICHERHEIT — harte Bedingung:**
|
||||||
|
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||||
|
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||||
|
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erstinstallation
|
||||||
|
|
||||||
|
### 1. Verzeichnis anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||||
|
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||||
|
|
||||||
|
### 2. .env erganzen
|
||||||
|
|
||||||
|
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compose-Stack neu starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/gerbilmanager
|
||||||
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||||
|
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||||
|
|
||||||
|
### 4. Julians externen nginx konfigurieren
|
||||||
|
|
||||||
|
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||||
|
(als eigenen `server`-Block oder per `include`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||||
|
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||||
|
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seite veroeffentlichen (Publish-Ablauf)
|
||||||
|
|
||||||
|
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||||
|
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||||
|
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||||
|
|
||||||
|
**Was passiert intern:**
|
||||||
|
```
|
||||||
|
POST /api/publish
|
||||||
|
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||||
|
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||||
|
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||||
|
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||||
|
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||||
|
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||||
|
Response: { "filesPublished": N }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# publicsite-Container laeuft?
|
||||||
|
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||||
|
|
||||||
|
# Seite lokal abrufbar?
|
||||||
|
curl -s http://localhost:8081/ | head -5
|
||||||
|
|
||||||
|
# live/-Verzeichnis gefuellt?
|
||||||
|
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||||
|
|
||||||
|
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||||
|
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitstrennung (Pflichtcheck)
|
||||||
|
|
||||||
|
| Was | Port | Internet-exponiert? |
|
||||||
|
|-----|------|---------------------|
|
||||||
|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||||
|
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||||
|
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||||
|
|
||||||
|
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||||
|
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||||
|
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hostname noch ausstehend
|
||||||
|
|
||||||
|
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||||
|
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||||
@@ -40,6 +40,25 @@ npm run build
|
|||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Farbschlag-Katalog (AR-5)
|
||||||
|
|
||||||
|
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
|
||||||
|
Nach jeder Änderung dort den Generator laufen lassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run gen:catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugt zwei Artefakte und committet beide:
|
||||||
|
|
||||||
|
| Datei | Notation | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
|
||||||
|
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
|
||||||
|
|
||||||
|
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
|
||||||
|
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
|
||||||
|
|
||||||
## E2E-Tests (QA-1, Playwright)
|
## E2E-Tests (QA-1, Playwright)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -56,4 +75,4 @@ npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
|||||||
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||||
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||||
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||||
`src/strings/de.ts`.
|
`src/strings/de.ts`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
|
|||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
|
||||||
await openFilterPanel(page)
|
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
|
||||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
|
||||||
|
|
||||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
await expect(maxRow).toBeVisible()
|
await expect(maxRow).toBeVisible()
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// DESIGN (Rennmausakte): Charakter sind anklickbare Chips (Buttons) statt Checkboxen.
|
||||||
// Eigenschaft 'neugierig' aktivieren und speichern.
|
// Eigenschaft 'neugierig' aktivieren und speichern.
|
||||||
await page.getByRole('checkbox', { name: 'neugierig' }).check()
|
await page.getByRole('button', { name: 'neugierig', exact: true }).click()
|
||||||
await page.getByRole('button', { name: de.character.save }).click()
|
await page.getByRole('button', { name: de.character.save }).click()
|
||||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
await expect(page.getByRole('button', { name: 'neugierig', exact: true })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
|
||||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||||
|
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
|
||||||
await page.goto('/abgabe')
|
await page.goto('/abgabe')
|
||||||
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||||
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||||
|
|||||||
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
|
const genotypeInput = page
|
||||||
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
|
.locator('input')
|
||||||
|
|
||||||
|
// Vor der Auswahl ist das Gencode-Feld leer.
|
||||||
|
await expect(genotypeInput).toHaveValue('')
|
||||||
|
|
||||||
|
// 'Agouti' wählen → Gencode wird automatisch befüllt.
|
||||||
|
await page.locator('.color-picker-trigger').click()
|
||||||
|
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||||
|
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
|
const genotypeInput = page
|
||||||
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
|
.locator('input')
|
||||||
|
|
||||||
|
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
|
||||||
|
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
|
const genotypeInput = page
|
||||||
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
|
.locator('input')
|
||||||
|
|
||||||
|
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
|
||||||
|
await page.locator('.color-picker-trigger').click()
|
||||||
|
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
|
||||||
|
|
||||||
|
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
|
||||||
|
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
|
||||||
|
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
|
const genotypeInput = page
|
||||||
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
|
.locator('input')
|
||||||
|
|
||||||
|
await page.locator('.color-picker-trigger').click()
|
||||||
|
await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
|
||||||
|
|
||||||
|
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
|
||||||
|
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||||
|
})
|
||||||
@@ -1,85 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
|
||||||
*
|
*
|
||||||
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
|
||||||
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
|
||||||
|
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
|
||||||
*/
|
*/
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
test.describe('Quick-Chips-Filter – Rennmäuse-Liste', () => {
|
||||||
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
if (testInfo.project.name !== 'phone') return
|
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
// Toggle-Button sichtbar.
|
const quickrow = page.locator('.rmf-quickrow')
|
||||||
const toggle = page.locator('.filter-panel__toggle')
|
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
|
||||||
|
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
|
||||||
// Status-Beschriftung im Drawer ist noch verborgen.
|
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
|
||||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
if (testInfo.project.name !== 'phone') return
|
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
const toggle = page.locator('.filter-panel__toggle')
|
|
||||||
|
|
||||||
// Öffnen → Status-Feld wird sichtbar.
|
// Default: Zucht-Chip ist aktiv.
|
||||||
await toggle.click()
|
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
|
||||||
|
|
||||||
// Schließen → wieder verborgen.
|
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
|
||||||
await toggle.click()
|
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
|
||||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
|
||||||
|
|
||||||
|
// „Alle löschen" entfernt sämtliche Chips.
|
||||||
|
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
|
||||||
|
await expect(page.locator('.rmf-chip')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
if (testInfo.project.name !== 'phone') return
|
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
const toggle = page.locator('.filter-panel__toggle')
|
|
||||||
|
|
||||||
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
const breedingChip = page.locator('.rmf-chip', {
|
||||||
await expect(toggle).toHaveText('Filter')
|
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
|
||||||
|
})
|
||||||
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
await expect(breedingChip).toBeVisible()
|
||||||
await toggle.click()
|
await breedingChip.click()
|
||||||
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
await expect(breedingChip).toHaveCount(0)
|
||||||
await expect(toggle).toHaveText('Filter (1)')
|
|
||||||
|
|
||||||
// Zurücksetzen → Badge weg.
|
|
||||||
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
|
||||||
await expect(toggle).toHaveText('Filter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
if (testInfo.project.name !== 'desktop') return
|
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
|
||||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
|
||||||
|
|
||||||
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
|
||||||
const toggle = page.locator('.filter-panel__toggle')
|
|
||||||
await expect(toggle).toBeHidden()
|
|
||||||
|
|
||||||
// Status-Beschriftung direkt sichtbar.
|
|
||||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
|
||||||
|
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
|
||||||
|
*/
|
||||||
|
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
|
||||||
|
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
|
||||||
|
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
|
||||||
|
const sheet = page.getByRole('dialog', { name: categoryLabel })
|
||||||
|
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
|
||||||
|
await sheet.getByRole('button', { name: de.filterBar.done }).click()
|
||||||
|
}
|
||||||
|
|
||||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
export const uniqueName = (prefix: string) =>
|
export const uniqueName = (prefix: string) =>
|
||||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
|||||||
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.litters
|
||||||
|
|
||||||
|
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
|
||||||
|
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
|
||||||
|
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
|
||||||
|
await expect(kruemelCard).toBeVisible()
|
||||||
|
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
|
||||||
|
|
||||||
|
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
|
||||||
|
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||||
|
await expect(pippaCard).toBeVisible()
|
||||||
|
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
|
||||||
|
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
|
||||||
|
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
|
||||||
|
|
||||||
|
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
|
||||||
|
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||||
|
await expect(bennyCard).toBeVisible()
|
||||||
|
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
|
||||||
|
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
|
||||||
|
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
|
||||||
|
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||||
|
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
|
||||||
|
|
||||||
|
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
|
||||||
|
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||||
|
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
|
||||||
|
await expect(bennyCard.locator('svg')).toBeVisible()
|
||||||
|
})
|
||||||
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
|
||||||
|
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.litters
|
||||||
|
|
||||||
|
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
// Frühverluste-Zeile im def-list: Label + Wert '1'
|
||||||
|
const mortalityRow = page.locator('.def-row', {
|
||||||
|
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||||
|
})
|
||||||
|
await expect(mortalityRow).toBeVisible()
|
||||||
|
await expect(mortalityRow.locator('dd')).toHaveText('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
const name = uniqueName('WurfM')
|
||||||
|
await page.goto('/wuerfe/neu')
|
||||||
|
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
|
||||||
|
|
||||||
|
// Frühverluste eingeben
|
||||||
|
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
|
||||||
|
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
// Frühverluste-Zeile im Detail
|
||||||
|
const mortalityRow = page.locator('.def-row', {
|
||||||
|
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||||
|
})
|
||||||
|
await expect(mortalityRow).toBeVisible()
|
||||||
|
await expect(mortalityRow.locator('dd')).toHaveText('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/neu')
|
||||||
|
|
||||||
|
await page.getByLabel(t.fields.totalBorn).fill('3')
|
||||||
|
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
|
||||||
|
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
|
|||||||
|
|
||||||
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
||||||
if (!orderBy) return rows
|
if (!orderBy) return rows
|
||||||
const [field, dir] = orderBy.split(/\s+/)
|
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
|
||||||
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
|
// is "<field> [desc]"; later keys act as tiebreakers.
|
||||||
|
const keys = orderBy.split(',').map((part) => {
|
||||||
|
const [field, dir] = part.trim().split(/\s+/)
|
||||||
|
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
|
||||||
|
})
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
const av = String(a[field] ?? '')
|
for (const { field, sign } of keys) {
|
||||||
const bv = String(b[field] ?? '')
|
const av = String(a[field] ?? '')
|
||||||
return av < bv ? -sign : av > bv ? sign : 0
|
const bv = String(b[field] ?? '')
|
||||||
|
if (av < bv) return -sign
|
||||||
|
if (av > bv) return sign
|
||||||
|
}
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +139,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
name: 'Frau Erika Muster',
|
name: 'Frau Erika Muster',
|
||||||
address: 'Musterweg 1, 12345 Musterstadt',
|
address: 'Musterweg 1, 12345 Musterstadt',
|
||||||
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
||||||
|
nameSuffix: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (method === 'PUT') return json(route, 204)
|
if (method === 'PUT') return json(route, 204)
|
||||||
@@ -245,6 +254,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||||
|
// Gehege-Bilder (analog zu Tier-Fotos).
|
||||||
|
m = path.match(/^\/enclosures\/([^/]+)\/photos$/)
|
||||||
|
if (m) {
|
||||||
|
if (method === 'GET') return json(route, 200, [])
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||||
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
||||||
if (m) {
|
if (m) {
|
||||||
const isKruemel = m[1] === 'kruemel'
|
const isKruemel = m[1] === 'kruemel'
|
||||||
@@ -361,6 +377,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 201, contract)
|
return json(route, 201, contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEAT-NAMEGEN: /names/suggest
|
||||||
|
if (path === '/names/suggest' && method === 'GET') {
|
||||||
|
if (!db.namesConfigured) {
|
||||||
|
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||||
|
}
|
||||||
|
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||||
|
const allSuggestions = [
|
||||||
|
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||||
|
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||||
|
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||||
|
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||||
|
]
|
||||||
|
const result = letter
|
||||||
|
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||||
|
: allSuggestions
|
||||||
|
return json(route, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
|||||||
// ABGABE: Verträge + KI-Inserat-Flag
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
contracts: MockContract[]
|
contracts: MockContract[]
|
||||||
saleAdConfigured: boolean
|
saleAdConfigured: boolean
|
||||||
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
|
namesConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -89,7 +91,7 @@ function gerbil(
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
gender,
|
gender,
|
||||||
status: 'Active',
|
status: 'Breeding',
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
dateOfDeath: null,
|
dateOfDeath: null,
|
||||||
causeOfDeath: null,
|
causeOfDeath: null,
|
||||||
@@ -106,6 +108,7 @@ function gerbil(
|
|||||||
notes: null,
|
notes: null,
|
||||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||||
isResident: true,
|
isResident: true,
|
||||||
|
profilePhotoUrl: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +121,7 @@ export function seedDb(): MockDb {
|
|||||||
originContactId: 'con-meier',
|
originContactId: 'con-meier',
|
||||||
originBreeder: 'Clan-Kleine-Chaoten',
|
originBreeder: 'Clan-Kleine-Chaoten',
|
||||||
},
|
},
|
||||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
|
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||||
@@ -133,8 +136,10 @@ export function seedDb(): MockDb {
|
|||||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
|
||||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
|
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
|
||||||
|
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
|
||||||
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||||
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||||
{
|
{
|
||||||
@@ -149,12 +154,27 @@ export function seedDb(): MockDb {
|
|||||||
enclosureId: 'enc-leer',
|
enclosureId: 'enc-leer',
|
||||||
notes: 'neugieriger Entdecker',
|
notes: 'neugieriger Entdecker',
|
||||||
},
|
},
|
||||||
|
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
|
||||||
|
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
|
||||||
|
{
|
||||||
|
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
|
||||||
|
status: 'GivenAway' as const,
|
||||||
|
goHomeDate: '2025-05-01',
|
||||||
|
receiverContactId: 'con-huber',
|
||||||
|
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
|
||||||
|
status: 'Deceased' as const,
|
||||||
|
dateOfDeath: '2025-04-15',
|
||||||
|
causeOfDeath: 'Altersschwäche',
|
||||||
|
},
|
||||||
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
||||||
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
|
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
|
||||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||||
@@ -171,9 +191,10 @@ export function seedDb(): MockDb {
|
|||||||
|
|
||||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
const contacts: Contact[] = [
|
const contacts: Contact[] = [
|
||||||
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null },
|
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
|
||||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
|
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
||||||
|
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const colorVarieties = [
|
const colorVarieties = [
|
||||||
@@ -292,5 +313,6 @@ export function seedDb(): MockDb {
|
|||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
|
namesConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.namegen
|
||||||
|
const tf = de.pages.gerbils.form
|
||||||
|
|
||||||
|
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Panel öffnen
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// Vorschläge laden
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf Vorschlag 'Fenrir'
|
||||||
|
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||||
|
|
||||||
|
// Panel geschlossen, Name-Feld befüllt
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||||
|
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByLabel(t.letterLabel).fill('F')
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
|
||||||
|
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||||
|
// Artemis (A) nicht sichtbar
|
||||||
|
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (mockDb) mockDb.namesConfigured = false
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user