Compare commits
24 Commits
aa4552ff84
...
84365bba7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 84365bba7f | |||
| a333bce1b3 | |||
| bbd305861f | |||
| 8aaddea4ef | |||
| 54cc493230 | |||
| bb1b8f8c76 | |||
| 00a3919c9f | |||
| b0e92c363a | |||
| 7262b34ae9 | |||
| ebc70cc0b8 | |||
| f9e672c707 | |||
| fe79be2eee | |||
| 41ee4ee3ab | |||
| f7a1a7724b | |||
| 750619d3d7 | |||
| 48b138bbfd | |||
| c0a7b1a5ee | |||
| a0fa33da42 | |||
| 6b264a9b71 | |||
| 26977d73ff | |||
| 12f5de00a0 | |||
| 0540498e85 | |||
| a87d1ec865 | |||
| 8c1ee75b81 |
15
CLAUDE.md
15
CLAUDE.md
@@ -110,7 +110,13 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
|
||||
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel.
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
|
||||
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
|
||||
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
|
||||
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
|
||||
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
|
||||
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
|
||||
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
|
||||
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||
@@ -175,6 +181,13 @@ Die Anbindung ist im Repo persistiert und lädt automatisch:
|
||||
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||
durable Erkenntnisse ablegen.
|
||||
|
||||
**Subagenten MÜSSEN MemPalace ebenfalls nutzen** — das im Subagent-Prompt IMMER explizit
|
||||
anweisen: zu Aufgabenbeginn den Palast abfragen (am einfachsten per CLI, kein Key nötig:
|
||||
`mempalace search "<keywords>"` bzw. `mempalace search "<…>" --wing gerbilmanager`; falls
|
||||
MCP-Tools verfügbar auch `mempalace_search`/`mempalace_kg_query`) — relevante Domänenregeln,
|
||||
frühere Entscheidungen und Stolperfallen holen, BEVOR im Code gesucht wird; durable
|
||||
Erkenntnisse am Ende ablegen (`mempalace add-drawer`/MCP). Gilt für JEDEN gespawnten Agenten.
|
||||
|
||||
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||
|
||||
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
||||
|
||||
@@ -100,15 +100,138 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("Open", reopened.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Delete -> 204, then 404 on subsequent edit/delete
|
||||
// Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
|
||||
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var delAgain = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
||||
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
|
||||
var restore = await client.PostAsync($"/feedback/{id}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
|
||||
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
// Delete/Restore eines unbekannten Tickets -> 404.
|
||||
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
|
||||
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Farbschlag stimmt nicht.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Direkt (ohne Rückfrage) lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
|
||||
|
||||
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Bitte beschreibe, was noch fehlt.",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
|
||||
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
|
||||
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_list_serve_delete_lifecycle()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Foto vom Fellschlag.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// 1×1-PNG hochladen.
|
||||
const string pngB64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "maus.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = pngB64,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
|
||||
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
|
||||
var attId = att.GetProperty("id").GetString();
|
||||
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
|
||||
Assert.True(att.GetProperty("size").GetInt32() > 0);
|
||||
|
||||
// GET /feedback liefert die Metadaten (ohne Bytes).
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
|
||||
|
||||
// Bytes ausliefern.
|
||||
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
|
||||
|
||||
// Löschen -> danach keine Anhänge mehr.
|
||||
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_rejects_invalid_base64()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "x.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = "###nicht base64###",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Welches Tier ist gemeint?",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Rückfrage stellen (NeedsInfo) …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
|
||||
// … manuell lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Meinst du A oder B?",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
|
||||
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
|
||||
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
|
||||
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
|
||||
/// </summary>
|
||||
public class PushEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PushEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Vapid_public_key_is_exposed()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
|
||||
Assert.True(doc.GetProperty("enabled").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
|
||||
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
|
||||
|
||||
var first = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
|
||||
var second = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
|
||||
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
|
||||
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,73 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, r.Placeholders.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_merges_variants_declared_same_by_breeder()
|
||||
{
|
||||
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
|
||||
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
|
||||
};
|
||||
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
|
||||
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(2, r.MergeClusters.Values.First().Count);
|
||||
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_splits_variants_declared_different_by_breeder()
|
||||
{
|
||||
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
};
|
||||
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … bleiben durch „Different" getrennt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_load_returns_empty_when_file_missing()
|
||||
{
|
||||
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
|
||||
Assert.Empty(d.Decisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_roundtrips_through_json()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
File.WriteAllText(path,
|
||||
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}]}");
|
||||
try
|
||||
{
|
||||
var d = Rpro3Decisions.Load(path);
|
||||
Assert.Single(d.Decisions);
|
||||
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -25,6 +25,8 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
@@ -400,25 +402,28 @@ public class ApplicationContext : DbContext
|
||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
|
||||
// --- Hell variants (IDs 38-48) ---
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
|
||||
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
|
||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||
|
||||
@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
|
||||
public record FeedbackAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
int Size);
|
||||
|
||||
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||
public record FeedbackAttachmentInput(
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string DataBase64);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
@@ -43,7 +56,17 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread);
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread,
|
||||
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
|
||||
DateTimeOffset? ReopenedAt = null,
|
||||
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
|
||||
DateTimeOffset? DeletedAt = null,
|
||||
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
|
||||
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
@@ -56,5 +79,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Question,
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext);
|
||||
string? AgentContext,
|
||||
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>
|
||||
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
|
||||
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
|
||||
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
|
||||
/// </summary>
|
||||
bool? Notify = null);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||
private const int TrashRetentionDays = 30;
|
||||
|
||||
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
|
||||
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
@@ -51,21 +57,50 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||
// auf DateTimeOffset-Spalten übersetzen.
|
||||
var rows = await db.Feedback.ToListAsync();
|
||||
|
||||
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
db.Feedback.RemoveRange(expired);
|
||||
await db.SaveChangesAsync();
|
||||
rows = rows.Except(expired).ToList();
|
||||
}
|
||||
|
||||
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
|
||||
var attMeta = await db.FeedbackAttachments
|
||||
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
|
||||
.ToListAsync();
|
||||
var byTicket = attMeta
|
||||
.GroupBy(a => a.FeedbackId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<FeedbackAttachmentDto>)g
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToList());
|
||||
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db) =>
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
|
||||
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
|
||||
|
||||
if (input.Message is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
@@ -133,6 +168,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.ResolvedAt = resolved
|
||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||
: null;
|
||||
|
||||
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
|
||||
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
|
||||
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
entity.ReopenedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
|
||||
@@ -151,10 +193,46 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.AgentContext = c.Length == 0 ? null : c;
|
||||
}
|
||||
|
||||
// KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen.
|
||||
if (input.Category is not null)
|
||||
{
|
||||
var cat = input.Category.Trim();
|
||||
entity.Category = cat.Length == 0 ? null : cat;
|
||||
}
|
||||
|
||||
// HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert).
|
||||
if (input.Helpful is not null)
|
||||
entity.Helpful = input.Helpful;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
|
||||
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
|
||||
if (input.Notify == true && push.Enabled)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
|
||||
var (title, body) = entity.Status switch
|
||||
{
|
||||
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
|
||||
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
|
||||
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
|
||||
};
|
||||
try
|
||||
{
|
||||
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
|
||||
}
|
||||
}
|
||||
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
|
||||
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
|
||||
// über POST /feedback/{id}/restore.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -162,7 +240,88 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.Feedback.Remove(entity);
|
||||
entity.DeletedAt ??= DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
|
||||
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
|
||||
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
entity.DeletedAt = null;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
|
||||
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
|
||||
{
|
||||
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (ticket is null)
|
||||
return TypedResults.NotFound();
|
||||
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
|
||||
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
// erlaubt sowohl reines base64 als auch eine data:-URL
|
||||
var raw = input.DataBase64;
|
||||
var comma = raw.IndexOf(',');
|
||||
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
|
||||
raw = raw[(comma + 1)..];
|
||||
bytes = Convert.FromBase64String(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
|
||||
}
|
||||
if (bytes.Length == 0)
|
||||
return TypedResults.BadRequest("Datei ist leer.");
|
||||
if (bytes.Length > MaxAttachmentBytes)
|
||||
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
|
||||
|
||||
var att = new FeedbackAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackId = id,
|
||||
FileName = input.FileName.Trim(),
|
||||
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
|
||||
Size = bytes.Length,
|
||||
Data = bytes,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.FeedbackAttachments.Add(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/attachments/{att.Id}",
|
||||
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
|
||||
});
|
||||
|
||||
// ANHANG-Bytes ausliefern (für <img>/Download).
|
||||
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
return TypedResults.File(att.Data, att.ContentType, att.FileName);
|
||||
});
|
||||
|
||||
// ANHANG löschen.
|
||||
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
db.FeedbackAttachments.Remove(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -172,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
|
||||
private static string? Trim(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var t = s.Trim();
|
||||
return t.Length > 140 ? t[..139] + "…" : t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
||||
/// before it gets overwritten by a new round, then clear the current Answer. Only the
|
||||
@@ -216,10 +383,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
private static FeedbackDto ToDto(Feedback f, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||
DeserializeThread(f.Thread));
|
||||
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
|
||||
attachments ?? Array.Empty<FeedbackAttachmentDto>());
|
||||
}
|
||||
}
|
||||
|
||||
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Push;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-PUSH: PWA-Benachrichtigungen für die Züchterin.
|
||||
/// GET /push/vapid-public-key -> öffentlicher VAPID-Schlüssel (für das Abonnieren im Browser).
|
||||
/// POST /push/subscribe -> Browser-Abo speichern (idempotent über die Endpoint-URL).
|
||||
/// POST /push/unsubscribe -> Abo entfernen.
|
||||
/// Das eigentliche Senden passiert über <see cref="PushNotifier"/> (z. B. wenn die KI eine
|
||||
/// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback).
|
||||
/// </summary>
|
||||
public static class PushEndpoints
|
||||
{
|
||||
public record PushSubscriptionInput(string Endpoint, string P256dh, string Auth);
|
||||
public record PushUnsubscribeInput(string Endpoint);
|
||||
|
||||
public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/push").WithTags("Push");
|
||||
|
||||
group.MapGet("/vapid-public-key", (PushNotifier push) =>
|
||||
TypedResults.Ok(new { publicKey = push.PublicKey, enabled = push.Enabled }));
|
||||
|
||||
group.MapPost("/subscribe", async Task<Results<Ok, BadRequest<string>>> (
|
||||
PushSubscriptionInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Endpoint)
|
||||
|| string.IsNullOrWhiteSpace(input.P256dh)
|
||||
|| string.IsNullOrWhiteSpace(input.Auth))
|
||||
return TypedResults.BadRequest("Endpoint, P256dh und Auth sind erforderlich.");
|
||||
|
||||
var existing = await db.WebPushSubscriptions
|
||||
.FirstOrDefaultAsync(s => s.Endpoint == input.Endpoint);
|
||||
if (existing is null)
|
||||
{
|
||||
db.WebPushSubscriptions.Add(new WebPushSubscription
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Endpoint = input.Endpoint,
|
||||
P256dh = input.P256dh,
|
||||
Auth = input.Auth,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.P256dh = input.P256dh;
|
||||
existing.Auth = input.Auth;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
});
|
||||
|
||||
group.MapPost("/unsubscribe", async (PushUnsubscribeInput input, ApplicationContext db) =>
|
||||
{
|
||||
var subs = await db.WebPushSubscriptions
|
||||
.Where(s => s.Endpoint == input.Endpoint)
|
||||
.ToListAsync();
|
||||
if (subs.Count > 0)
|
||||
{
|
||||
db.WebPushSubscriptions.RemoveRange(subs);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,6 +36,12 @@
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
|
||||
damit der Importer sie zur Laufzeit findet. -->
|
||||
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
|
||||
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
|
||||
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
|
||||
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
|
||||
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
|
||||
///
|
||||
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
|
||||
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
|
||||
/// der ganze Cluster wird mitgezogen).
|
||||
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
|
||||
/// Zusammenlegen über Gruppengrenzen).
|
||||
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
|
||||
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
|
||||
///
|
||||
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
|
||||
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
|
||||
/// verschieben sich, sobald sich die Clusterbildung ändert.
|
||||
/// </summary>
|
||||
public sealed class Rpro3Decisions
|
||||
{
|
||||
public List<Rpro3Decision> Decisions { get; set; } = new();
|
||||
|
||||
public static Rpro3Decisions Empty { get; } = new();
|
||||
|
||||
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
|
||||
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
|
||||
public static Rpro3Decisions Load(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return Empty;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
|
||||
return d ?? Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
|
||||
public IEnumerable<IReadOnlyList<string>> SameGroups()
|
||||
{
|
||||
foreach (var d in Decisions)
|
||||
foreach (var g in d.Same)
|
||||
if (g.Count >= 2) yield return g;
|
||||
}
|
||||
|
||||
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
|
||||
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
|
||||
public Dictionary<string, int> BuildSplitGroups()
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
int gid = 0;
|
||||
foreach (var d in Decisions)
|
||||
{
|
||||
if (d.Different.Count < 2) continue;
|
||||
foreach (var grp in d.Different)
|
||||
{
|
||||
gid++;
|
||||
foreach (var rid in grp)
|
||||
map[rid] = gid;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
|
||||
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
|
||||
{
|
||||
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
|
||||
foreach (var d in Decisions)
|
||||
foreach (var (rid, ov) in d.Fields)
|
||||
map[rid] = ov;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Rpro3Decision
|
||||
{
|
||||
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
|
||||
public string? Ticket { get; set; }
|
||||
public List<List<string>> Same { get; set; } = new();
|
||||
public List<List<string>> Different { get; set; } = new();
|
||||
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class Rpro3FieldOverride
|
||||
{
|
||||
public string? Color { get; set; }
|
||||
public string? Dob { get; set; } // ISO yyyy-MM-dd
|
||||
public string? Origin { get; set; }
|
||||
public bool? Resident { get; set; }
|
||||
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
return UnknownValues.Contains(v) ? "" : v;
|
||||
}
|
||||
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals) => Run(animals, null);
|
||||
|
||||
/// <summary>
|
||||
/// Wie <see cref="Run(IReadOnlyList{Rpro3Animal})"/>, berücksichtigt aber manuelle
|
||||
/// Entscheidungen der Züchterin (<paramref name="decisions"/>): „Different"-Gruppen
|
||||
/// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über
|
||||
/// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit).
|
||||
/// </summary>
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals, Rpro3Decisions? decisions)
|
||||
{
|
||||
var result = new DedupResult();
|
||||
decisions ??= Rpro3Decisions.Empty;
|
||||
var splitGroup = decisions.BuildSplitGroups();
|
||||
|
||||
foreach (var a in animals)
|
||||
{
|
||||
@@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
for (int j = i + 1; j < group.Count; j++)
|
||||
{
|
||||
var a = group[i]; var b = group[j];
|
||||
// Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen.
|
||||
if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb)
|
||||
continue;
|
||||
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
|
||||
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
|
||||
Union(a.Rid, b.Rid);
|
||||
}
|
||||
}
|
||||
|
||||
// Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen
|
||||
// (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids,
|
||||
// die es im Datensatz auch gibt, werden berücksichtigt.
|
||||
var knownRids = new HashSet<string>(animals.Select(a => a.Rid), StringComparer.Ordinal);
|
||||
foreach (var grp in decisions.SameGroups())
|
||||
{
|
||||
string? anchor = null;
|
||||
foreach (var rid in grp)
|
||||
{
|
||||
if (!knownRids.Contains(rid)) continue;
|
||||
parent.TryAdd(rid, rid);
|
||||
if (anchor is null) anchor = rid;
|
||||
else Union(anchor, rid);
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster sammeln
|
||||
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
||||
foreach (var a in animals)
|
||||
|
||||
@@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _photoRoot;
|
||||
private readonly Rpro3Decisions _decisions;
|
||||
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
|
||||
|
||||
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
||||
// Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
|
||||
// überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
|
||||
var decPath = config["Rpro3:DecisionsPath"]
|
||||
?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
|
||||
_decisions = Rpro3Decisions.Load(decPath);
|
||||
}
|
||||
|
||||
private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
|
||||
_fieldIndex ??= _decisions.BuildFieldIndex();
|
||||
|
||||
// ───────────────────────── ANALYZE ─────────────────────────
|
||||
|
||||
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
||||
@@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
||||
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
|
||||
@@ -407,6 +417,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
|
||||
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
|
||||
|
||||
// Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
|
||||
// rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
|
||||
Rpro3FieldOverride? ov = null;
|
||||
foreach (var m in members)
|
||||
if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
|
||||
bool? residentOverride = ov?.Resident;
|
||||
if (ov is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
|
||||
}
|
||||
|
||||
var gp = new GerbilPlan
|
||||
{
|
||||
Id = id,
|
||||
@@ -419,7 +442,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
Genotype = CleanGenotype(fcode),
|
||||
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
||||
OriginContactId = HerkContactId(herkId),
|
||||
IsResident = resident,
|
||||
IsResident = residentOverride ?? resident,
|
||||
IsCastrated = members.Any(m => m.IsCastrated),
|
||||
MotherRid = rep.MidRaw,
|
||||
FatherRid = rep.PidRaw,
|
||||
@@ -440,6 +463,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
}
|
||||
|
||||
gp.Notes = BuildNotes(members);
|
||||
if (!string.IsNullOrWhiteSpace(ov?.Note))
|
||||
gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
|
||||
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||
plan.Gerbils[id] = gp;
|
||||
}
|
||||
|
||||
303
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
303
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"name": "Eiji",
|
||||
"ticket": "6aad4527-0401-43de-bda9-f649042bd1db",
|
||||
"same": [
|
||||
[
|
||||
"u735",
|
||||
"u469"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Momo",
|
||||
"ticket": "7937b283-2056-435d-8821-c68ada8b1bd2",
|
||||
"same": [
|
||||
[
|
||||
"u7453",
|
||||
"u7345"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u1014"
|
||||
],
|
||||
[
|
||||
"u7453"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Samuel",
|
||||
"ticket": "6b3f28c6-47a5-4752-be94-93cda5d6d29c",
|
||||
"same": [
|
||||
[
|
||||
"u2207",
|
||||
"u263"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u2207"
|
||||
],
|
||||
[
|
||||
"u144"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Female",
|
||||
"ticket": "2d77eefe-f3a1-4be4-a8ee-7cb73cee14ab",
|
||||
"different": [
|
||||
[
|
||||
"u649"
|
||||
],
|
||||
[
|
||||
"u683"
|
||||
],
|
||||
[
|
||||
"u647"
|
||||
],
|
||||
[
|
||||
"u685"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kennedy",
|
||||
"ticket": "e5fc2c01-d443-445b-8530-ff5b86be27b1",
|
||||
"same": [
|
||||
[
|
||||
"354",
|
||||
"u6929"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"354": {
|
||||
"color": "Blau, meliert-Starkschecke"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fegur",
|
||||
"ticket": "746a965b-a2e7-4d88-a497-640784b66818",
|
||||
"same": [
|
||||
[
|
||||
"231",
|
||||
"u6395"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tuli",
|
||||
"ticket": "06dd2e3d-5bed-469a-b975-d698712fd8ac",
|
||||
"same": [
|
||||
[
|
||||
"229",
|
||||
"u2255"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bura",
|
||||
"ticket": "7dbeb108-453d-4930-b615-a1054f0df8ad",
|
||||
"same": [
|
||||
[
|
||||
"228",
|
||||
"u2128"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"228": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mister X",
|
||||
"ticket": "ebd80367-a80c-426e-9541-1325274ba206",
|
||||
"same": [
|
||||
[
|
||||
"227",
|
||||
"u7065"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"227": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Zoey",
|
||||
"ticket": "3ad458f7-645f-447f-b217-7b3b7382f380",
|
||||
"different": [
|
||||
[
|
||||
"224"
|
||||
],
|
||||
[
|
||||
"u2887"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"224": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Max",
|
||||
"ticket": "625a86ab-51d8-4612-8292-5dbd93be2a5c",
|
||||
"different": [
|
||||
[
|
||||
"133"
|
||||
],
|
||||
[
|
||||
"u7533"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Akiro",
|
||||
"ticket": "9216ab27-407d-4f4c-abb2-51160fa33683",
|
||||
"same": [
|
||||
[
|
||||
"u1227",
|
||||
"u1865"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u1339"
|
||||
],
|
||||
[
|
||||
"u1227"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u1227": {
|
||||
"dob": "2008-02-05",
|
||||
"color": "Polarfuchs, hell",
|
||||
"note": "Abweichendes Geburtsdatum in RennmausPro: 2008-02-06 (laut Variante C)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Merlin",
|
||||
"ticket": "d748b16a-18b5-48ec-b8a6-a4c8df66ad80",
|
||||
"same": [
|
||||
[
|
||||
"u2859",
|
||||
"u2626"
|
||||
],
|
||||
[
|
||||
"u7537",
|
||||
"u7536"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u7537": {
|
||||
"dob": "2021-02-10",
|
||||
"note": "Abweichendes Geburtsdatum in RennmausPro: 2012-08-07 (laut Variante G)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Snickers",
|
||||
"ticket": "f469352a-644b-43a1-9fb2-884600e4b9d3",
|
||||
"same": [
|
||||
[
|
||||
"384",
|
||||
"u6937"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"384": {
|
||||
"resident": false,
|
||||
"note": "Reiner Zuchtvorfahre (kein eigenes Zuchttier)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Bali",
|
||||
"ticket": "8c7246da-a9c6-406d-b857-8d9fcb9884f1",
|
||||
"same": [
|
||||
[
|
||||
"u3410",
|
||||
"u6997"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aladin",
|
||||
"ticket": "08be9baa-b0c8-4dd8-8fbe-daea1d647344",
|
||||
"different": [
|
||||
[
|
||||
"u480"
|
||||
],
|
||||
[
|
||||
"u1098"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lexi",
|
||||
"ticket": "16b449e8-50b1-4bed-b772-ab2c461a8984",
|
||||
"different": [
|
||||
[
|
||||
"u275"
|
||||
],
|
||||
[
|
||||
"u648"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pinto",
|
||||
"ticket": "08d00bca-70c0-4c14-88c8-0d3181313ba3",
|
||||
"same": [
|
||||
[
|
||||
"385",
|
||||
"u3709"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"385": {
|
||||
"note": "Angaben/Schenkung von Variante A (eigenes Tier) übernehmen."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Jasper",
|
||||
"ticket": "46951886-2e1c-40c8-a567-ca74e2a20de7",
|
||||
"different": [
|
||||
[
|
||||
"351"
|
||||
],
|
||||
[
|
||||
"u1289"
|
||||
],
|
||||
[
|
||||
"u7492"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u7492": {
|
||||
"origin": "Clan of Colourful furry gerbils"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Nestor",
|
||||
"ticket": "61b31c30-a24e-4dea-8ecb-9daeae5affd6",
|
||||
"same": [
|
||||
[
|
||||
"200",
|
||||
"u3459",
|
||||
"u2634"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Gen5FuchsschimmelHetSeedFix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC dd ee gg PP spsp rere");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC dd ee gg pp spsp rere");
|
||||
}
|
||||
}
|
||||
}
|
||||
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackReopenedAndSoftDelete : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackCategoryAndHelpful : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Category",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Helpful",
|
||||
table: "Feedback",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Category",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Helpful",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
ContentType = table.Column<string>(type: "text", nullable: false),
|
||||
Size = table.Column<int>(type: "integer", nullable: false),
|
||||
Data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackAttachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.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 WebPushSubscriptions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WebPushSubscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Endpoint = table.Column<string>(type: "text", nullable: false),
|
||||
P256dh = table.Column<string>(type: "text", nullable: false),
|
||||
Auth = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WebPushSubscriptions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 40
|
||||
},
|
||||
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 43
|
||||
},
|
||||
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 46
|
||||
},
|
||||
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 48
|
||||
},
|
||||
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
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 = "Dilute Blaufuchs",
|
||||
SortOrder = 53
|
||||
},
|
||||
@@ -893,6 +893,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -906,6 +909,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -915,6 +921,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool?>("Helpful")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -925,6 +934,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Question")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReopenedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -948,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Feedback");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<Guid>("FeedbackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Size")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeedbackAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1602,6 +1646,32 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("WaitingListEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebPushSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -55,6 +55,33 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
|
||||
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
|
||||
/// merely had an open question, got marked resolved, and were reopened (those just return
|
||||
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReopenedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
|
||||
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
|
||||
/// (DeletedAt → null) from there. null = not deleted.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optionale Kategorie/Thema (von der KI gesetzt) für Filter + Übersicht, z. B.
|
||||
/// "Genetik", "Import", "Stammbaum", "Daten", "Foto". Frei wählbar (kein FK), null = ohne.
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// War die Lösung hilfreich? (Daumen hoch/runter auf gelösten Tickets.) true = 👍,
|
||||
/// false = 👎 (löst i. d. R. ein Wiederöffnen aus), null = noch keine Rückmeldung.
|
||||
/// </summary>
|
||||
public bool? Helpful { get; set; }
|
||||
|
||||
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||
public string? Question { get; set; }
|
||||
|
||||
|
||||
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
|
||||
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
|
||||
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
|
||||
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
|
||||
/// </summary>
|
||||
public class FeedbackAttachment
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
|
||||
public Guid FeedbackId { get; set; }
|
||||
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public required string ContentType { get; set; }
|
||||
|
||||
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
|
||||
public int Size { get; set; }
|
||||
|
||||
/// <summary>The raw file bytes.</summary>
|
||||
public required byte[] Data { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A browser's Web-Push subscription (PWA-Benachrichtigungen). Single-user app, daher i. d. R.
|
||||
/// nur wenige Einträge (ein Gerät der Züchterin). Endpoint ist eindeutig; veraltete Abos werden
|
||||
/// beim Senden (404/410) automatisch entfernt.
|
||||
/// </summary>
|
||||
public class WebPushSubscription
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Die vom Browser vergebene Push-Endpoint-URL (eindeutig).</summary>
|
||||
public required string Endpoint { get; set; }
|
||||
|
||||
/// <summary>Öffentlicher Client-Schlüssel (keys.p256dh).</summary>
|
||||
public required string P256dh { get; set; }
|
||||
|
||||
/// <summary>Auth-Secret des Clients (keys.auth).</summary>
|
||||
public required string Auth { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilMan
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -127,6 +128,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapPushEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
app.MapSaleReservationEndpoints();
|
||||
app.MapWaitingListEndpoints();
|
||||
|
||||
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WebPush;
|
||||
|
||||
namespace GerbilManagerWebAPI.Push
|
||||
{
|
||||
/// <summary>
|
||||
/// Versendet Web-Push-Benachrichtigungen an alle gespeicherten Abos (PWA der Züchterin).
|
||||
/// Ist kein VAPID-Schlüsselpaar konfiguriert, sind die Methoden No-Ops (Push deaktiviert).
|
||||
/// Veraltete Abos (404/410) werden beim Senden entfernt.
|
||||
/// </summary>
|
||||
public class PushNotifier
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PushNotifier> _log;
|
||||
private readonly VapidDetails? _vapid;
|
||||
|
||||
public PushNotifier(IConfiguration config, IServiceScopeFactory scopeFactory, ILogger<PushNotifier> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_log = log;
|
||||
var subject = config["WebPush:Subject"];
|
||||
var publicKey = config["WebPush:PublicKey"];
|
||||
var privateKey = config["WebPush:PrivateKey"];
|
||||
if (!string.IsNullOrWhiteSpace(subject)
|
||||
&& !string.IsNullOrWhiteSpace(publicKey)
|
||||
&& !string.IsNullOrWhiteSpace(privateKey))
|
||||
{
|
||||
_vapid = new VapidDetails(subject, publicKey, privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>true, wenn Push konfiguriert ist (VAPID-Schlüssel vorhanden).</summary>
|
||||
public bool Enabled => _vapid is not null;
|
||||
|
||||
public string? PublicKey => _vapid?.PublicKey;
|
||||
|
||||
/// <summary>Eine Benachrichtigung an ALLE Abos senden. Fehler einzelner Abos werden geschluckt.</summary>
|
||||
public async Task NotifyAllAsync(string title, string body, string url, CancellationToken ct = default)
|
||||
{
|
||||
if (_vapid is null) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var subs = await db.WebPushSubscriptions.ToListAsync(ct);
|
||||
if (subs.Count == 0) return;
|
||||
|
||||
var client = new WebPushClient();
|
||||
var payload = JsonSerializer.Serialize(new { title, body, url });
|
||||
var stale = new List<WebPushSubscription>();
|
||||
foreach (var s in subs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushSub = new WebPush.PushSubscription(s.Endpoint, s.P256dh, s.Auth);
|
||||
await client.SendNotificationAsync(pushSub, payload, _vapid);
|
||||
}
|
||||
catch (WebPushException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound
|
||||
or System.Net.HttpStatusCode.Gone)
|
||||
{
|
||||
stale.Add(s); // Abo abgelaufen/abgemeldet -> aufräumen
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Push an {Endpoint} fehlgeschlagen", s.Endpoint);
|
||||
}
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
{
|
||||
db.WebPushSubscriptions.RemoveRange(stale);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,10 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"WebPush": {
|
||||
"Subject": "mailto:zucht-kleine-chaoten@example.com",
|
||||
"PublicKey": "BGRqOfadYjRZFJaFYjPq2cThD7MpMHJHiRPrv9ZGSHJAl5eeqeCCZHj4I4_h-RcrjCF2MXhN34dI_RcACz36qnk",
|
||||
"PrivateKey": "_HJTA1qtsJ-azLAaRFyxJqmJCym2qBQaSbYFCLjVSAA"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,12 +431,71 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
reopenedAt: null,
|
||||
deletedAt: null,
|
||||
category: null,
|
||||
helpful: null,
|
||||
attachments: [],
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return json(route, 200, [...db.feedback].reverse())
|
||||
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
|
||||
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
|
||||
return json(route, 200, rows)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
|
||||
if (path === '/push/vapid-public-key' && method === 'GET') {
|
||||
return json(route, 200, { enabled: false, publicKey: null })
|
||||
}
|
||||
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
|
||||
return json(route, 200, {})
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
|
||||
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
||||
if (attUpload && method === 'POST') {
|
||||
const fid = decodeURIComponent(attUpload[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
|
||||
const meta = {
|
||||
id: newId('att'),
|
||||
fileName: body.fileName ?? 'datei',
|
||||
contentType: body.contentType ?? 'application/octet-stream',
|
||||
size: (body.dataBase64 ?? '').length,
|
||||
}
|
||||
const list = (row.attachments as unknown[] | undefined) ?? []
|
||||
list.push(meta)
|
||||
row.attachments = list
|
||||
return json(route, 201, meta)
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
|
||||
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
|
||||
if (attDelete && method === 'DELETE') {
|
||||
const attId = decodeURIComponent(attDelete[1])
|
||||
for (const f of db.feedback) {
|
||||
const list = (f.attachments as { id: string }[] | undefined) ?? []
|
||||
const i = list.findIndex((a) => a.id === attId)
|
||||
if (i >= 0) {
|
||||
list.splice(i, 1)
|
||||
f.attachments = list
|
||||
return json(route, 204)
|
||||
}
|
||||
}
|
||||
return json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
|
||||
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
|
||||
if (restoreMatch) {
|
||||
const fid = decodeURIComponent(restoreMatch[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'POST') {
|
||||
row.deletedAt = null
|
||||
return json(route, 200, row)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
@@ -454,8 +513,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
answer?: string
|
||||
fixNote?: string
|
||||
agentContext?: string
|
||||
category?: string
|
||||
helpful?: boolean
|
||||
}
|
||||
const row = db.feedback[idx]
|
||||
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
|
||||
const wasResolved = row.status === 'Resolved'
|
||||
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
@@ -504,6 +568,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
? s
|
||||
: 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
|
||||
// wieder geöffnet wird (s. Backend).
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
row.reopenedAt = new Date().toISOString()
|
||||
}
|
||||
// Changelog (fixNote) — laienverständlich, sichtbar.
|
||||
if (typeof body.fixNote === 'string') {
|
||||
@@ -515,10 +583,17 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
const c = body.agentContext.trim()
|
||||
row.agentContext = c.length === 0 ? null : c
|
||||
}
|
||||
if (typeof body.category === 'string') {
|
||||
const cat = body.category.trim()
|
||||
row.category = cat.length === 0 ? null : cat
|
||||
}
|
||||
if (typeof body.helpful === 'boolean') row.helpful = body.helpful
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.feedback.splice(idx, 1)
|
||||
// Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
|
||||
const target = db.feedback[idx]
|
||||
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
|
||||
@@ -442,6 +442,7 @@ export function seedDb(): MockDb {
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-10T09:00:00Z',
|
||||
status: 'Resolved',
|
||||
category: 'Stammbaum',
|
||||
resolvedAt: '2026-06-12T14:00:00Z',
|
||||
question: null,
|
||||
answer: null,
|
||||
@@ -480,6 +481,29 @@ export function seedDb(): MockDb {
|
||||
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Beantwortetes Ticket (Answered): die KI hat noch NICHT reagiert. Der Erst-Text ist
|
||||
// jetzt gesperrt (kein „Bearbeiten"), nur die neueste Antwort ist noch änderbar.
|
||||
id: 'feedback-answered',
|
||||
message: 'Bei Karlsson stimmt der Farbschlag nicht.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'karlsson',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Karlsson',
|
||||
url: 'http://localhost:5173/rennmaeuse/karlsson',
|
||||
clientTimestamp: '2026-06-16T09:00:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-16T09:00:00Z',
|
||||
status: 'Answered',
|
||||
resolvedAt: null,
|
||||
question: 'Welcher Farbschlag ist korrekt?',
|
||||
answer: 'Karlsson ist Blau, nicht Schwarz.',
|
||||
answeredAt: '2026-06-16T18:00:00Z',
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
|
||||
|
||||
128
gerbil-manager-web/e2e/scroll-restore.spec.ts
Normal file
128
gerbil-manager-web/e2e/scroll-restore.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Scroll-Wiederherstellung (useScrollRestoration im AppShell).
|
||||
*
|
||||
* Regression: Liste runterscrollen → Eintrag öffnen → Browser-Zurück → die Position
|
||||
* muss erhalten bleiben (vorher sprang sie auf 0, weil beim Wegnavigieren der kollabierende
|
||||
* Inhalt ein scroll→0 auslöste, das die gemerkte Position überschrieb).
|
||||
*
|
||||
* Gescrollt wird der Container `.app-main` (nicht das Fenster). Läuft in beiden Projekten
|
||||
* (desktop + phone) — die Rennmausliste ist in beiden Viewports hoch genug zum Scrollen.
|
||||
*/
|
||||
import { test, expect, skipUnlessMock, de } from './fixtures'
|
||||
|
||||
const SCROLLER = '.app-main'
|
||||
|
||||
async function scrollerTop(page: import('@playwright/test').Page) {
|
||||
return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Realistisch runterscrollen: ein Warm-up-Schritt (synchronisiert die gemerkte Höhe nach
|
||||
* dem ersten Render) + der eigentliche Schritt — wie echtes Scrollen, das viele Events feuert.
|
||||
*/
|
||||
async function scrollDown(page: import('@playwright/test').Page, target: number) {
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) el.scrollTop = 60
|
||||
}, SCROLLER)
|
||||
await page.waitForTimeout(80)
|
||||
await page.evaluate(
|
||||
([sel, t]) => {
|
||||
const el = document.querySelector(sel as string)
|
||||
if (el) el.scrollTop = Math.min(t as number, el.scrollHeight - el.clientHeight)
|
||||
},
|
||||
[SCROLLER, target] as const,
|
||||
)
|
||||
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
|
||||
}
|
||||
|
||||
test.describe('Scroll-Wiederherstellung', () => {
|
||||
test.beforeEach(() => skipUnlessMock())
|
||||
|
||||
test('Rennmausliste: Position überlebt Detail-Öffnen + Browser-Zurück', async ({ page }) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
// Warten bis Tierkarten-Links da sind.
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
|
||||
// Container muss scrollbar sein, sonst ist der Test sinnlos.
|
||||
const scrollable = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
return el ? el.scrollHeight > el.clientHeight + 50 : false
|
||||
}, SCROLLER)
|
||||
expect(scrollable, 'Liste muss scrollbar sein').toBeTruthy()
|
||||
|
||||
// Ein Stück runterscrollen.
|
||||
await scrollDown(page, 400)
|
||||
const before = await scrollerTop(page)
|
||||
expect(before).toBeGreaterThan(50)
|
||||
|
||||
// Einen AKTUELL SICHTBAREN Tier-Link öffnen (kein Auto-Scroll, wie ein echter Klick).
|
||||
const href = await page.evaluate(() => {
|
||||
for (const a of document.querySelectorAll('a[href^="/rennmaeuse/"]')) {
|
||||
const r = a.getBoundingClientRect()
|
||||
if (r.top >= 0 && r.bottom <= window.innerHeight && a.getAttribute('href') !== '/rennmaeuse/neu') {
|
||||
;(a as HTMLElement).click()
|
||||
return a.getAttribute('href')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
expect(href).toBeTruthy()
|
||||
await expect(page).toHaveURL(/\/rennmaeuse\/[^/]+$/)
|
||||
|
||||
// Zurück — Position muss (nahezu) wiederhergestellt sein.
|
||||
await page.goBack()
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => scrollerTop(page), { timeout: 4000, message: 'Scrollposition wiederhergestellt' })
|
||||
.toBeGreaterThan(before - 30)
|
||||
})
|
||||
|
||||
test('Rennmausliste: Position überlebt App-Hintergrund (visibilitychange)', async ({ page }) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
await scrollDown(page, 400)
|
||||
const before = await scrollerTop(page)
|
||||
test.skip(before < 50, 'Liste in diesem Viewport nicht hoch genug')
|
||||
|
||||
// App in den Hintergrund (Handy-Sperre) und zurück simulieren.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
// Inhalt „springt" (wie beim Aufwachen) künstlich nach oben …
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) el.scrollTop = 0
|
||||
}, SCROLLER)
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
|
||||
// … und muss wiederhergestellt werden.
|
||||
await expect
|
||||
.poll(async () => scrollerTop(page), { timeout: 4000 })
|
||||
.toBeGreaterThan(before - 30)
|
||||
})
|
||||
|
||||
test('Tickets: gewählter Tab überlebt Browser-Zurück (nicht zurück auf „Offen")', async ({ page }) => {
|
||||
const tt = de.feedback.tickets
|
||||
await page.goto('/hilfe/tickets')
|
||||
// In die „Geschlossen"-Ansicht wechseln.
|
||||
await page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }).click()
|
||||
await expect(
|
||||
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
|
||||
).toBeVisible()
|
||||
|
||||
// Über die Brotkrümel weg und per Browser-Zurück wieder her.
|
||||
await page.locator('.tickets-breadcrumb a[href="/hilfe"]').click()
|
||||
await expect(page).toHaveURL(/\/hilfe$/)
|
||||
await page.goBack()
|
||||
|
||||
// Tab muss weiterhin „Geschlossen" sein (vorher sprang er zurück auf „Offen").
|
||||
await expect(
|
||||
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,8 @@ test.describe('Meine Tickets', () => {
|
||||
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
|
||||
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
|
||||
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
|
||||
await expect(open.locator('.tickets-filter__count')).toHaveText('1')
|
||||
// „Offen" = Open + Answered → das offene + das beantwortete Ticket = 2.
|
||||
await expect(open.locator('.tickets-filter__count')).toHaveText('2')
|
||||
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
|
||||
|
||||
// „Offen": nur das offene Ticket.
|
||||
@@ -131,6 +132,20 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('beantwortetes Ticket: Erst-Text gesperrt, nur die neueste Antwort ist änderbar', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// „Offen"-Ansicht (Default) zeigt auch beantwortete Tickets (Answered).
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Karlsson ist Blau' })
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Erst-Text ist gesperrt: kein „Bearbeiten"-Button mehr, sobald es eine Antwort gibt.
|
||||
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
|
||||
// Die neueste Antwort ist dagegen noch änderbar (KI hat noch nicht reagiert → Answered).
|
||||
await expect(card.getByRole('button', { name: tt.amendAnswer })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -173,7 +188,9 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
@@ -185,8 +202,134 @@ test.describe('Meine Tickets', () => {
|
||||
acceptNextDialog(page)
|
||||
await card.getByRole('button', { name: tt.delete }).click()
|
||||
|
||||
// Aus „Geschlossen" verschwunden …
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
|
||||
await selectView(page, tt.filters.deleted)
|
||||
const trashed = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(trashed).toBeVisible()
|
||||
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
|
||||
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
|
||||
|
||||
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
|
||||
await trashed.getByRole('button', { name: tt.restore }).click()
|
||||
await expect(
|
||||
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
|
||||
).toHaveCount(0)
|
||||
await selectView(page, tt.filters.closed)
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await card.getByRole('button', { name: tt.reopen }).click()
|
||||
|
||||
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
|
||||
await selectView(page, tt.filters.dialog)
|
||||
const reopened = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Volltextsuche filtert die Ticket-Liste', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const search = page.getByPlaceholder(tt.searchPlaceholder)
|
||||
await search.fill('Krümel')
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
|
||||
await search.fill('xyzgibtsnicht')
|
||||
await expect(page.getByText(tt.emptySearch)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kategorie-Filter zeigt nur Tickets der gewählten Kategorie', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
// Das Krümel-Ticket hat die Kategorie „Stammbaum" → als Chip sichtbar.
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card.locator('.ticket-card__category')).toHaveText('Stammbaum')
|
||||
|
||||
// Über das Kategorie-Menü filtern.
|
||||
await page.getByLabel(tt.categoryFilterLabel).selectOption('Stammbaum')
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('👍 auf gelöstem Ticket speichert Rückmeldung; 👎 öffnet es wieder', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card.getByText(tt.helpfulQuestion)).toBeVisible()
|
||||
|
||||
// 👍 → Dank-Text, kein Wiederöffnen.
|
||||
await card.getByRole('button', { name: tt.helpfulYes }).click()
|
||||
await expect(card.getByText(tt.helpfulThanks)).toBeVisible()
|
||||
})
|
||||
|
||||
test('👎 auf gelöstem Ticket öffnet es als Rückfrage wieder', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await card.getByRole('button', { name: tt.helpfulNo }).click()
|
||||
|
||||
// Verlässt „Geschlossen", landet in „Rückfragen" mit Bitte um Infos.
|
||||
await selectView(page, tt.filters.dialog)
|
||||
const reopened = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
|
||||
|
||||
// 1×1-PNG als Datei hochladen.
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
await card.locator('input[type="file"]').setInputFiles({
|
||||
name: 'maus.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
})
|
||||
|
||||
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
|
||||
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
49
gerbil-manager-web/public/sw.js
Normal file
49
gerbil-manager-web/public/sw.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Service Worker — NUR für Web-Push-Benachrichtigungen.
|
||||
* BEWUSST OHNE fetch-Handler/Caching, damit das Laden der App niemals beeinflusst wird.
|
||||
*/
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {}
|
||||
try {
|
||||
data = event.data ? event.data.json() : {}
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
const title = data.title || 'Zucht der kleinen Chaoten'
|
||||
const body = data.body || ''
|
||||
const url = data.url || '/hilfe/tickets'
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { url },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/hilfe/tickets'
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) {
|
||||
if ('navigate' in client) {
|
||||
try {
|
||||
client.navigate(url)
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -3,11 +3,18 @@ import { de } from '../strings/de'
|
||||
/**
|
||||
* Basis-URL der GerbilManagerWebAPI.
|
||||
* Dev (Vite dev server / e2e mock): absolute URL, damit Playwright-Route-Interception greift.
|
||||
* Der Host wird aus der AKTUELLEN Adresse abgeleitet (window.location.hostname), damit der
|
||||
* Zugriff vom Handy/anderen LAN-Gerät funktioniert: lädt die Seite von 192.168.x.y:5173,
|
||||
* geht die API an 192.168.x.y:5179 (nicht „localhost", was auf dem Handy das Handy selbst wäre).
|
||||
* Auf dem Rechner/in e2e ist der Host „localhost" → unverändert http://localhost:5179.
|
||||
* Produktion (Vite build → nginx): relativer Pfad /api; nginx proxyt zum API-Container.
|
||||
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer (LAN-IP + Port für Handy-Zugriff).
|
||||
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer.
|
||||
*/
|
||||
const devApiBase = `http://${
|
||||
typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : 'localhost'
|
||||
}:5179`
|
||||
export const API_BASE_URL: string =
|
||||
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : 'http://localhost:5179')
|
||||
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : devApiBase)
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number | null
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
|
||||
import { api } from './client'
|
||||
import { api, API_BASE_URL } from './client'
|
||||
|
||||
const RESOURCE = '/feedback'
|
||||
|
||||
/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */
|
||||
export interface FeedbackAttachment {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Which view a report was filed from (matches the backend Context contract). */
|
||||
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
||||
|
||||
@@ -50,6 +58,22 @@ export interface Feedback {
|
||||
createdAt: string
|
||||
status: FeedbackStatus
|
||||
resolvedAt: string | null
|
||||
/**
|
||||
* Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||
* Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt.
|
||||
*/
|
||||
reopenedAt: string | null
|
||||
/**
|
||||
* Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar).
|
||||
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
|
||||
*/
|
||||
deletedAt: string | null
|
||||
/** Optionale Kategorie/Thema (von der KI gesetzt), z. B. „Genetik", „Import". null = ohne. */
|
||||
category: string | null
|
||||
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
|
||||
helpful: boolean | null
|
||||
/** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */
|
||||
attachments: FeedbackAttachment[]
|
||||
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
|
||||
question: string | null
|
||||
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
||||
@@ -85,6 +109,10 @@ export interface FeedbackUpdate {
|
||||
fixNote?: string
|
||||
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
|
||||
agentContext?: string
|
||||
/** Kategorie/Thema setzen (leer ⇒ löschen). */
|
||||
category?: string
|
||||
/** 👍/👎-Rückmeldung auf ein gelöstes Ticket. */
|
||||
helpful?: boolean
|
||||
}
|
||||
|
||||
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||
@@ -106,7 +134,50 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
|
||||
return updateFeedback(id, { answer })
|
||||
}
|
||||
|
||||
/** Delete a ticket. */
|
||||
/** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */
|
||||
export function deleteFeedback(id: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${id}`)
|
||||
}
|
||||
|
||||
/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */
|
||||
export function restoreFeedback(id: string): Promise<Feedback> {
|
||||
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
|
||||
}
|
||||
|
||||
/** Volle URL zu den Bytes eines Anhangs (für <img src> / Download). */
|
||||
export function attachmentUrl(attachmentId: string): string {
|
||||
return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}`
|
||||
}
|
||||
|
||||
/** Einen Anhang (base64) an ein Ticket hochladen. */
|
||||
export function uploadAttachment(
|
||||
feedbackId: string,
|
||||
body: { fileName: string; contentType: string; dataBase64: string },
|
||||
): Promise<FeedbackAttachment> {
|
||||
return api.post<FeedbackAttachment>(`${RESOURCE}/${feedbackId}/attachments`, body)
|
||||
}
|
||||
|
||||
/** Einen Anhang löschen. */
|
||||
export function deleteAttachment(attachmentId: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/attachments/${attachmentId}`)
|
||||
}
|
||||
|
||||
/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */
|
||||
export function readFileAsUpload(
|
||||
file: File,
|
||||
): Promise<{ fileName: string; contentType: string; dataBase64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result)
|
||||
const comma = result.indexOf(',')
|
||||
resolve({
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
dataBase64: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
})
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||
import { useScrollRestoration } from '../hooks/useScrollRestoration'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
*/
|
||||
export default function AppShell() {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
// Scrollposition je Seite erhalten (Zurück-Navigation + Handy-Sperre/Hintergrund) — gilt für
|
||||
// ALLE scrollbaren Listen der App.
|
||||
useScrollRestoration()
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
|
||||
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
|
||||
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
|
||||
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { useToast } from './toast'
|
||||
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
|
||||
|
||||
export default function PushToggle() {
|
||||
const t = de.feedback.tickets
|
||||
const toast = useToast()
|
||||
const [available, setAvailable] = useState(false)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
if (!isPushSupported()) return
|
||||
const cfg = await fetchPushConfig()
|
||||
if (cancelled || !cfg.enabled) return
|
||||
setAvailable(true)
|
||||
setSubscribed(await isSubscribed())
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await disablePush()
|
||||
setSubscribed(false)
|
||||
toast.success(t.pushDisabledToast)
|
||||
} else {
|
||||
const res = await enablePush()
|
||||
if (res === 'enabled') {
|
||||
setSubscribed(true)
|
||||
toast.success(t.pushEnabledToast)
|
||||
} else if (res === 'denied') {
|
||||
toast.error(t.pushDenied)
|
||||
} else {
|
||||
toast.error(t.pushUnavailable)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t.pushUnavailable)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
|
||||
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,26 @@
|
||||
* dialog captures the current URL and a client timestamp itself and POSTs everything
|
||||
* to /feedback. Success/error is surfaced via the existing toast system.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { submitFeedback, type FeedbackContext } from '../api/feedback'
|
||||
import {
|
||||
listFeedback,
|
||||
readFileAsUpload,
|
||||
submitFeedback,
|
||||
uploadAttachment,
|
||||
type FeedbackContext,
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { useToast } from './toast'
|
||||
import './reportErrorDialog.css'
|
||||
|
||||
/** Wörter (≥3 Zeichen) für einen einfachen Ähnlichkeits-Score. */
|
||||
function wordSet(s: string): Set<string> {
|
||||
return new Set((s.toLowerCase().match(/[a-zäöüß0-9]{3,}/g) ?? []))
|
||||
}
|
||||
|
||||
export interface ReportErrorContext {
|
||||
context: FeedbackContext
|
||||
gerbilId?: string | null
|
||||
@@ -58,8 +71,49 @@ function ReportErrorDialogBody({
|
||||
}
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
|
||||
const [resolvedTickets, setResolvedTickets] = useState<FeedbackTicket[]>([])
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listFeedback()
|
||||
.then((all) => {
|
||||
if (!cancelled)
|
||||
setResolvedTickets(all.filter((tk) => tk.status === 'Resolved' && !tk.deletedAt))
|
||||
})
|
||||
.catch(() => {
|
||||
/* ohne Vorschläge weiter */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Ähnliche gelöste Tickets: Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt.
|
||||
const similar = useMemo(() => {
|
||||
const msgWords = wordSet(message)
|
||||
if (msgWords.size < 2) return []
|
||||
const scored = resolvedTickets.map((tk) => {
|
||||
const hay = wordSet(`${tk.message} ${tk.fixNote ?? ''} ${tk.entityName ?? ''}`)
|
||||
let score = 0
|
||||
for (const w of msgWords) if (hay.has(w)) score++
|
||||
if (
|
||||
(context.gerbilId && tk.gerbilId === context.gerbilId) ||
|
||||
(context.litterId && tk.litterId === context.litterId) ||
|
||||
(context.contactId && tk.contactId === context.contactId)
|
||||
)
|
||||
score += 2
|
||||
return { tk, score }
|
||||
})
|
||||
return scored
|
||||
.filter((s) => s.score >= 2)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map((s) => s.tk)
|
||||
}, [message, resolvedTickets, context.gerbilId, context.litterId, context.contactId])
|
||||
|
||||
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -96,7 +150,7 @@ function ReportErrorDialogBody({
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await submitFeedback({
|
||||
const created = await submitFeedback({
|
||||
message: trimmed,
|
||||
context: context.context,
|
||||
gerbilId: context.gerbilId ?? null,
|
||||
@@ -106,6 +160,14 @@ function ReportErrorDialogBody({
|
||||
url: window.location.href,
|
||||
clientTimestamp: new Date().toISOString(),
|
||||
})
|
||||
// Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor).
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadAttachment(created.id, await readFileAsUpload(file))
|
||||
} catch {
|
||||
toast.error(t.attachmentError)
|
||||
}
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(draftKey)
|
||||
} catch {
|
||||
@@ -161,6 +223,41 @@ function ReportErrorDialogBody({
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
{similar.length > 0 && (
|
||||
<div className="report-error__similar">
|
||||
<div className="report-error__similar-title">{t.similarTitle}</div>
|
||||
<ul className="report-error__similar-list">
|
||||
{similar.map((tk) => (
|
||||
<li key={tk.id}>
|
||||
<Link to={`/hilfe/tickets?focus=${tk.id}`} onClick={onClose}>
|
||||
{tk.message.length > 80 ? `${tk.message.slice(0, 80)}…` : tk.message}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-error__attach">
|
||||
<label className="report-error__label" htmlFor="report-error-files">
|
||||
{t.attachLabel}
|
||||
</label>
|
||||
<input
|
||||
id="report-error-files"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<ul className="report-error__attach-list">
|
||||
{files.map((f, i) => (
|
||||
<li key={i}>{f.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="report-error__debug">
|
||||
<div className="report-error__debug-title">{t.debugTitle}</div>
|
||||
<dl className="report-error__debug-list">
|
||||
|
||||
@@ -147,3 +147,24 @@
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
/* Vorschlag: ähnliche bereits gelöste Tickets. */
|
||||
.report-error__similar {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-accent-bg, #eef2ff);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.report-error__similar-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.report-error__similar-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
describe('Partially-unknown parents (wildcards)', () => {
|
||||
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
|
||||
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
|
||||
// = effectively 3/4 A, 1/4 a ; mother always a.
|
||||
// Offspring: 3/4 Aa, 1/4 aa.
|
||||
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||
const father = makeGenotype({
|
||||
A: ['A', '?'],
|
||||
C: ['C', 'C'],
|
||||
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||
const result = breed(father, mother)
|
||||
|
||||
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
|
||||
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
|
||||
expect(agouti.probability.text).toBe('3/4')
|
||||
expect(schwarz.probability.text).toBe('1/4')
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||
expect(result.offspring[0].probability.text).toBe('1')
|
||||
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -603,7 +606,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is BLACK-eyed (P), and dilution is
|
||||
// independent of the P-locus, so Dilute Blaufuchs is aa dd ee gg P- (was
|
||||
// wrongly P:'p'). The pink-eyed variant is a different (REW-adjacent) colour.
|
||||
expect(name('aa CC dd ee gg PP spsp rere')).toBe('Dilute Blaufuchs')
|
||||
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||
})
|
||||
@@ -628,10 +634,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
|
||||
it('Farbarten (categories) never appear as computed results', () => {
|
||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||
// GEN-5 (ticket 5826e8e2): het ef/e is the FUCHSSCHIMMEL family — it resolves to
|
||||
// a *fuchsschimmel variety, NEVER a pure Schimmel (Rotaugen-/Orangeschimmel).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Agouti ef/e black-eyed → Algierfuchsschimmel (A:A,E:ef,G:G,P:P), NOT Orangeschimmel.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Algierfuchsschimmel')
|
||||
// hom ef/ef agouti black-eyed → the pure Orangeschimmel (Schimmel family).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||
@@ -789,3 +798,118 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(displayGenotypeSafe('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: unknown allele = copy of known (resolveAllelePair, breeder rule)', () => {
|
||||
it('a single-unknown pair resolves to the homozygote of the KNOWN allele', () => {
|
||||
expect(resolveAllelePair('A', ['A', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('A', ['?', 'a'])).toEqual(['a', 'a'])
|
||||
expect(resolveAllelePair('D', ['D', '?'])).toEqual(['D', 'D'])
|
||||
expect(resolveAllelePair('E', ['e', '?'])).toEqual(['e', 'e']) // ee[-] = Fuchs
|
||||
expect(resolveAllelePair('E', ['ef', '?'])).toEqual(['ef', 'ef']) // ef[-] = Schimmel
|
||||
})
|
||||
it('a fully-unknown pair falls back to wild-type (markers stay unmarked)', () => {
|
||||
expect(resolveAllelePair('A', ['?', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('C', ['?', '?'])).toEqual(['C', 'C'])
|
||||
expect(resolveAllelePair('Sp', ['?', '?'])).toEqual(['sp', 'sp']) // never implies Schecke
|
||||
})
|
||||
it('a fully-known pair is returned unchanged', () => {
|
||||
expect(resolveAllelePair('C', ['C', 'ch'])).toEqual(['C', 'ch'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: genetics-engine ticket reproductions (real stored genotypes)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('5826e8e2: ee[f] het (Fuchsschimmel) + pp → Goldfuchsschimmel, NOT Rotaugenschimmel', () => {
|
||||
// Tier bf6f4507: Aa C- D- ee[f] G- pp Spsp — het ef/e is a Fuchsschimmel.
|
||||
expect(name('Aa C- D- ee[f] G- pp Spsp')).toBe('Goldfuchsschimmel Schecke')
|
||||
// and the pure hom ef/ef pp stays the pure Schimmel:
|
||||
expect(name('AA CC DD efef GG pp spsp')).toBe('Rotaugenschimmel')
|
||||
})
|
||||
|
||||
it('b034ddd2: ee[-] (e + unknown) → ee Fuchs → Algierfuchs, NOT Agouti', () => {
|
||||
// Tier 33a7c1f9: Aa CC D- ee[-] Gg Pp spsp. Old engine read [e,?] as e/E → Agouti.
|
||||
expect(name('Aa CC D- ee[-] Gg Pp spsp')).toBe('Algierfuchs')
|
||||
})
|
||||
|
||||
it('3deab547 / efa2b232: aa cchm dd ee[-] gg P- → Dilute CP-Blaufuchs, NOT Zobel/blau/Unbekannt', () => {
|
||||
// Tier 6864eaef. Non-agouti Fuchs colourpoint is NOT a marten (Zobel) — it
|
||||
// derives a CP-fox base with the Dilute prefix.
|
||||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).toBe('Dilute CP-Blaufuchs Schecke')
|
||||
})
|
||||
|
||||
it('473dc345 / 5151ab20: Vance uw[d] (dense underwhite) parses (no crash) → Kohlfuchs', () => {
|
||||
// Tier f31eb1f9: aa Cc[chm] D- ee Uwuw[d] PP spsp. uw[d] is the G locus;
|
||||
// it must parse and NEVER render 'uw'.
|
||||
const g = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||||
expect(g.G).toEqual(['G', 'g'])
|
||||
expect(toDisplayString(g)).not.toContain('uw')
|
||||
expect(genotypeToFarbschlag(g)).toBe('Kohlfuchs')
|
||||
})
|
||||
|
||||
it('Fuchsschimmel family never resolves to a pure Schimmel variety', () => {
|
||||
// Agouti het ef/e black-eyed → Algierfuchsschimmel; hom ef/ef → Orangeschimmel.
|
||||
expect(name('AA CC DD eef GG PP spsp')).toBe('Algierfuchsschimmel')
|
||||
expect(name('AA CC DD efef GG PP spsp')).toBe('Orangeschimmel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: no phantom colours in the expected-litter list (3e643ef1/c8ce27e2/3c46d0b4/1e7b66e6)', () => {
|
||||
it('Mamta Mini (D-, Ee[-]) × Gold (D-, Ee): unknown D copies known D → no Dilute, no Unbekannt, no efef', () => {
|
||||
// Real litter 98bfdf92. Both parents carry D- (unknown D) and an unknown E
|
||||
// partner. The old uniform-spread invented dd / ef / 'Unbekannt' offspring.
|
||||
const father = fromDisplayString('AA CC D- Ee[-] Gg PP spsp') // Mamta Mini
|
||||
const mother = fromDisplayString('Aa CC D- Ee Gg pp spsp') // Gold
|
||||
const result = breed(father, mother)
|
||||
|
||||
const names = result.byFarbschlag.map((f) => f.farbschlag)
|
||||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||
expect(names.some((n) => n.startsWith('Dilute'))).toBe(false)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||||
// Probabilities still sum to exactly 1.
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// Only agouti vs silver-agouti can fall here (G locus segregates; everything else fixed).
|
||||
expect(new Set(names)).toEqual(new Set(['Agouti', 'Silberagouti']))
|
||||
})
|
||||
|
||||
it('D- × D- never yields a dd (dilute) offspring at all', () => {
|
||||
const p = fromDisplayString('AA CC D- EE GG PP spsp')
|
||||
const result = breed(p, p)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('dd'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)', () => {
|
||||
it('Mamta Mini Ee[-] + homozygous ee father Geely → Ee', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] Gg PP spsp')
|
||||
const geely = fromDisplayString('aa CC DD ee gg PP spsp') // father: ee (hom fox)
|
||||
const res = inferUnknownsFromParents(child, geely, null)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(res.genotype)).toBe('AA CC DD Ee Gg PP spsp')
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'father' }])
|
||||
})
|
||||
|
||||
it('falls back to the mother when only she is homozygous', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp') // het → no force
|
||||
const mother = fromDisplayString('aa CC DD ee GG PP spsp') // ee → forces e
|
||||
const res = inferUnknownsFromParents(child, father, mother)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'mother' }])
|
||||
})
|
||||
|
||||
it('leaves the genotype untouched when no parent is homozygous at the unknown locus', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, father, null)
|
||||
expect(res.inferred).toEqual([])
|
||||
expect(res.genotype.E).toEqual(child.E)
|
||||
})
|
||||
|
||||
it('no-op when there is nothing unknown', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, child, child)
|
||||
expect(res.inferred).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
||||
*/
|
||||
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import {
|
||||
makeGenotype,
|
||||
resolveAllelePair,
|
||||
toDisplayString,
|
||||
wildType,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
type Genotype,
|
||||
} from './genotype'
|
||||
@@ -113,7 +113,11 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
|
||||
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
|
||||
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
|
||||
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
|
||||
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
|
||||
* variety specifically.
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
|
||||
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
|
||||
// back to the wild-type reading (most-dominant colour / unmarked marker).
|
||||
const [x, y] = resolveAllelePair(locus, g[locus])
|
||||
if (locus === 'E') {
|
||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
||||
@@ -195,29 +196,50 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
|
||||
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
|
||||
const [x, y] = resolveAllelePair('E', g.E)
|
||||
if (x === 'e' && y === 'e') return 'Fuchs'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
|
||||
/**
|
||||
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
|
||||
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
|
||||
*/
|
||||
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
return [x, y]
|
||||
return resolveAllelePair(locus, g[locus])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
|
||||
* entries split into two breeder groups that share the same E:'ef' token but
|
||||
* differ by zygosity of the live animal:
|
||||
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
|
||||
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
|
||||
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
|
||||
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
|
||||
* Silberschimmel, …).
|
||||
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
|
||||
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
|
||||
*/
|
||||
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
|
||||
if (entry.tokens.E === undefined) return false
|
||||
const n = entry.name.toLowerCase()
|
||||
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
|
||||
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
|
||||
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
|
||||
return !n.includes('schimmel')
|
||||
}
|
||||
|
||||
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||
function baseColourFor(g: Genotype): string | null {
|
||||
const family = eFamily(g)
|
||||
const base = family
|
||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
|
||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||
@@ -242,32 +264,20 @@ function colourpointName(g: Genotype): string | null {
|
||||
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||
const agouti = resolvedPair(g, 'A').includes('A')
|
||||
if (!agouti) {
|
||||
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
|
||||
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
|
||||
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
|
||||
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
|
||||
// dd dilute or ee Fuchs) is named from the resolved base colour, so
|
||||
// 'aa cchm dd ee gg' no longer collapses to Zobel.
|
||||
const [d1, d2] = resolvedPair(g, 'D')
|
||||
const wildD = d1 === 'D' && d2 === 'D'
|
||||
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
|
||||
if (wildD && fullExtension) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
if (!base) return null
|
||||
const DILUTE = 'Dilute '
|
||||
if (base.startsWith(DILUTE)) {
|
||||
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||
}
|
||||
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
|
||||
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
|
||||
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
|
||||
// must derive its base generically like the A- branch, so e.g.
|
||||
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
|
||||
if (!agouti && eFamily(g) === null) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||
// Colourpoint base derivation: name the colour as if C were full, then prefix
|
||||
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
|
||||
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
if (!base) return null
|
||||
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||
@@ -333,12 +343,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
|
||||
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||
const base = wildType()
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
|
||||
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
|
||||
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
|
||||
// shared phenotype letter 'ef'; the representative genotype must encode the
|
||||
// right zygosity so each entry round-trips back to its own family.
|
||||
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const token = entry.tokens[locus]
|
||||
if (!token) {
|
||||
out[locus] = base[locus]
|
||||
continue
|
||||
}
|
||||
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
|
||||
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
|
||||
continue
|
||||
}
|
||||
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
||||
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||
|
||||
@@ -236,25 +236,25 @@
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
|
||||
@@ -236,25 +236,25 @@
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,38 @@ export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair
|
||||
return rank(a) <= rank(b) ? [a, b] : [b, a]
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-5 — the breeder's UNKNOWN-allele rule (ticket 3e643ef1, confirmed by the
|
||||
* Züchterin): an unknown allele '?' is a COPY of the known, visible partner
|
||||
* allele until the gene is determined. So a single-unknown pair resolves to the
|
||||
* homozygote of the KNOWN allele:
|
||||
* A? → AA D? → DD [e,?] → ee [E,?] → EE [ef,?] → ef/ef
|
||||
* Only when BOTH alleles are unknown is the locus genuinely undetermined; it then
|
||||
* falls back to the wild-type reading (most-dominant colour allele, but the
|
||||
* recessive UNMARKED allele for the Sp/Re/Sls markers so an unknown marker never
|
||||
* implies Schecke/Rex/WP).
|
||||
*
|
||||
* This single rule is shared by phenotype/catalog resolution (catalog.ts) and the
|
||||
* Punnett gamete weights (punnett.ts), so an unknown allele never invents a
|
||||
* recessive phenotype (no phantom Dilute/efef/Unbekannt in offspring lists).
|
||||
*/
|
||||
export function resolveAllelePair(locus: LocusKey, pair: AllelePair): [string, string] {
|
||||
const [a, b] = pair
|
||||
const aUnknown = a === WILDCARD
|
||||
const bUnknown = b === WILDCARD
|
||||
if (!aUnknown && !bUnknown) return [a, b]
|
||||
if (aUnknown && bUnknown) {
|
||||
// Fully unknown: wild-type reading (markers default to the unmarked recessive).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fb = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
return [fb, fb]
|
||||
}
|
||||
// Exactly one unknown → copy of the known partner allele (homozygous).
|
||||
const known = aUnknown ? b : a
|
||||
return [known, known]
|
||||
}
|
||||
|
||||
function assertAllele(locus: LocusKey, allele: string): void {
|
||||
if (allele === WILDCARD) return
|
||||
if (!LOCI[locus].alleles.includes(allele)) {
|
||||
@@ -180,27 +212,27 @@ function normalizeToken(tok: string): string | null {
|
||||
let t = tok
|
||||
if (t === 'WP') t = 'Slsl'
|
||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||
// GEN-3b (#32/#34): Underwhite == G locus. Strip the breeder's "[d]" (dense
|
||||
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
|
||||
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
|
||||
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
|
||||
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
|
||||
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
|
||||
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
|
||||
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
|
||||
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
|
||||
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
|
||||
t = t.replace(/uw\[d\]/gi, 'uw')
|
||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
||||
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||
// #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
|
||||
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
|
||||
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
|
||||
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
|
||||
// fox allele with an unknown partner) is genetically impossible and is left to
|
||||
// be rejected by splitToken (invalid → genotypeInvalid path).
|
||||
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
|
||||
// form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
|
||||
// NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
|
||||
// recessive fox with an unknown partner) is genetically impossible and is rejected below.
|
||||
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
||||
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
|
||||
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
|
||||
// strips only the bracket part; the leading allele stays.
|
||||
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
||||
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
||||
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
||||
// leaving the leading allele intact for splitToken).
|
||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
||||
@@ -266,12 +298,14 @@ export function fromDisplayString(input: string): Genotype {
|
||||
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
||||
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
|
||||
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
|
||||
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
|
||||
// the genotypeInvalid message instead of silently mis-computing the colour.
|
||||
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
|
||||
// genetically impossible — fox is recessive, so a fox allele is only visible homozygous
|
||||
// ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
|
||||
// instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
|
||||
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
|
||||
throw new Error(`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`)
|
||||
throw new Error(
|
||||
`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`,
|
||||
)
|
||||
}
|
||||
acc[locus] = canonicalPair(locus, a, b)
|
||||
}
|
||||
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
|
||||
export function hasUnknown(g: Genotype): boolean {
|
||||
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
|
||||
}
|
||||
|
||||
/** Per-locus note about an allele that parent-inference filled in. */
|
||||
export interface ParentInferredLocus {
|
||||
readonly locus: LocusKey
|
||||
/** The allele a homozygous parent forced onto the child. */
|
||||
readonly allele: string
|
||||
/** 'father' | 'mother' — which parent was homozygous. */
|
||||
readonly from: 'father' | 'mother'
|
||||
}
|
||||
|
||||
export interface ParentInferenceResult {
|
||||
readonly genotype: Genotype
|
||||
/** Loci whose unknown allele was resolved from a parent (empty = nothing changed). */
|
||||
readonly inferred: ParentInferredLocus[]
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-5 (tickets cc9ea3fe / 1a508c04, breeder rule via Mendel): a child's UNKNOWN
|
||||
* allele can be filled in from a HOMOZYGOUS parent, which can only pass that one
|
||||
* allele. E.g. Mamta Mini stored E = [E,?]; her father Geely is ee (homozygous
|
||||
* fox) so he must pass an 'e' — the child's unknown E allele therefore IS 'e',
|
||||
* giving Ee (not the copy-of-known EE default).
|
||||
*
|
||||
* Rule, per locus, ONLY for an allele still unknown ('?') in the child:
|
||||
* - if a parent is homozygous (both alleles equal and known), that allele is
|
||||
* forced onto the child's unknown slot.
|
||||
* - the father is checked first; if he doesn't resolve it, the mother is tried.
|
||||
* - a parent allele is only accepted if it is one the child could legitimately
|
||||
* carry at that locus (it always is for a real parent, but we guard anyway).
|
||||
* Pairs with no unknown, or where no parent is homozygous, are left untouched
|
||||
* (still subject to the copy-of-known display/colour rule elsewhere).
|
||||
*/
|
||||
export function inferUnknownsFromParents(
|
||||
child: Genotype,
|
||||
father: Genotype | null | undefined,
|
||||
mother: Genotype | null | undefined,
|
||||
): ParentInferenceResult {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
const inferred: ParentInferredLocus[] = []
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const [a, b] = child[locus]
|
||||
const aUnknown = a === WILDCARD
|
||||
const bUnknown = b === WILDCARD
|
||||
if (!aUnknown && !bUnknown) {
|
||||
out[locus] = child[locus]
|
||||
continue
|
||||
}
|
||||
const homForced = (p: Genotype | null | undefined): string | null => {
|
||||
if (!p) return null
|
||||
const [pa, pb] = p[locus]
|
||||
if (pa === WILDCARD || pb === WILDCARD) return null
|
||||
return pa === pb ? pa : null
|
||||
}
|
||||
const fatherAllele = homForced(father)
|
||||
const forced = fatherAllele ?? homForced(mother)
|
||||
const from: 'father' | 'mother' = fatherAllele ? 'father' : 'mother'
|
||||
if (forced && (aUnknown !== bUnknown)) {
|
||||
// Exactly one unknown slot → fill it with the forced parent allele.
|
||||
const known = aUnknown ? b : a
|
||||
out[locus] = canonicalPair(locus, known, forced)
|
||||
inferred.push({ locus, allele: forced, from })
|
||||
} else if (forced && aUnknown && bUnknown) {
|
||||
// Both unknown but a parent is homozygous → that allele is certain on one
|
||||
// slot; the other stays unknown.
|
||||
out[locus] = canonicalPair(locus, forced, WILDCARD)
|
||||
inferred.push({ locus, allele: forced, from })
|
||||
} else {
|
||||
out[locus] = child[locus]
|
||||
}
|
||||
}
|
||||
return { genotype: makeGenotype(out), inferred }
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ export {
|
||||
fromJSON,
|
||||
hasUnknown,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
WILDCARD,
|
||||
} from './genotype'
|
||||
export type { Genotype, AllelePair } from './genotype'
|
||||
export type {
|
||||
Genotype,
|
||||
AllelePair,
|
||||
ParentInferenceResult,
|
||||
ParentInferredLocus,
|
||||
} from './genotype'
|
||||
|
||||
export { LOCI, LOCUS_ORDER } from './loci'
|
||||
export type { LocusKey, LocusDef } from './loci'
|
||||
|
||||
@@ -10,72 +10,40 @@
|
||||
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
||||
* before combining, so a parent known only by phenotype can still be paired.
|
||||
*/
|
||||
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
||||
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import { add, frac, multiply, type Fraction, ONE } from './fraction'
|
||||
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import {
|
||||
canonicalPair,
|
||||
resolveAllelePair,
|
||||
toDisplayString,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
type Genotype,
|
||||
} from './genotype'
|
||||
|
||||
/**
|
||||
* #37/#39/#40/#41: which concrete alleles an UNKNOWN partner allele may actually be,
|
||||
* given the KNOWN allele it is paired with at this locus.
|
||||
*
|
||||
* A hidden allele is constrained by the recorded (visible) one:
|
||||
* 1. It can NEVER be more dominant than the known allele — otherwise the animal's
|
||||
* phenotype would be different from what the breeder recorded. So the unknown
|
||||
* only ranges over alleles with dominance rank >= rank(known) (equal or more
|
||||
* recessive). This kills impossible more-dominant offspring morphs.
|
||||
* 2. It can never be an allele that is VISIBLE in the heterozygote, unless the
|
||||
* animal already expresses it. At the E locus 'ef' (Schimmel/roan) shows even
|
||||
* heterozygously, so a non-Schimmel animal (known E or e) cannot secretly carry
|
||||
* 'ef'. Excluding it removes the phantom Schimmel/efef predictions (#41).
|
||||
*
|
||||
* When BOTH alleles are unknown the locus is genuinely unconstrained → full set.
|
||||
*/
|
||||
function unknownPartnerOptions(locus: LocusKey, known: string): readonly string[] {
|
||||
const alleles = LOCI[locus].alleles
|
||||
if (known === WILDCARD) return alleles // fully unknown locus: any allele
|
||||
const knownRank = dominanceRank(locus, known)
|
||||
return alleles.filter((a) => {
|
||||
if (dominanceRank(locus, a) < knownRank) return false // can't outrank the visible allele
|
||||
// E-locus 'ef' is visible in het: only possible if the animal is itself Schimmel.
|
||||
if (locus === 'E' && a === 'ef' && known !== 'ef') return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** A probability distribution over outcomes of type T (keyed by a string). */
|
||||
export interface DistEntry<T> {
|
||||
readonly value: T
|
||||
readonly probability: Fraction
|
||||
}
|
||||
|
||||
/** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */
|
||||
/**
|
||||
* Expand a (possibly partly-unknown) parent allele pair into weighted concrete
|
||||
* gamete alleles.
|
||||
*
|
||||
* GEN-5 (ticket 3e643ef1, breeder rule): an unknown allele '?' is a COPY of the
|
||||
* known partner allele (A?→AA, D?→DD, [e,?]→ee), NOT a uniform spread over the
|
||||
* whole allele set. Spreading wrongly invented recessive gametes (d, ef, e) that
|
||||
* produced impossible offspring colours — phantom Dilute, efef Schimmel and
|
||||
* 'Unbekannter Farbschlag' in the expected-litter list. After resolution each of
|
||||
* the two (now concrete) alleles contributes 1/2 of the gamete. A fully-unknown
|
||||
* locus resolves to the wild-type homozygote (see resolveAllelePair).
|
||||
*/
|
||||
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
|
||||
const weights = new Map<string, Fraction>()
|
||||
const addWeight = (allele: string, w: Fraction) => {
|
||||
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
||||
}
|
||||
// The "other" allele of the pair tells us what an unknown is allowed to be:
|
||||
// an unknown partner is constrained by the known visible allele (see
|
||||
// unknownPartnerOptions), not blown up uniformly over every allele.
|
||||
const [a0, a1] = pair
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const a = pair[i]
|
||||
if (a === WILDCARD) {
|
||||
const known = i === 0 ? a1 : a0
|
||||
const options = unknownPartnerOptions(locus, known)
|
||||
// Unknown allele contributes 1/2 of the gamete, split over its possible values.
|
||||
const share = frac(1, 2 * options.length)
|
||||
for (const concrete of options) addWeight(concrete, share)
|
||||
} else {
|
||||
addWeight(a, frac(1, 2))
|
||||
}
|
||||
}
|
||||
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
|
||||
return weights
|
||||
}
|
||||
|
||||
|
||||
@@ -50,17 +50,41 @@ interface Resolved<T> {
|
||||
export function useInfiniteList<T>(
|
||||
loader: (page: number) => Promise<Paged<T>>,
|
||||
resetKey: string,
|
||||
/**
|
||||
* Optionaler stabiler Schlüssel je Liste/Route (z. B. "gerbils"). Wenn gesetzt, wird die
|
||||
* geladene Seitenzahl in der Session gemerkt und nach einer Zurück-Navigation wieder
|
||||
* aufgebaut — so ist die Liste hoch genug, dass die globale Scroll-Wiederherstellung greift.
|
||||
*/
|
||||
restoreKey?: string,
|
||||
): InfiniteListState<T> {
|
||||
const loaderRef = useRef(loader)
|
||||
useEffect(() => {
|
||||
loaderRef.current = loader
|
||||
})
|
||||
|
||||
const storageKey = restoreKey ? `il-pages:${restoreKey}` : null
|
||||
|
||||
// Gemerkte Zielseite EINMAL beim Mount lesen (nur wenn dieselbe Filter-/Sortier-Signatur):
|
||||
// bis hierhin werden nach einer Zurück-Navigation die Seiten wieder nachgeladen.
|
||||
const [restoreTarget, setRestoreTarget] = useState<number>(() => {
|
||||
if (!storageKey) return 0
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(storageKey) || 'null')
|
||||
if (saved && saved.key === resetKey && typeof saved.page === 'number' && saved.page > 1) {
|
||||
return saved.page
|
||||
}
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
|
||||
// Adjust state during render when the query signature changes — the recommended
|
||||
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
||||
if (req.key !== resetKey) {
|
||||
setReq({ key: resetKey, page: 1, nonce: 0 })
|
||||
setRestoreTarget(0) // Filter/Sortierung geändert → kein Seiten-Restore mehr
|
||||
}
|
||||
|
||||
const queryId = `${req.nonce}:${req.key}`
|
||||
@@ -123,6 +147,24 @@ export function useInfiniteList<T>(
|
||||
}, [])
|
||||
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
|
||||
|
||||
// Geladene Seitenzahl je Query merken (für die Wiederherstellung nach Zurück-Navigation).
|
||||
useEffect(() => {
|
||||
if (!storageKey || !sameQuery || inFlight) return
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify({ key: req.key, page: req.page }))
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}, [storageKey, sameQuery, inFlight, req.key, req.page])
|
||||
|
||||
// Nach Zurück-Navigation die zuvor geladene Seitenzahl wieder aufbauen (eine Seite je
|
||||
// abgeschlossener Anfrage), damit die Liste ihre alte Höhe erreicht.
|
||||
useEffect(() => {
|
||||
if (restoreTarget <= req.page || !sameQuery || inFlight || !hasMore) return
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- kontrolliertes Nachladen bis zur gemerkten Seite
|
||||
setReq((r) => ({ ...r, page: r.page + 1 }))
|
||||
}, [restoreTarget, sameQuery, inFlight, hasMore, req.page])
|
||||
|
||||
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
||||
}
|
||||
|
||||
|
||||
194
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
194
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
|
||||
*
|
||||
* WICHTIG: Gescrollt wird NICHT das Fenster, sondern der Inhaltsbereich `.app-main`
|
||||
* (`.app-shell` ist height:100dvh/overflow:hidden). Daher arbeiten wir auf diesem Container.
|
||||
*
|
||||
* Erhält die Scrollposition je Route über:
|
||||
* - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen,
|
||||
* - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position
|
||||
* sichern und beim Zurückkommen wiederherstellen.
|
||||
* Neue Navigation (PUSH/REPLACE) startet oben.
|
||||
*
|
||||
* EVENT-basiert (zuverlässiger als ein Timer): ein MutationObserver auf dem Inhalt springt
|
||||
* erneut zur Zielposition, sobald die Liste (nach-)lädt und höher wird — bricht aber sofort ab,
|
||||
* sobald die Nutzerin selbst scrollt. Deep-Links (?focus=, #anchor) haben Vorrang.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useLocation, useNavigationType } from 'react-router-dom'
|
||||
|
||||
const PREFIX = 'scroll:'
|
||||
// Sicherheitsnetz: Inhalts-Beobachtung nach dieser Zeit beenden (kein Dauer-Listener).
|
||||
const RESTORE_OBSERVE_CAP_MS = 15000
|
||||
|
||||
function getScroller(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('.app-main')
|
||||
}
|
||||
function getTop(el: HTMLElement | null): number {
|
||||
return el ? el.scrollTop : window.scrollY
|
||||
}
|
||||
function setTop(el: HTMLElement | null, y: number): void {
|
||||
if (el) el.scrollTo(0, y)
|
||||
else window.scrollTo(0, y)
|
||||
}
|
||||
|
||||
export function useScrollRestoration() {
|
||||
const { pathname } = useLocation()
|
||||
const navType = useNavigationType()
|
||||
const pathRef = useRef(pathname)
|
||||
const restoringRef = useRef(false)
|
||||
const rafSaveRef = useRef<number | null>(null)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastTopRef = useRef(0)
|
||||
|
||||
const keyFor = (p: string) => PREFIX + p
|
||||
|
||||
const persist = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.setItem(keyFor(pathRef.current), String(getTop(getScroller())))
|
||||
} catch {
|
||||
/* sessionStorage nicht verfügbar */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restore = useCallback((path: string, respectDeepLink: boolean) => {
|
||||
if (respectDeepLink) {
|
||||
if (window.location.hash) return
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search).has('focus')) return
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
let y = NaN
|
||||
try {
|
||||
y = Number(sessionStorage.getItem(keyFor(path)))
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
if (!Number.isFinite(y) || y <= 0) return
|
||||
|
||||
// WICHTIG gegen Race: `restoringRef` bleibt aktiv, bis die Zielposition ERREICHT ist (oder
|
||||
// die Nutzerin selbst scrollt / die harte Obergrenze greift). So kann ein durch das
|
||||
// Re-Rendern/Nachladen ausgelöster Sprung nach oben (scroll→0) NICHT als neue Position
|
||||
// gespeichert werden und die gemerkte Position überschreiben.
|
||||
//
|
||||
// KEIN „Ruhe-/Settling-Timer": Listen, die ihre Inhalte erst nach einer kurzen Pause am Stück
|
||||
// rendern (z. B. die Tickets-Liste: ein Fetch, dann alle Karten auf einmal), würden sonst
|
||||
// vorzeitig freigegeben — die Position griffe dann ins Leere. Stattdessen warten wir per
|
||||
// MutationObserver auf JEDE Inhaltsänderung und springen erneut, bis die Position sitzt.
|
||||
restoringRef.current = true
|
||||
let done = false
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
restoringRef.current = false
|
||||
mo.disconnect()
|
||||
window.removeEventListener('wheel', onUser)
|
||||
window.removeEventListener('touchmove', onUser)
|
||||
window.removeEventListener('keydown', onUser)
|
||||
window.clearTimeout(safety)
|
||||
}
|
||||
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
|
||||
const onUser = () => stop()
|
||||
const tryReach = () => {
|
||||
if (done) return
|
||||
const el = getScroller()
|
||||
setTop(el, y)
|
||||
if (Math.abs(getTop(el) - y) <= 2) stop() // Zielposition sitzt → fertig
|
||||
}
|
||||
// EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt/rendert) → erneut zur Zielposition.
|
||||
const mo = new MutationObserver(() => requestAnimationFrame(tryReach))
|
||||
const passive = { passive: true } as AddEventListenerOptions
|
||||
window.addEventListener('wheel', onUser, passive)
|
||||
window.addEventListener('touchmove', onUser, passive)
|
||||
window.addEventListener('keydown', onUser)
|
||||
// Harte Obergrenze nur als Notausstieg (z. B. wenn die Zielposition nie erreichbar ist, weil
|
||||
// der Inhalt jetzt kürzer ist). Hält bis dahin nur den Speicher-Schutz — harmlos.
|
||||
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
|
||||
const scroller = getScroller()
|
||||
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
|
||||
requestAnimationFrame(tryReach)
|
||||
}, [])
|
||||
|
||||
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
||||
useEffect(() => {
|
||||
pathRef.current = pathname
|
||||
if (navType === 'POP') {
|
||||
restore(pathname, true)
|
||||
} else {
|
||||
restoringRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
setTop(getScroller(), 0)
|
||||
restoringRef.current = false
|
||||
})
|
||||
}
|
||||
}, [pathname, navType, restore])
|
||||
|
||||
// Laufendes Mitschreiben (rAF-gedrosselt) am Scroll-Container + Sichern beim Ausblenden +
|
||||
// Wiederherstellen beim Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const el = getScroller()
|
||||
if (el) {
|
||||
const prevTop = lastTopRef.current
|
||||
const prevHeight = lastHeightRef.current
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
if (restoringRef.current || document.visibilityState !== 'visible') return
|
||||
// ENTSCHEIDEND: Beim Seitenwechsel wird der Inhalt aus-/eingehängt → die scrollHeight
|
||||
// SCHRUMPFT und der Container KLEMMT auf eine kleinere Position. Genau dieser Fall
|
||||
// (Position UND Höhe gleichzeitig gesunken) ist ein „unechter" Sprung — NICHT speichern,
|
||||
// sonst überschreibt er die gemerkte Stelle der Seite, die wir gerade verlassen. Eine
|
||||
// echte Nutzer-Scrollung verkleinert die scrollHeight nie.
|
||||
if (el.scrollTop < prevTop && el.scrollHeight < prevHeight) return
|
||||
if (el.scrollHeight <= el.clientHeight + 4) return // nicht scrollbar
|
||||
} else if (restoringRef.current || document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
if (rafSaveRef.current != null) return
|
||||
rafSaveRef.current = requestAnimationFrame(() => {
|
||||
rafSaveRef.current = null
|
||||
persist()
|
||||
})
|
||||
}
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') persist()
|
||||
else restore(pathRef.current, false)
|
||||
}
|
||||
const onPageShow = () => restore(pathRef.current, false)
|
||||
|
||||
const scroller = getScroller()
|
||||
lastHeightRef.current = scroller?.scrollHeight ?? 0
|
||||
lastTopRef.current = scroller?.scrollTop ?? 0
|
||||
const scrollTarget: HTMLElement | Window = scroller ?? window
|
||||
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('pagehide', persist)
|
||||
window.addEventListener('pageshow', onPageShow)
|
||||
return () => {
|
||||
scrollTarget.removeEventListener('scroll', onScroll)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('pagehide', persist)
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
|
||||
}
|
||||
}, [persist, restore])
|
||||
|
||||
// NUR Dev (Vite-HMR / React Fast Refresh): vor dem Hot-Update sichern, danach wiederherstellen.
|
||||
// In der Produktion ist import.meta.hot undefiniert → No-Op.
|
||||
useEffect(() => {
|
||||
const hot = import.meta.hot
|
||||
if (!hot) return
|
||||
const before = () => persist()
|
||||
const after = () => restore(pathRef.current, false)
|
||||
hot.on('vite:beforeUpdate', before)
|
||||
hot.on('vite:afterUpdate', after)
|
||||
hot.on('vite:beforeFullReload', before)
|
||||
return () => {
|
||||
hot.off?.('vite:beforeUpdate', before)
|
||||
hot.off?.('vite:afterUpdate', after)
|
||||
hot.off?.('vite:beforeFullReload', before)
|
||||
}
|
||||
}, [persist, restore])
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export default function AnfragenPage() {
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
`${status}`,
|
||||
'anfragen',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(requests)
|
||||
const { items, total, loading } = requests
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function BeckenPage() {
|
||||
const list = useInfiniteList(
|
||||
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
`${filter ?? ''}`,
|
||||
'becken',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, loading } = list
|
||||
|
||||
@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||
import {
|
||||
fromDisplayString,
|
||||
genotypeToFarbschlag,
|
||||
displayGenotypeSafe,
|
||||
toDisplayString,
|
||||
hasUnknown,
|
||||
inferUnknownsFromParents,
|
||||
} from '../genetics'
|
||||
import type { Gender, GerbilStatus } from '../api/types'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
||||
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
|
||||
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
||||
|
||||
const geno = describeGenotype(g.genotype)
|
||||
// GEN-5 (Tickets cc9ea3fe / 1a508c04): unbekannte Gencode-Buchstaben aus einem
|
||||
// reinerbigen Elternteil ergänzen (Mendel: ein reinerbiger Elternteil kann nur
|
||||
// dieses eine Allel vererben). Greift nur, wenn der Genotyp ein '-' enthält UND
|
||||
// mindestens ein Elternteil mit Genotyp am eigenen Wurf hinterlegt ist.
|
||||
const genoInferred = (() => {
|
||||
if (!g.genotype) return null
|
||||
let child
|
||||
try {
|
||||
child = fromDisplayString(g.genotype)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!hasUnknown(child)) return null
|
||||
const parse = (s: string | null | undefined) => {
|
||||
if (!s) return null
|
||||
try {
|
||||
return fromDisplayString(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const f = parse(father.data?.genotype)
|
||||
const m = parse(mother.data?.genotype)
|
||||
if (!f && !m) return null
|
||||
const res = inferUnknownsFromParents(child, f, m)
|
||||
if (res.inferred.length === 0) return null
|
||||
const display = toDisplayString(res.genotype)
|
||||
if (display === geno?.display) return null
|
||||
return { display, farbschlag: genotypeToFarbschlag(res.genotype) }
|
||||
})()
|
||||
|
||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||
|
||||
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
|
||||
<dl className="ak-kvlist">
|
||||
<Kv label={t.fields.genotype}>
|
||||
<code className="ak-genotype">{geno.display}</code>
|
||||
{genoInferred && (
|
||||
<span className="ak-inferred" title={t.detail.genotypeInferredTitle}>
|
||||
{' → '}
|
||||
<code className="ak-genotype">{genoInferred.display}</code>{' '}
|
||||
<small className="ak-inferred-chip">⮑ {t.detail.genotypeInferred}</small>
|
||||
</span>
|
||||
)}
|
||||
</Kv>
|
||||
<Kv label={t.detail.resolvedPrefix}>
|
||||
{geno.farbschlag}
|
||||
{storedColorName &&
|
||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
)}
|
||||
{(() => {
|
||||
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
|
||||
return (
|
||||
<>
|
||||
{resolvedFarbschlag}
|
||||
{storedColorName &&
|
||||
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
|
||||
storedColorName !==
|
||||
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</Kv>
|
||||
</dl>
|
||||
) : (
|
||||
|
||||
Binary file not shown.
@@ -64,6 +64,7 @@ export default function KontaktePage() {
|
||||
(page) =>
|
||||
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
filter,
|
||||
'kontakte',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, hasMore, loading, loadingMore } = list
|
||||
|
||||
@@ -19,12 +19,18 @@ import {
|
||||
listFeedback,
|
||||
updateFeedback,
|
||||
deleteFeedback,
|
||||
restoreFeedback,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
attachmentUrl,
|
||||
readFileAsUpload,
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { getGerbil } from '../api/gerbils'
|
||||
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import PushToggle from '../components/PushToggle'
|
||||
import './tickets.css'
|
||||
|
||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||
@@ -227,19 +233,24 @@ function formatDate(iso: string | null): string {
|
||||
}
|
||||
|
||||
/** Gefilterte Ansichten (Filter-Chips). */
|
||||
type TicketView = 'dialog' | 'open' | 'closed'
|
||||
type TicketView = 'dialog' | 'open' | 'closed' | 'deleted'
|
||||
|
||||
/** Auto-Fallback-Reihenfolge, wenn die gewünschte Ansicht leer ist — „Gelöscht" NICHT (Papierkorb). */
|
||||
const FALLBACK_VIEWS: TicketView[] = ['open', 'dialog', 'closed']
|
||||
|
||||
/**
|
||||
* Zuordnung Ticket-Status → gefilterte Ansicht.
|
||||
* Zuordnung Ticket → gefilterte Ansicht.
|
||||
* - „Gelöscht" (deleted): soft-gelöscht (deletedAt gesetzt) — hat Vorrang vor dem Status.
|
||||
* - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin.
|
||||
* - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin
|
||||
* geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen"
|
||||
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
|
||||
* - „Geschlossen" (closed): Resolved.
|
||||
*/
|
||||
function viewOf(status: FeedbackTicket['status']): TicketView {
|
||||
if (status === 'Resolved') return 'closed'
|
||||
if (status === 'NeedsInfo') return 'dialog'
|
||||
function viewOf(ticket: Pick<FeedbackTicket, 'status' | 'deletedAt'>): TicketView {
|
||||
if (ticket.deletedAt) return 'deleted'
|
||||
if (ticket.status === 'Resolved') return 'closed'
|
||||
if (ticket.status === 'NeedsInfo') return 'dialog'
|
||||
return 'open'
|
||||
}
|
||||
|
||||
@@ -249,15 +260,39 @@ export default function TicketsPage() {
|
||||
// Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste
|
||||
// nicht-leere Gruppe zurück, falls „Offen" leer ist. Solange die Nutzerin keinen Filter
|
||||
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
|
||||
const [view, setView] = useState<TicketView>('open')
|
||||
// Der zuletzt gewählte Tab wird in der Session gemerkt, damit man nach „Zurück" (Remount)
|
||||
// NICHT wieder im falschen Tab („Offen") landet. userPicked startet bewusst false, damit ein
|
||||
// frischer ?focus=-Deep-Link weiterhin in den passenden Tab springen darf.
|
||||
const [view, setView] = useState<TicketView>(() => {
|
||||
try {
|
||||
const v = sessionStorage.getItem('tickets-view')
|
||||
if (v === 'dialog' || v === 'open' || v === 'closed' || v === 'deleted') return v
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
return 'open'
|
||||
})
|
||||
const [userPicked, setUserPicked] = useState(false)
|
||||
|
||||
async function handleToggleStatus(ticket: FeedbackTicket) {
|
||||
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
|
||||
try {
|
||||
await updateFeedback(ticket.id, { status: next })
|
||||
tickets.reload()
|
||||
toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast)
|
||||
if (ticket.status === 'Resolved') {
|
||||
// Wiederöffnen → zurück in die „Rückfragen" (NeedsInfo). Hatte das Ticket bereits eine
|
||||
// offene Rückfrage (z. B. eine manuell gelöste Rückfrage), wird genau diese Frage wieder
|
||||
// geöffnet (kein neuer Hinweis, kein Zeitstempel). War es dagegen ein echt gelöstes Ticket
|
||||
// ohne Rückfrage, wird um mehr Infos gebeten — und das Backend stempelt „Wieder geöffnet am".
|
||||
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
|
||||
await updateFeedback(ticket.id, {
|
||||
status: 'NeedsInfo',
|
||||
question: hadRueckfrage ? ticket.question! : t.reopenRequest,
|
||||
})
|
||||
tickets.reload()
|
||||
toast.success(t.reopenedToast)
|
||||
} else {
|
||||
await updateFeedback(ticket.id, { status: 'Resolved' })
|
||||
tickets.reload()
|
||||
toast.success(t.resolvedToast)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
||||
}
|
||||
@@ -286,6 +321,59 @@ export default function TicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(ticket: FeedbackTicket) {
|
||||
try {
|
||||
await restoreFeedback(ticket.id)
|
||||
tickets.reload()
|
||||
toast.success(t.restoredToast)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.restoreError)
|
||||
}
|
||||
}
|
||||
|
||||
// 👍/👎 auf einem gelösten Ticket. 👍 = nur Rückmeldung speichern; 👎 = Ticket wieder öffnen
|
||||
// (zurück in die Rückfragen) und um die fehlende Info bitten.
|
||||
async function handleHelpful(ticket: FeedbackTicket, helpful: boolean) {
|
||||
try {
|
||||
if (helpful) {
|
||||
await updateFeedback(ticket.id, { helpful: true })
|
||||
tickets.reload()
|
||||
toast.success(t.helpfulThanks)
|
||||
} else {
|
||||
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
|
||||
await updateFeedback(ticket.id, {
|
||||
helpful: false,
|
||||
status: 'NeedsInfo',
|
||||
question: hadRueckfrage ? ticket.question! : t.helpfulReopenNote,
|
||||
})
|
||||
tickets.reload()
|
||||
toast.success(t.reopenedToast)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAttachment(ticket: FeedbackTicket, file: File) {
|
||||
try {
|
||||
await uploadAttachment(ticket.id, await readFileAsUpload(file))
|
||||
tickets.reload()
|
||||
toast.success(t.attachmentAdded)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(attachmentId: string) {
|
||||
try {
|
||||
await deleteAttachment(attachmentId)
|
||||
tickets.reload()
|
||||
toast.success(t.attachmentRemoved)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tickets.data
|
||||
|
||||
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
|
||||
@@ -300,8 +388,8 @@ export default function TicketsPage() {
|
||||
|
||||
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 }
|
||||
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1
|
||||
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0, deleted: 0 }
|
||||
for (const ticket of rows ?? []) c[viewOf(ticket)] += 1
|
||||
return c
|
||||
}, [rows])
|
||||
|
||||
@@ -309,7 +397,7 @@ export default function TicketsPage() {
|
||||
const focusView = useMemo<TicketView | null>(() => {
|
||||
if (!focusId || !rows) return null
|
||||
const target = rows.find((r) => r.id === focusId)
|
||||
return target ? viewOf(target.status) : null
|
||||
return target ? viewOf(target) : null
|
||||
}, [focusId, rows])
|
||||
|
||||
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
|
||||
@@ -318,9 +406,32 @@ export default function TicketsPage() {
|
||||
const activeView: TicketView =
|
||||
counts[desiredView] > 0
|
||||
? desiredView
|
||||
: (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView
|
||||
: FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView
|
||||
|
||||
const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView)
|
||||
const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
|
||||
|
||||
// Suche (Volltext über Nachricht/Frage/Antwort/Name/Kategorie) + Kategorie-Filter, jeweils
|
||||
// INNERHALB der aktiven Ansicht.
|
||||
const [query, setQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
// Vorhandene Kategorien (über alle nicht-gelöschten Tickets) für das Auswahlmenü.
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const tk of rows ?? []) if (!tk.deletedAt && tk.category) set.add(tk.category)
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'de'))
|
||||
}, [rows])
|
||||
const displayed = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return visible.filter((tk) => {
|
||||
if (categoryFilter && tk.category !== categoryFilter) return false
|
||||
if (!q) return true
|
||||
const hay = [tk.message, tk.question, tk.answer, tk.fixNote, tk.entityName, tk.category]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [visible, query, categoryFilter])
|
||||
|
||||
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
|
||||
useEffect(() => {
|
||||
@@ -331,7 +442,10 @@ export default function TicketsPage() {
|
||||
el.classList.add('ticket-card--focus')
|
||||
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [focusId, visible])
|
||||
}, [focusId, displayed])
|
||||
|
||||
// (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
|
||||
// Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
|
||||
|
||||
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
|
||||
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).
|
||||
@@ -418,6 +532,7 @@ export default function TicketsPage() {
|
||||
dialog: t.emptyDialog,
|
||||
open: t.emptyOpen,
|
||||
closed: t.emptyClosed,
|
||||
deleted: t.emptyDeleted,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -429,6 +544,7 @@ export default function TicketsPage() {
|
||||
</nav>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="tickets-subtitle">{t.subtitle}</p>
|
||||
<PushToggle />
|
||||
|
||||
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
|
||||
|
||||
@@ -437,7 +553,7 @@ export default function TicketsPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="tickets-filters" role="group" aria-label={t.title}>
|
||||
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => (
|
||||
{(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
@@ -446,6 +562,11 @@ export default function TicketsPage() {
|
||||
onClick={() => {
|
||||
setUserPicked(true)
|
||||
setView(v)
|
||||
try {
|
||||
sessionStorage.setItem('tickets-view', v)
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t.filters[v]}
|
||||
@@ -454,12 +575,38 @@ export default function TicketsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="tickets-empty">{emptyText[activeView]}</p>
|
||||
<div className="tickets-search">
|
||||
<input
|
||||
type="search"
|
||||
className="tickets-search__input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
aria-label={t.searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
className="tickets-search__category"
|
||||
aria-label={t.categoryFilterLabel}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t.categoryAll}</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayed.length === 0 ? (
|
||||
<p className="tickets-empty">{query || categoryFilter ? t.emptySearch : emptyText[activeView]}</p>
|
||||
) : (
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
|
||||
<ul className="tickets-list">
|
||||
{visible.map((ticket) => (
|
||||
{displayed.map((ticket) => (
|
||||
<TicketCard
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
@@ -467,6 +614,10 @@ export default function TicketsPage() {
|
||||
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
||||
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
onRestore={() => handleRestore(ticket)}
|
||||
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
|
||||
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -484,6 +635,10 @@ interface TicketCardProps {
|
||||
onSaveMessage: (message: string) => Promise<void>
|
||||
onAnswer: (answer: string) => Promise<void>
|
||||
onDelete: () => void
|
||||
onRestore: () => void
|
||||
onHelpful: (helpful: boolean) => void
|
||||
onAddAttachment: (file: File) => void
|
||||
onDeleteAttachment: (attachmentId: string) => void
|
||||
}
|
||||
|
||||
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
|
||||
@@ -500,7 +655,20 @@ function statusBadge(status: FeedbackTicket['status']): { label: string; modifie
|
||||
}
|
||||
}
|
||||
|
||||
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) {
|
||||
/** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
|
||||
const TRASH_RETENTION_DAYS = 30
|
||||
|
||||
function TicketCard({
|
||||
ticket,
|
||||
onToggleStatus,
|
||||
onSaveMessage,
|
||||
onAnswer,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onHelpful,
|
||||
onAddAttachment,
|
||||
onDeleteAttachment,
|
||||
}: TicketCardProps) {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(ticket.message)
|
||||
@@ -526,7 +694,21 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
const [answering, setAnswering] = useState(false)
|
||||
const entityLink = entityHref(ticket)
|
||||
const resolved = ticket.status === 'Resolved'
|
||||
const deleted = !!ticket.deletedAt
|
||||
const badge = statusBadge(ticket.status)
|
||||
// Verbleibende Tage bis zur endgültigen Löschung (Soft-Delete-Countdown). `new Date()` (wie
|
||||
// anderswo im Code) statt Date.now() — letzteres verstößt gegen die Purity-Lintregel.
|
||||
const trashDaysLeft = ticket.deletedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(new Date(ticket.deletedAt).getTime() +
|
||||
TRASH_RETENTION_DAYS * 86_400_000 -
|
||||
new Date().getTime()) /
|
||||
86_400_000,
|
||||
),
|
||||
)
|
||||
: null
|
||||
const [answerEditing, setAnswerEditing] = useState(false)
|
||||
// Erstantwort: Rückfrage offen, noch keine Antwort.
|
||||
const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
|
||||
@@ -534,10 +716,13 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
// Info noch korrigieren, BEVOR die KI das Ticket bearbeitet.
|
||||
const canAmend = !!ticket.answer && ticket.status === 'Answered'
|
||||
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
|
||||
const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft)
|
||||
// Bearbeiten (Beschreibung) ist gesperrt, solange eine Rückfrage offen ist (NeedsInfo):
|
||||
// dann soll die Züchterin ANTWORTEN, nicht den Text ändern.
|
||||
const canEditMessage = ticket.status !== 'NeedsInfo'
|
||||
// Im Papierkorb (deleted) ist nichts interaktiv außer „Wiederherstellen".
|
||||
const showAnswerForm = !deleted && (canAnswerNew || answerEditing || (canAmend && !!answerDraft))
|
||||
// Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt
|
||||
// UND keine Rückfrage offen ist (NeedsInfo). Sobald die Züchterin geantwortet hat (oder die
|
||||
// KI nachgefragt hat), bleibt der Erst-Text fix — editierbar ist dann nur noch die neueste
|
||||
// Antwort (und auch die nur, bis die KI darauf reagiert hat, s. canAmend).
|
||||
const canEditMessage = !deleted && !ticket.answer && ticket.status !== 'NeedsInfo'
|
||||
|
||||
function startAmend() {
|
||||
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
|
||||
@@ -593,6 +778,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
<div className="ticket-card__top">
|
||||
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
|
||||
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
||||
{ticket.category && <span className="ticket-card__category">{ticket.category}</span>}
|
||||
{ticket.entityName &&
|
||||
(entityLink ? (
|
||||
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
|
||||
@@ -723,6 +909,101 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolved && !deleted && (
|
||||
<div className="ticket-card__helpful">
|
||||
{ticket.helpful === true ? (
|
||||
<span className="ticket-card__helpful-thanks">{t.helpfulThanks}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="ticket-card__helpful-q">{t.helpfulQuestion}</span>
|
||||
<button type="button" className="btn btn--small" onClick={() => onHelpful(true)}>
|
||||
{t.helpfulYes}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small" onClick={() => onHelpful(false)}>
|
||||
{t.helpfulNo}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(ticket.attachments.length > 0 || !deleted) && (
|
||||
<div className="ticket-card__attachments">
|
||||
<span className="ticket-card__attachments-label">{t.attachmentsLabel}</span>
|
||||
<div className="ticket-card__attachments-grid">
|
||||
{ticket.attachments.map((att) =>
|
||||
att.contentType.startsWith('image/') ? (
|
||||
<a
|
||||
key={att.id}
|
||||
href={attachmentUrl(att.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ticket-card__attachment"
|
||||
>
|
||||
<img src={attachmentUrl(att.id)} alt={att.fileName} loading="lazy" />
|
||||
{!deleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="ticket-card__attachment-remove"
|
||||
title={t.removeAttachment}
|
||||
aria-label={t.removeAttachment}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onDeleteAttachment(att.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<span key={att.id} className="ticket-card__attachment ticket-card__attachment--file">
|
||||
<a href={attachmentUrl(att.id)} target="_blank" rel="noopener noreferrer">
|
||||
{att.fileName}
|
||||
</a>
|
||||
{!deleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="ticket-card__attachment-remove"
|
||||
title={t.removeAttachment}
|
||||
aria-label={t.removeAttachment}
|
||||
onClick={() => onDeleteAttachment(att.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!deleted && (
|
||||
<label className="ticket-card__attachment-add">
|
||||
+ {t.addAttachment}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onAddAttachment(f)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleted && trashDaysLeft !== null && (
|
||||
<p className="ticket-card__trash-notice">
|
||||
{trashDaysLeft === 0
|
||||
? t.trashCountdownToday
|
||||
: trashDaysLeft === 1
|
||||
? t.trashCountdownTomorrow
|
||||
: t.trashCountdownDays.replace('{n}', String(trashDaysLeft))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="ticket-card__meta">
|
||||
<div>
|
||||
<dt>{t.createdLabel}</dt>
|
||||
@@ -734,19 +1015,39 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
<dd>{formatDate(ticket.resolvedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{ticket.reopenedAt && (
|
||||
<div>
|
||||
<dt>{t.reopenedLabel}</dt>
|
||||
<dd>{formatDate(ticket.reopenedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{deleted && (
|
||||
<div>
|
||||
<dt>{t.deletedLabel}</dt>
|
||||
<dd>{formatDate(ticket.deletedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<div className="ticket-card__actions">
|
||||
<button type="button" className="btn" onClick={onToggleStatus}>
|
||||
{resolved ? t.reopen : t.markResolved}
|
||||
</button>
|
||||
{canEditMessage && (
|
||||
<button type="button" className="btn" onClick={startEdit}>
|
||||
{t.edit}
|
||||
{deleted ? (
|
||||
<button type="button" className="btn btn--primary" onClick={onRestore}>
|
||||
{t.restore}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onToggleStatus}>
|
||||
{resolved ? t.reopen : t.markResolved}
|
||||
</button>
|
||||
{canEditMessage && (
|
||||
<button type="button" className="btn" onClick={startEdit}>
|
||||
{t.edit}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
||||
{t.delete}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
||||
{t.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function VertraegeListPage() {
|
||||
const contracts = useInfiniteList(
|
||||
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
|
||||
'contracts',
|
||||
'vertraege',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(contracts)
|
||||
const { items, total: totalCount, loading } = contracts
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function WuerfeListPage() {
|
||||
const litters = useInfiniteList(
|
||||
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
||||
`${filter}|${orderBy}`,
|
||||
'wuerfe',
|
||||
)
|
||||
const littersSentinelRef = useInfiniteSentinel(litters)
|
||||
|
||||
|
||||
@@ -286,6 +286,20 @@
|
||||
color: var(--ak-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* GEN-5: parent-inferred genotype hint (Ee[-] → Ee aus den Eltern ergänzt). */
|
||||
.ak-inferred {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ak-inferred-chip {
|
||||
display: inline-block;
|
||||
background: var(--ak-tan);
|
||||
color: var(--color-text);
|
||||
border-radius: 999px;
|
||||
padding: 1px 9px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ---------- Character chips ---------- */
|
||||
.ak-cgroup {
|
||||
|
||||
@@ -311,6 +311,148 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Suche + Kategorie-Filter über der Ticket-Liste. */
|
||||
.tickets-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tickets-search__input {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tickets-search__category {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
/* Kategorie-Chip im Ticket-Kopf. */
|
||||
.ticket-card__category {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent-bg, #eef2ff);
|
||||
color: var(--color-accent-text, #3730a3);
|
||||
}
|
||||
|
||||
/* „Hat das geholfen?"-Leiste auf gelösten Tickets. */
|
||||
.ticket-card__helpful {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ticket-card__helpful-q {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket-card__helpful-thanks {
|
||||
color: var(--color-success-text, #166534);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn--small {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Anhänge (Fotos/Dateien) auf einem Ticket. */
|
||||
.ticket-card__attachments {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
.ticket-card__attachments-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-muted, #6b7280);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.ticket-card__attachments-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ticket-card__attachment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ticket-card__attachment img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ticket-card__attachment--file {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ticket-card__attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 1;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.ticket-card__attachment--file .ticket-card__attachment-remove {
|
||||
position: static;
|
||||
background: transparent;
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
.ticket-card__attachment-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 1px dashed var(--color-border, #9ca3af);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-muted, #6b7280);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
|
||||
.ticket-card__trash-notice {
|
||||
border-left: 3px solid var(--color-danger, #ef4444);
|
||||
background: var(--color-danger-bg, #fef2f2);
|
||||
color: var(--color-danger-text, #991b1b);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ticket-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
87
gerbil-manager-web/src/push.ts
Normal file
87
gerbil-manager-web/src/push.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* WEB-PUSH (PWA-Benachrichtigungen) — Client-Seite.
|
||||
* Registriert den Service Worker (push-only) und verwaltet das Abo gegen /push/*.
|
||||
*/
|
||||
import { API_BASE_URL } from './api/client'
|
||||
|
||||
export function isPushSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
typeof window !== 'undefined' &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
)
|
||||
}
|
||||
|
||||
/** Server-Status: ist Push konfiguriert + welcher VAPID-Public-Key. */
|
||||
export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey: string | null }> {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/push/vapid-public-key`)
|
||||
if (!r.ok) return { enabled: false, publicKey: null }
|
||||
return await r.json()
|
||||
} catch {
|
||||
return { enabled: false, publicKey: null }
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const arr = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
|
||||
return arr
|
||||
}
|
||||
|
||||
export async function isSubscribed(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const sub = await reg?.pushManager.getSubscription()
|
||||
return !!sub
|
||||
}
|
||||
|
||||
export type EnableResult = 'enabled' | 'denied' | 'unavailable'
|
||||
|
||||
export async function enablePush(): Promise<EnableResult> {
|
||||
if (!isPushSupported()) return 'unavailable'
|
||||
const cfg = await fetchPushConfig()
|
||||
if (!cfg.enabled || !cfg.publicKey) return 'unavailable'
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return 'denied'
|
||||
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
|
||||
})
|
||||
const json = sub.toJSON()
|
||||
await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: json.keys?.p256dh ?? '',
|
||||
auth: json.keys?.auth ?? '',
|
||||
}),
|
||||
})
|
||||
return 'enabled'
|
||||
}
|
||||
|
||||
export async function disablePush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const sub = await reg?.pushManager.getSubscription()
|
||||
if (!sub) return
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
})
|
||||
} catch {
|
||||
/* trotzdem lokal abmelden */
|
||||
}
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
@@ -112,6 +112,10 @@ export const de = {
|
||||
resolvedPrefix: 'Errechnet',
|
||||
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
|
||||
genotypeNotSet: 'Kein Genotyp hinterlegt.',
|
||||
// GEN-5: ein unbekannter Gencode-Buchstabe wurde aus einem reinerbigen
|
||||
// Elternteil ergänzt (z. B. Vater ee ⇒ Kind erbt e). %s = aufgelöster Genotyp.
|
||||
genotypeInferred: 'Aus den Eltern ergänzt',
|
||||
genotypeInferredTitle: 'Ein unbekanntes Gen wurde aus einem reinerbigen Elternteil abgeleitet.',
|
||||
testMating: 'Probeverpaarung',
|
||||
edit: 'Bearbeiten',
|
||||
back: 'Zurück zur Liste',
|
||||
@@ -1197,6 +1201,12 @@ export const de = {
|
||||
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
|
||||
error: 'Fehlerbericht konnte nicht gesendet werden.',
|
||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||
/** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */
|
||||
similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:',
|
||||
similarOpen: 'ansehen',
|
||||
/** Datei-Anhänge. */
|
||||
attachLabel: 'Fotos anhängen (optional)',
|
||||
attachmentError: 'Ein Anhang konnte nicht hochgeladen werden.',
|
||||
/** Toast nach erfolgreichem "ID kopieren". */
|
||||
idCopied: 'ID kopiert',
|
||||
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
||||
@@ -1217,20 +1227,31 @@ export const de = {
|
||||
entityLabel: 'Datensatz',
|
||||
createdLabel: 'Gesendet am',
|
||||
resolvedLabel: 'Gelöst am',
|
||||
reopenedLabel: 'Wieder geöffnet am',
|
||||
markResolved: 'Als gelöst markieren',
|
||||
reopen: 'Wieder öffnen',
|
||||
reopenRequest:
|
||||
'Du hast dieses Ticket wieder geöffnet. Bitte beschreibe kurz, was noch fehlt oder nicht stimmt — dann schaue ich es mir erneut an.',
|
||||
edit: 'Bearbeiten',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
saving: 'Wird gespeichert …',
|
||||
delete: 'Löschen',
|
||||
confirmDelete: 'Diesen Fehlerbericht wirklich löschen?',
|
||||
confirmDelete: 'Dieses Ticket in den Papierkorb verschieben? Du kannst es unter „Gelöscht" wiederherstellen.',
|
||||
editTitle: 'Fehlerbericht bearbeiten',
|
||||
editLabel: 'Beschreibung',
|
||||
saved: 'Änderung gespeichert.',
|
||||
resolvedToast: 'Ticket als gelöst markiert.',
|
||||
reopenedToast: 'Ticket wieder geöffnet.',
|
||||
deletedToast: 'Ticket gelöscht.',
|
||||
deletedToast: 'Ticket in den Papierkorb verschoben.',
|
||||
restore: 'Wiederherstellen',
|
||||
restoredToast: 'Ticket wiederhergestellt.',
|
||||
restoreError: 'Ticket konnte nicht wiederhergestellt werden.',
|
||||
/** Countdown im Papierkorb bis zur endgültigen Löschung. {n} = verbleibende Tage. */
|
||||
trashCountdownDays: 'Wird in {n} Tagen endgültig gelöscht.',
|
||||
trashCountdownTomorrow: 'Wird morgen endgültig gelöscht.',
|
||||
trashCountdownToday: 'Wird heute endgültig gelöscht.',
|
||||
deletedLabel: 'Gelöscht am',
|
||||
updateError: 'Ticket konnte nicht aktualisiert werden.',
|
||||
deleteError: 'Ticket konnte nicht gelöscht werden.',
|
||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||
@@ -1253,11 +1274,39 @@ export const de = {
|
||||
open: 'Offen',
|
||||
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
|
||||
closed: 'Geschlossen',
|
||||
/** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */
|
||||
deleted: 'Gelöscht',
|
||||
},
|
||||
/** Leertext je gefilterter Ansicht. */
|
||||
emptyDialog: 'Aktuell keine Tickets im Dialog.',
|
||||
emptyOpen: 'Keine offenen Tickets.',
|
||||
emptyClosed: 'Noch keine geschlossenen Tickets.',
|
||||
emptyDeleted: 'Der Papierkorb ist leer.',
|
||||
/** Suche + Kategorie-Filter. */
|
||||
searchPlaceholder: 'Tickets durchsuchen …',
|
||||
categoryFilterLabel: 'Nach Kategorie filtern',
|
||||
categoryAll: 'Alle Kategorien',
|
||||
emptySearch: 'Keine Tickets passen zu Suche/Filter.',
|
||||
/** Daumen-Rückmeldung auf gelösten Tickets. */
|
||||
helpfulQuestion: 'Hat dir das geholfen?',
|
||||
helpfulYes: '👍 Ja',
|
||||
helpfulNo: '👎 Nein',
|
||||
helpfulThanks: 'Danke für die Rückmeldung!',
|
||||
helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.',
|
||||
/** Anhänge auf einem Ticket. */
|
||||
attachmentsLabel: 'Anhänge',
|
||||
addAttachment: 'Foto anhängen',
|
||||
removeAttachment: 'Anhang entfernen',
|
||||
attachmentAdded: 'Anhang hinzugefügt.',
|
||||
attachmentRemoved: 'Anhang entfernt.',
|
||||
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
|
||||
/** Push-Benachrichtigungen (PWA). */
|
||||
pushEnable: 'Benachrichtigungen aktivieren',
|
||||
pushDisable: 'Benachrichtigungen aus',
|
||||
pushEnabledToast: 'Benachrichtigungen aktiviert — du wirst informiert, sobald ich antworte.',
|
||||
pushDisabledToast: 'Benachrichtigungen deaktiviert.',
|
||||
pushDenied: 'Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.',
|
||||
pushUnavailable: 'Benachrichtigungen sind auf diesem Gerät nicht verfügbar.',
|
||||
/** Changelog (fixNote) auf geschlossenen Tickets. */
|
||||
changelogLabel: 'Was wurde geändert',
|
||||
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
||||
|
||||
@@ -1,382 +1,469 @@
|
||||
{
|
||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||
"resolutions": [
|
||||
{
|
||||
"name": "Firefly von den Kleinen Chaoten",
|
||||
"dob": "18.12.2019",
|
||||
"decision": "D-locus = D- (the DD in one source was a typo)",
|
||||
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "WildFire von den Kleinen Chaoten",
|
||||
"dob": "05.10.2017",
|
||||
"decision": "P-locus = PP (not P-)",
|
||||
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Flint von den Kleinen Chaoten",
|
||||
"dob": "23.12.2017",
|
||||
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
|
||||
"dateOfDeath": "10.05.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Molly of Black Forest",
|
||||
"dob": "13.09.2021",
|
||||
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
|
||||
"dateOfDeath": "03.05.2022",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Daja of Little Rose",
|
||||
"dob": "16.05.2021",
|
||||
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
|
||||
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Ichika von den Kleinen Chaoten",
|
||||
"dob": "19.04.2020",
|
||||
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
|
||||
"genotype": "aa CC D- ee[f] Gg pp spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Zuleika von den Kleinen Chaoten",
|
||||
"dob": "24.10.2015",
|
||||
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
|
||||
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Milka of LennyLengo",
|
||||
"dob": "09.12.2018",
|
||||
"decision": "C-locus = Cc[h], E-locus = EE",
|
||||
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Silvain von den Kleinen Chaoten",
|
||||
"dob": "27.03.2022",
|
||||
"decision": "E-locus = ee, P-locus = Pp",
|
||||
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Chelsea von den Kleinen Chaoten",
|
||||
"dob": "15.10.2021",
|
||||
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
|
||||
"correctDob": "02.04.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
|
||||
},
|
||||
{
|
||||
"name": "Vestra von den Schlossmäusen",
|
||||
"dob": "08.02.2019",
|
||||
"decision": "D-locus = DD",
|
||||
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||
"dob": "16.01.2023",
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Little Runner's Big Ben",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||
"dob": "10.04.2022",
|
||||
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Skarlett von den Kleinen Chaoten",
|
||||
"dob": "14.07.2013",
|
||||
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
|
||||
"dateOfDeath": "01.01.2017",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
|
||||
},
|
||||
{
|
||||
"name": "Kazu von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Hanami von den Kleinen Chaoten",
|
||||
"dob": "10.09.2015",
|
||||
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||
"dateOfDeath": "12.12.2019",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||
},
|
||||
{
|
||||
"name": "Kazumi von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Filou von den Kleinen Chaoten",
|
||||
"dob": "24.11.2014",
|
||||
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
|
||||
"dateOfDeath": "31.08.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Sokrates von den Kleinen Chaoten",
|
||||
"dob": "14.12.2015",
|
||||
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
|
||||
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
|
||||
"dateOfDeath": "20.05.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Percy of little runners",
|
||||
"dob": "16.12.2017",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Iwana of little runners",
|
||||
"dob": "02.10.2018",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "Aa CC DD Ee Gg Pp spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Osamu von den Kleinen Chaoten",
|
||||
"dob": "10.12.2015",
|
||||
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
||||
"dateOfDeath": "18.12.2020",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Max von Privat",
|
||||
"dob": "01.02.2013",
|
||||
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
|
||||
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
|
||||
"dateOfDeath": "01.01.2014",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
|
||||
},
|
||||
{
|
||||
"name": "Isa of Golden Lights",
|
||||
"dob": "24.12.2014",
|
||||
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
|
||||
"dateOfDeath": "21.07.2018",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Jack II von den Kleinen Chaoten",
|
||||
"dob": "14.02.2016",
|
||||
"decision": "Sterbedatum von der Züchterin",
|
||||
"dateOfDeath": "06.10.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Milon von den Kleinen Chaoten",
|
||||
"dob": "27.11.2014",
|
||||
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
|
||||
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Sunny von PZ Karl",
|
||||
"dob": "10.04.2014",
|
||||
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
|
||||
"dateOfDeath": "30.04.2018",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
|
||||
},
|
||||
{
|
||||
"name": "Dakota of sweet little mouse",
|
||||
"dob": "30.01.2015",
|
||||
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
|
||||
"genotype": "Aa CC Dd Ee Gg pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Banjo of Fiomi",
|
||||
"dob": "06.07.2015",
|
||||
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
|
||||
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||
"farbschlag": "Goldfuchs Starkschecke",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
||||
},
|
||||
{
|
||||
"name": "Joghurt von Privat",
|
||||
"dob": "06.09.2013",
|
||||
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
|
||||
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Ken'ichi",
|
||||
"dob": "01.03.2015",
|
||||
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
|
||||
"genotype": "AA CC DD Ee GG PP spsp",
|
||||
"farbschlag": "Agouti",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Harumi",
|
||||
"dob": "21.02.2015",
|
||||
"decision": "Death date is 11.01.2018 (died in 2018).",
|
||||
"dateOfDeath": "11.01.2018",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
|
||||
"dob": "18.05.2016",
|
||||
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
|
||||
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Kazuya von den Kleinen Chaoten",
|
||||
"dob": "22.08.2018",
|
||||
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
|
||||
"correctDob": "14.07.2019",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Kazuya von den Kleinen Chaoten",
|
||||
"dob": "14.07.2019",
|
||||
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
|
||||
"father": "Wilbur von den Kleinen Chaoten",
|
||||
"mother": "Naho von den Kleinen Chaoten",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Naho von den Kleinen Chaoten",
|
||||
"dob": "20.07.2017",
|
||||
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
|
||||
"father": "Osamu von den Kleinen Chaoten",
|
||||
"mother": "Montana v.d. Kleinen Chaoten",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Mozart of Lennylengo",
|
||||
"dob": "12.03.2017",
|
||||
"decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.",
|
||||
"gender": "female",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)"
|
||||
},
|
||||
{
|
||||
"name": "Yuki von den Kleinen Chaoten",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).",
|
||||
"father": "Chevrolet Camaro of Topolino",
|
||||
"mother": "Izumi von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #23"
|
||||
},
|
||||
{
|
||||
"name": "Gold v.d. Kleinen Chaoten",
|
||||
"dob": "03.08.2023",
|
||||
"decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.",
|
||||
"father": "Trogir von den Kleinen Chaoten",
|
||||
"mother": "Chelsea von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #36"
|
||||
},
|
||||
{
|
||||
"name": "Arya Stark von den Kleinen Chaoten",
|
||||
"dob": "30.06.2020",
|
||||
"decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).",
|
||||
"father": "Vance von den Kleinen Chaoten",
|
||||
"mother": "Sansa Stark von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #16"
|
||||
},
|
||||
{
|
||||
"name": "Tony v.d. Kleinen Chaoten",
|
||||
"dob": "16.09.2015",
|
||||
"decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.",
|
||||
"father": "Sammy von den Kleinen Chaoten",
|
||||
"mother": "Qamikaze Queen von den Schlossmäusen",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #12"
|
||||
},
|
||||
{
|
||||
"name": "Odelia von den Kleinen Chaoten",
|
||||
"dob": "21.06.2015",
|
||||
"decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).",
|
||||
"father": "Cash v.d. Kleinen Chaoten",
|
||||
"mother": "Elena",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #15"
|
||||
},
|
||||
{
|
||||
"name": "Jamie von den kleinen Chaoten",
|
||||
"dob": "27.12.2010",
|
||||
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
|
||||
"father": "Danny von PZ Maintal",
|
||||
"mother": "Jana of little longnoses",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #31"
|
||||
},
|
||||
{
|
||||
"name": "Silver von den kleinen Chaoten",
|
||||
"dob": "20.03.2013",
|
||||
"decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.",
|
||||
"father": "Taro von den kleinen Chaoten",
|
||||
"mother": "Beatrice von den kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #11"
|
||||
},
|
||||
{
|
||||
"name": "Roni",
|
||||
"dob": "27.01.2022",
|
||||
"decision": "gender = male. Roni is the FATHER (not mother) of the T21/Z21/22 litters (Wurfchronik names father=Roni, mother=Fumi). Box colour / chart had her typed female, so role normalisation wrongly put Roni in the mother slot and left the father empty. Setting male lets Roni take the father role; Fumi (added via addAnimals) fills the mother slot.",
|
||||
"gender": "male",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Roni ist Vater)"
|
||||
},
|
||||
{
|
||||
"name": "Cherry Berry's Quqquluuruu",
|
||||
"dob": "",
|
||||
"decision": "gender = female (weisse Box = weiblich, blaue = maennlich). Pure pedigree ancestor without a birthdate, so the box-colour gender was never read — the Stammbaum showed a question mark instead of the female icon. dob MUST be empty so the name-only override key matches.",
|
||||
"gender": "female",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 94892100 (Weibchen-Icon fehlt)"
|
||||
},
|
||||
{
|
||||
"name": "Sunny von PZ Karl",
|
||||
"dob": "10.04.2014",
|
||||
"decision": "father is Bill von Privat, mother is Melly von Privat (from Stammbaum von Yurikas und Pintos Sohn.xlsx / Renner-Pro-3). The chart-position resolver had wrongly given Hiro of Golden Lights as father.",
|
||||
"father": "Bill von Privat",
|
||||
"mother": "Melly von Privat",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 0ba551a3 (Sunny falscher Vater)"
|
||||
},
|
||||
{
|
||||
"name": "Danielle von den Kleinen Chaoten",
|
||||
"dob": "04.03.2020",
|
||||
"decision": "mother is Ella (Tochter von Louis+Roswitha, *10.06.2019) — Louis & Roswitha are Danielle's GRANDparents, not her parents. The father is an unnamed brother of Ella (a sibling pairing) with no own record, so he stays unknown. Two 'Ella' exist; motherDob pins the correct one (*10.06.2019), and the second Ella (*13.04.2023, born after Danielle) is age-impossible anyway.",
|
||||
"mother": "Ella",
|
||||
"motherDob": "10.06.2019",
|
||||
"source": "Züchterin 2026-06-22 — Tickets f77aa4a6 / 4692fd5c (Danielle echte Mutter Ella)"
|
||||
},
|
||||
{
|
||||
"name": "Unbekannt",
|
||||
"dob": "15.02.2024",
|
||||
"decision": "Nameless buck *15.02.2024 (son of Inochi gen. Picu) is recorded twice — once via 'Stammbaum von Picus Son.xlsx' (externalRef stammbaum-unbekannt-15022024-2) and once via 'Stammbaum von Alberto Kids.xlsx' (externalRef stammbaum-unbekannt-15022024). Same buck. The two reconstructed litters (Picus Son vs Alberto Kids) differ only by the mother's birth YEAR (a typo): mother 13.11.2023 vs 13.11.2022. Merge the two bucks and the two mothers into one each via mergeExternalRefs.",
|
||||
"mergeExternalRefs": [
|
||||
["stammbaum-unbekannt-15022024-2", "stammbaum-unbekannt-15022024"],
|
||||
["stammbaum-unbekannt-13112023", "stammbaum-unbekannt-13112022"]
|
||||
],
|
||||
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
|
||||
}
|
||||
],
|
||||
"addAnimals": [
|
||||
{
|
||||
"name": "Fumi von den Kleinen Chaoten",
|
||||
"gender": "female",
|
||||
"zucht": "Zucht der kleinen Chaoten",
|
||||
"decision": "Mother of the T21/Z21/22 litters (Wurfchronik names mother=Fumi, father=Roni) but she has no own source record, so her litters showed an empty mother. Materialised as a non-resident stub so the parent link resolves; her own parents stay unknown.",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Mutter Fumi)"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `externalRef` (the dedup slug / animals.json id, e.g. \"unbekannt-13082025-3\"): matches ONE specific record even when several NAMELESS animals share the same (name=\"\" + dob) key — externalRef wins over the name/dob keys. An externalRef-only resolution (no `name`) does NOT register a name/dob key. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||
"resolutions": [
|
||||
{
|
||||
"name": "Firefly von den Kleinen Chaoten",
|
||||
"dob": "18.12.2019",
|
||||
"decision": "D-locus = D- (the DD in one source was a typo)",
|
||||
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "WildFire von den Kleinen Chaoten",
|
||||
"dob": "05.10.2017",
|
||||
"decision": "P-locus = PP (not P-)",
|
||||
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Flint von den Kleinen Chaoten",
|
||||
"dob": "23.12.2017",
|
||||
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
|
||||
"dateOfDeath": "10.05.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Molly of Black Forest",
|
||||
"dob": "13.09.2021",
|
||||
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
|
||||
"dateOfDeath": "03.05.2022",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Daja of Little Rose",
|
||||
"dob": "16.05.2021",
|
||||
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
|
||||
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Ichika von den Kleinen Chaoten",
|
||||
"dob": "19.04.2020",
|
||||
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
|
||||
"genotype": "aa CC D- ee[f] Gg pp spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Zuleika von den Kleinen Chaoten",
|
||||
"dob": "24.10.2015",
|
||||
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
|
||||
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Milka of LennyLengo",
|
||||
"dob": "09.12.2018",
|
||||
"decision": "C-locus = Cc[h], E-locus = EE",
|
||||
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Silvain von den Kleinen Chaoten",
|
||||
"dob": "27.03.2022",
|
||||
"decision": "E-locus = ee, P-locus = Pp",
|
||||
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Chelsea von den Kleinen Chaoten",
|
||||
"dob": "15.10.2021",
|
||||
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
|
||||
"correctDob": "02.04.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
|
||||
},
|
||||
{
|
||||
"name": "Vestra von den Schlossmäusen",
|
||||
"dob": "08.02.2019",
|
||||
"decision": "D-locus = DD",
|
||||
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||
"dob": "16.01.2023",
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Little Runner's Big Ben",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||
"dob": "10.04.2022",
|
||||
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Skarlett von den Kleinen Chaoten",
|
||||
"dob": "14.07.2013",
|
||||
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
|
||||
"dateOfDeath": "01.01.2017",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
|
||||
},
|
||||
{
|
||||
"name": "Kazu von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Hanami von den Kleinen Chaoten",
|
||||
"dob": "10.09.2015",
|
||||
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||
"dateOfDeath": "12.12.2019",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||
},
|
||||
{
|
||||
"name": "Kazumi von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Filou von den Kleinen Chaoten",
|
||||
"dob": "24.11.2014",
|
||||
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
|
||||
"dateOfDeath": "31.08.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Sokrates von den Kleinen Chaoten",
|
||||
"dob": "14.12.2015",
|
||||
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
|
||||
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
|
||||
"dateOfDeath": "20.05.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Percy of little runners",
|
||||
"dob": "16.12.2017",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Iwana of little runners",
|
||||
"dob": "02.10.2018",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "Aa CC DD Ee Gg Pp spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Osamu von den Kleinen Chaoten",
|
||||
"dob": "10.12.2015",
|
||||
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
||||
"dateOfDeath": "18.12.2020",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Max von Privat",
|
||||
"dob": "01.02.2013",
|
||||
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
|
||||
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
|
||||
"dateOfDeath": "01.01.2014",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
|
||||
},
|
||||
{
|
||||
"name": "Isa of Golden Lights",
|
||||
"dob": "24.12.2014",
|
||||
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
|
||||
"dateOfDeath": "21.07.2018",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Jack II von den Kleinen Chaoten",
|
||||
"dob": "14.02.2016",
|
||||
"decision": "Sterbedatum von der Züchterin",
|
||||
"dateOfDeath": "06.10.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Milon von den Kleinen Chaoten",
|
||||
"dob": "27.11.2014",
|
||||
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
|
||||
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Sunny von PZ Karl",
|
||||
"dob": "10.04.2014",
|
||||
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
|
||||
"dateOfDeath": "30.04.2018",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
|
||||
},
|
||||
{
|
||||
"name": "Dakota of sweet little mouse",
|
||||
"dob": "30.01.2015",
|
||||
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
|
||||
"genotype": "Aa CC Dd Ee Gg pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Banjo of Fiomi",
|
||||
"dob": "06.07.2015",
|
||||
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
|
||||
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||
"farbschlag": "Goldfuchs Starkschecke",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
||||
},
|
||||
{
|
||||
"name": "Joghurt von Privat",
|
||||
"dob": "06.09.2013",
|
||||
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
|
||||
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Ken'ichi",
|
||||
"dob": "01.03.2015",
|
||||
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
|
||||
"genotype": "AA CC DD Ee GG PP spsp",
|
||||
"farbschlag": "Agouti",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Harumi",
|
||||
"dob": "21.02.2015",
|
||||
"decision": "Death date is 11.01.2018 (died in 2018).",
|
||||
"dateOfDeath": "11.01.2018",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
|
||||
"dob": "18.05.2016",
|
||||
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
|
||||
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-12"
|
||||
},
|
||||
{
|
||||
"name": "Kazuya von den Kleinen Chaoten",
|
||||
"dob": "22.08.2018",
|
||||
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
|
||||
"correctDob": "14.07.2019",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Kazuya von den Kleinen Chaoten",
|
||||
"dob": "14.07.2019",
|
||||
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
|
||||
"father": "Wilbur von den Kleinen Chaoten",
|
||||
"mother": "Naho von den Kleinen Chaoten",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Naho von den Kleinen Chaoten",
|
||||
"dob": "20.07.2017",
|
||||
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
|
||||
"father": "Osamu von den Kleinen Chaoten",
|
||||
"mother": "Montana v.d. Kleinen Chaoten",
|
||||
"source": "Stammbaum von Kazuya.xlsx"
|
||||
},
|
||||
{
|
||||
"name": "Mozart of Lennylengo",
|
||||
"dob": "12.03.2017",
|
||||
"decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.",
|
||||
"gender": "female",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)"
|
||||
},
|
||||
{
|
||||
"name": "Yuki von den Kleinen Chaoten",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).",
|
||||
"father": "Chevrolet Camaro of Topolino",
|
||||
"mother": "Izumi von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #23"
|
||||
},
|
||||
{
|
||||
"name": "Gold v.d. Kleinen Chaoten",
|
||||
"dob": "03.08.2023",
|
||||
"decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.",
|
||||
"father": "Trogir von den Kleinen Chaoten",
|
||||
"mother": "Chelsea von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #36"
|
||||
},
|
||||
{
|
||||
"name": "Arya Stark von den Kleinen Chaoten",
|
||||
"dob": "30.06.2020",
|
||||
"decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).",
|
||||
"father": "Vance von den Kleinen Chaoten",
|
||||
"mother": "Sansa Stark von den Kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #16"
|
||||
},
|
||||
{
|
||||
"name": "Tony v.d. Kleinen Chaoten",
|
||||
"dob": "16.09.2015",
|
||||
"decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.",
|
||||
"father": "Sammy von den Kleinen Chaoten",
|
||||
"mother": "Qamikaze Queen von den Schlossmäusen",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #12"
|
||||
},
|
||||
{
|
||||
"name": "Odelia von den Kleinen Chaoten",
|
||||
"dob": "21.06.2015",
|
||||
"decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).",
|
||||
"father": "Cash v.d. Kleinen Chaoten",
|
||||
"mother": "Elena",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #15"
|
||||
},
|
||||
{
|
||||
"name": "Jamie von den kleinen Chaoten",
|
||||
"dob": "27.12.2010",
|
||||
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
|
||||
"father": "Danny von PZ Maintal",
|
||||
"mother": "Jana of little longnoses",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #31"
|
||||
},
|
||||
{
|
||||
"name": "Silver von den kleinen Chaoten",
|
||||
"dob": "20.03.2013",
|
||||
"decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.",
|
||||
"father": "Taro von den kleinen Chaoten",
|
||||
"mother": "Beatrice von den kleinen Chaoten",
|
||||
"source": "Züchterin 2026-06-22 — Ticket #11"
|
||||
},
|
||||
{
|
||||
"name": "Roni",
|
||||
"dob": "27.01.2022",
|
||||
"decision": "gender = male. Roni is the FATHER (not mother) of the T21/Z21/22 litters (Wurfchronik names father=Roni, mother=Fumi). Box colour / chart had her typed female, so role normalisation wrongly put Roni in the mother slot and left the father empty. Setting male lets Roni take the father role; Fumi (added via addAnimals) fills the mother slot.",
|
||||
"gender": "male",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Roni ist Vater)"
|
||||
},
|
||||
{
|
||||
"name": "Cherry Berry's Quqquluuruu",
|
||||
"dob": "",
|
||||
"decision": "gender = female (weisse Box = weiblich, blaue = maennlich). Pure pedigree ancestor without a birthdate, so the box-colour gender was never read — the Stammbaum showed a question mark instead of the female icon. dob MUST be empty so the name-only override key matches.",
|
||||
"gender": "female",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 94892100 (Weibchen-Icon fehlt)"
|
||||
},
|
||||
{
|
||||
"name": "Sunny von PZ Karl",
|
||||
"dob": "10.04.2014",
|
||||
"decision": "father is Bill von Privat, mother is Melly von Privat (from Stammbaum von Yurikas und Pintos Sohn.xlsx / Renner-Pro-3). The chart-position resolver had wrongly given Hiro of Golden Lights as father.",
|
||||
"father": "Bill von Privat",
|
||||
"mother": "Melly von Privat",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 0ba551a3 (Sunny falscher Vater)"
|
||||
},
|
||||
{
|
||||
"name": "Danielle von den Kleinen Chaoten",
|
||||
"dob": "04.03.2020",
|
||||
"decision": "mother is Ella (*10.06.2019); father is Makoto von den Kleinen Chaoten (*16.07.2019), Ellas juengerer Bruder — eine Geschwisterverpaarung (Ella ist die aeltere Schwester). Louis & Roswitha sind Danielles GROSSeltern, nicht ihre Eltern.",
|
||||
"mother": "Ella",
|
||||
"motherDob": "10.06.2019",
|
||||
"source": "Züchterin 2026-06-23 — Ticket 4692fd5c (Vater Makoto)",
|
||||
"father": "Makoto von den Kleinen Chaoten",
|
||||
"fatherDob": "16.07.2019"
|
||||
},
|
||||
{
|
||||
"name": "Unbekannt",
|
||||
"dob": "15.02.2024",
|
||||
"decision": "Nameless buck *15.02.2024 (son of Inochi gen. Picu) is recorded twice — once via 'Stammbaum von Picus Son.xlsx' (externalRef stammbaum-unbekannt-15022024-2) and once via 'Stammbaum von Alberto Kids.xlsx' (externalRef stammbaum-unbekannt-15022024). Same buck. The two reconstructed litters (Picus Son vs Alberto Kids) differ only by the mother's birth YEAR (a typo): mother 13.11.2023 vs 13.11.2022. Merge the two bucks and the two mothers into one each via mergeExternalRefs.",
|
||||
"mergeExternalRefs": [
|
||||
[
|
||||
"stammbaum-unbekannt-15022024-2",
|
||||
"stammbaum-unbekannt-15022024"
|
||||
],
|
||||
[
|
||||
"stammbaum-unbekannt-13112023",
|
||||
"stammbaum-unbekannt-13112022"
|
||||
]
|
||||
],
|
||||
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
|
||||
},
|
||||
{
|
||||
"name": "Mamta Mini v.d. Kleinen Chaoten",
|
||||
"dob": "11.11.2023",
|
||||
"decision": "E-Locus = Ee (das unbekannte zweite Allel ist erzwungen 'e', weil Vater Geely von den Kleinen Chaoten am E-Locus reinerbig ee=Fuchs ist und nur 'e' vererben kann). Eltern Geely (Vater) × Gaida (Mutter) verbindlich am Geburtswurf verankert (die chart-position-Heuristik lieferte sie bereits) — als Entscheidung/high gesetzt, damit der Wurf die Eltern sicher verknuepft.",
|
||||
"genotype": "AA CC D- Ee Gg PP spsp",
|
||||
"father": "Geely von den Kleinen Chaoten",
|
||||
"mother": "Gaida von den Kleinen Chaoten",
|
||||
"fatherDob": "04.03.2023",
|
||||
"motherDob": "02.08.2022",
|
||||
"source": "Züchterin 2026-06-22 — Tickets cc9ea3fe / 1a508c04 (Mamta Mini Ee[-]→Ee, Eltern Geely×Gaida)"
|
||||
},
|
||||
{
|
||||
"name": "",
|
||||
"externalRef": "unbekannt-13082025-3",
|
||||
"decision": "Sp-Locus = spsp (KEINE Schecke). Das namenlose Weibchen (*13.08.2025, Quelle 'Stammbaum von Martin.xlsx') war im Quell-Stammbaum als Spsp notiert, ist aber ungescheckt — der Sp-Locus muss spsp sein. Der Farbschlag bleibt der genotyp-berechnete Kohlfuchsschimmel (ee[f] = Fuchsschimmel). externalRef pinnt genau dieses Tier (mehrere namenlose Tiere teilen das Datum 13.08.2025).",
|
||||
"genotype": "aa Cc[chm] D- ee[f] Gg Pp spsp",
|
||||
"source": "Züchterin 2026-06-22 — Ticket e09d6f22 (faelschlich Schecke, soll spsp)"
|
||||
},
|
||||
{
|
||||
"name": "Catelyn Stark von den Kleinen Chaoten",
|
||||
"dob": "11.08.2015",
|
||||
"decision": "parents are Eddard Stark of Sunset Glow (father, *20.06.2012) x Milena von den Kleinen Chaoten (mother, *19.03.2014). Chart-position had mother=Katara and no father.",
|
||||
"father": "Eddard Stark of Sunset Glow",
|
||||
"fatherDob": "20.06.2012",
|
||||
"mother": "Milena von den Kleinen Chaoten",
|
||||
"motherDob": "19.03.2014",
|
||||
"source": "Züchterin 2026-06-23 — Ticket 7bbc045c"
|
||||
},
|
||||
{
|
||||
"name": "Gaida von den Kleinen Chaoten",
|
||||
"dob": "02.08.2022",
|
||||
"decision": "parents are Zhuāngzǐ von den Kleinen Chaoten (father) x Zaibunissa von den Kleinen Chaoten (mother), beide *21.02.2020 — eine Geschwisterverpaarung (Kinder von Nisha x Zenon). Import hatte faelschlich Nisha (Grossmutter) als Mutter und keinen Vater.",
|
||||
"father": "Zhuāngzǐ von den Kleinen Chaoten",
|
||||
"fatherDob": "21.02.2020",
|
||||
"mother": "Zaibunissa von den Kleinen Chaoten",
|
||||
"motherDob": "21.02.2020",
|
||||
"source": "Züchterin 2026-06-23 — Ticket ba63325a"
|
||||
},
|
||||
{
|
||||
"name": "Bentley",
|
||||
"externalRef": "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003",
|
||||
"isResident": false,
|
||||
"decision": "Nur Vorfahre (Clan of Black Forest), kein eigenes Zuchttier. Bestätigt von der Züchterin.",
|
||||
"source": "Züchterin 2026-06-23 — Ticket 09bcac78"
|
||||
},
|
||||
{
|
||||
"name": "Alexandria",
|
||||
"externalRef": "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004",
|
||||
"isResident": false,
|
||||
"decision": "Nur Vorfahre, kein eigenes Zuchttier (wie Bentley).",
|
||||
"source": "Züchterin 2026-06-23 — Ticket 45cc501b"
|
||||
},
|
||||
{
|
||||
"name": "Bugatti",
|
||||
"externalRef": "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003",
|
||||
"isResident": false,
|
||||
"decision": "Bugatti stammt aus Clan of Black Forest, war nie ihr Zuchttier — nur Vorfahre.",
|
||||
"source": "Züchterin 2026-06-23 — Ticket 09bcac78"
|
||||
},
|
||||
{
|
||||
"name": "Hiro of Golden Lights",
|
||||
"dob": "13.02.2012",
|
||||
"decision": "Eltern aus RennmausPro ergänzt: Wynn × Amidala",
|
||||
"source": "Züchterin 2026-06-23 — Ticket f3ad5ec9 (Hiro-Ahnen aus RennmausPro)",
|
||||
"father": "Wynn",
|
||||
"fatherDob": "16.05.2011",
|
||||
"mother": "Amidala",
|
||||
"motherDob": "06.07.2011"
|
||||
}
|
||||
],
|
||||
"addAnimals": [
|
||||
{
|
||||
"name": "Fumi von den Kleinen Chaoten",
|
||||
"gender": "female",
|
||||
"zucht": "Zucht der kleinen Chaoten",
|
||||
"decision": "Mother of the T21/Z21/22 litters (Wurfchronik names mother=Fumi, father=Roni) but she has no own source record, so her litters showed an empty mother. Materialised as a non-resident stub so the parent link resolves; her own parents stay unknown.",
|
||||
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Mutter Fumi)"
|
||||
},
|
||||
{
|
||||
"name": "Wynn",
|
||||
"gender": "male",
|
||||
"dob": "16.05.2011"
|
||||
},
|
||||
{
|
||||
"name": "Amidala",
|
||||
"gender": "female",
|
||||
"dob": "06.07.2011"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1099,9 +1099,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
||||
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
||||
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
|
||||
decisions_ref = {} # externalRef (merged-animal id) -> r — for NAMELESS animals whose
|
||||
# (name="" + dob) key is shared by several records: the externalRef
|
||||
# (the dedup slug, e.g. „unbekannt-13082025-3") pins exactly one.
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for r in (json.load(fh).get("resolutions") or []):
|
||||
ref = r.get("externalRef")
|
||||
if ref:
|
||||
decisions_ref[ref] = r
|
||||
# An externalRef-only decision (no name) must NOT register a name/dob
|
||||
# key — a ("", "") key would match every nameless, dateless animal.
|
||||
if not r.get("name"):
|
||||
continue
|
||||
nc, zc = canon_pair(r.get("name", ""))
|
||||
dob = norm_dob(r.get("dob", ""))
|
||||
if zc:
|
||||
@@ -1110,14 +1120,17 @@ def apply_conflict_decisions(merged, conflicts, path):
|
||||
decisions_name[(nc, dob)] = r
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
if not decisions_full and not decisions_name:
|
||||
if not decisions_full and not decisions_name and not decisions_ref:
|
||||
return 0
|
||||
|
||||
resolved = 0
|
||||
for a in merged:
|
||||
nc, zc = canon_pair(a["name"])
|
||||
dob = norm_dob(a["dob"])
|
||||
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
|
||||
# externalRef (the dedup id) wins — it is the most specific key and the only
|
||||
# way to address one of several same-(name,dob) nameless animals.
|
||||
d = decisions_ref.get(a.get("id")) or decisions_full.get((nc, zc, dob)) \
|
||||
or decisions_name.get((nc, dob))
|
||||
if not d:
|
||||
continue
|
||||
a["resolvedByDecision"] = True
|
||||
|
||||
@@ -176,3 +176,261 @@ def looks_like_genotype(text):
|
||||
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
||||
n += 1
|
||||
return n >= 3
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Genotype → Farbschlag (German variety name)
|
||||
#
|
||||
# A faithful Python port of gerbil-manager-web/src/genetics/catalog.ts
|
||||
# (`genotypeToFarbschlag` + the loci/genotype helpers it relies on). The engine
|
||||
# is the single source of truth for the colour names; the IMPORT mirrors it here
|
||||
# so the stored colorVarietyId can be DERIVED from a known genotype instead of a
|
||||
# fragile free-text colour label (ticket cluster genetics-farbschlag).
|
||||
#
|
||||
# Allele symbols here use the CATALOG form (cchm / ch / ef), so we normalise the
|
||||
# parser's '^'-form ('c^chm' -> 'cchm', 'e^f' -> 'ef') and treat '?' as unknown.
|
||||
# Keep this in lockstep with catalog.ts — when the TS catalog changes, change here
|
||||
# too (the round-trip tests in test_genotype.py guard the mapping).
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Alleles per locus, MOST-DOMINANT FIRST (mirror of loci.ts LOCI).
|
||||
_FARB_LOCI = {
|
||||
"A": ["A", "a"],
|
||||
"C": ["C", "cchm", "ch"],
|
||||
"D": ["D", "d"],
|
||||
"E": ["E", "ef", "e"],
|
||||
"G": ["G", "g"],
|
||||
"P": ["P", "p"],
|
||||
"Sp": ["Sp", "sp"],
|
||||
"Re": ["Re", "re"],
|
||||
}
|
||||
_MARKER_LOCI = ("Sp", "Re", "Sls")
|
||||
|
||||
UNKNOWN_FARBSCHLAG = "Unbekannter Farbschlag"
|
||||
|
||||
# BASE_COLORS — order matters (first match wins). Mirror of catalog.ts BASE_COLORS.
|
||||
# Each entry: (name, {locus: token, ...}); omitted loci are wildcards.
|
||||
_BASE_COLORS = [
|
||||
# ── Frozen names (DB-key contract) ──
|
||||
("REW", {"C": "ch", "P": "p"}),
|
||||
("Hermelin", {"A": "a", "C": "ch", "D": "D", "P": "P"}),
|
||||
("Himalaya", {"A": "A", "C": "ch", "D": "D", "P": "P"}),
|
||||
("Zobel", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("Rotaugenschimmel", {"C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||
("Agouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("Schwarz", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("Silberagouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("Anthrazit", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("Algierfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("Blau", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
|
||||
("Gold", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Platin", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Goldfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||
("Rotfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||
("Dilute Gold", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||
("Dilute Platin", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||
# ── baseportal.de varieties ──
|
||||
("Altweiss (REW)", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
|
||||
("Apricot (Blassfuchs)", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
|
||||
("Blaufuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("C-Separator", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
|
||||
("Elfenbein", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
|
||||
("Kohlfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("Polarfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("Saphir", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Orangeschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
("Topas", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Platin-Hell", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Dilute Agouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
|
||||
("Dilute Silberagouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
|
||||
("Dilute Kohlfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||
("Dilute Anthrazit", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
|
||||
("Dilute Algierfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||
("Dilute Goldfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
|
||||
("Dilute Rotfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
|
||||
("Dilute Polarfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||
("Silberschimmel", {"C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||
("Polarfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||
("Algierfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
("Kohlfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
("Blaufuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||
("Kohlfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("Goldfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||
("Goldfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||
("Gold-Hell", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||
("Blaufuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("Rotfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||
("Polarfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("Kohlfuchsschimmel, hell", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
("Rotfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||
("Kohlfuchs-Hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("Algierfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("Dilute Topas", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||
("Dilute Blaufuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||
# ── c^chm colourpoint varieties ──
|
||||
("Marder", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("Siam", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("Zobel-Hell", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("CP-Agouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("CP-Agouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||
("CP-Silberagouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("CP-Silberagouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||
("CP-Algierfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("CP-Algierfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||
("CP-Polarfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("CP-Polarfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||
("CP-Fuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||
("CP-Fuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||
("CP-Blaufuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||
("CP-Orangeschimmel", {"C": "cchm", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
("CP-Orangeschimmel-Hell", {"C": "cchm/ch", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_allele(a):
|
||||
"""Parser allele form -> catalog form. 'c^chm'->'cchm', 'e^f'->'ef', '-'/None->'?'."""
|
||||
if a is None or a == "-":
|
||||
return "?"
|
||||
return a.replace("^", "")
|
||||
|
||||
|
||||
def _resolve_allele_pair(locus, pair):
|
||||
"""GEN-5 unknown-allele rule (mirror of genotype.ts resolveAllelePair).
|
||||
An unknown '?' is a COPY of the known partner; both unknown -> wild-type
|
||||
(markers default to the unmarked recessive)."""
|
||||
a = _normalize_allele(pair[0] if len(pair) > 0 else "?")
|
||||
b = _normalize_allele(pair[1] if len(pair) > 1 else "?")
|
||||
a_unknown = a == "?"
|
||||
b_unknown = b == "?"
|
||||
if not a_unknown and not b_unknown:
|
||||
return [a, b]
|
||||
if a_unknown and b_unknown:
|
||||
alleles = _FARB_LOCI.get(locus, ["?"])
|
||||
fb = alleles[-1] if locus in _MARKER_LOCI else alleles[0]
|
||||
return [fb, fb]
|
||||
known = b if a_unknown else a
|
||||
return [known, known]
|
||||
|
||||
|
||||
def _dominance_rank(locus, allele):
|
||||
alleles = _FARB_LOCI.get(locus, [])
|
||||
return alleles.index(allele) if allele in alleles else len(alleles)
|
||||
|
||||
|
||||
def _dominant_allele(locus, a, b):
|
||||
return a if _dominance_rank(locus, a) <= _dominance_rank(locus, b) else b
|
||||
|
||||
|
||||
def _locus_token(mapped, locus):
|
||||
"""Expressed token at a locus (mirror of catalog.ts locusToken)."""
|
||||
x, y = _resolve_allele_pair(locus, mapped.get(locus, ["?", "?"]))
|
||||
if locus == "E":
|
||||
if x == y:
|
||||
return x
|
||||
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
|
||||
return "ef"
|
||||
return _dominant_allele("E", x, y)
|
||||
return _dominant_allele(locus, x, y)
|
||||
|
||||
|
||||
def _e_family(mapped):
|
||||
"""E-locus family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') or None."""
|
||||
x, y = _resolve_allele_pair("E", mapped.get("E", ["?", "?"]))
|
||||
if x == "e" and y == "e":
|
||||
return "Fuchs"
|
||||
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
|
||||
return "Fuchsschimmel"
|
||||
if x == "ef" and y == "ef":
|
||||
return "Schimmel"
|
||||
return None
|
||||
|
||||
|
||||
def _entry_in_e_family(name, tokens, family):
|
||||
if tokens.get("E") is None:
|
||||
return False
|
||||
n = name.lower()
|
||||
if family == "Fuchsschimmel":
|
||||
return "fuchsschimmel" in n
|
||||
if family == "Schimmel":
|
||||
return "schimmel" in n and "fuchsschimmel" not in n
|
||||
return "schimmel" not in n
|
||||
|
||||
|
||||
def _matches(mapped, tokens):
|
||||
return all(_locus_token(mapped, locus) == tok for locus, tok in tokens.items())
|
||||
|
||||
|
||||
def _base_colour_for(mapped):
|
||||
family = _e_family(mapped)
|
||||
if family:
|
||||
for name, tokens in _BASE_COLORS:
|
||||
if _entry_in_e_family(name, tokens, family) and _matches(mapped, tokens):
|
||||
return name
|
||||
return None
|
||||
for name, tokens in _BASE_COLORS:
|
||||
if _matches(mapped, tokens):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _colourpoint_name(mapped):
|
||||
"""C-locus colourpoint NAMING transform (mirror of catalog.ts colourpointName)."""
|
||||
c = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
|
||||
if "C" in c:
|
||||
return None
|
||||
if c[0] == "ch" and c[1] == "ch":
|
||||
return None
|
||||
both_cchm = c[0] == "cchm" and c[1] == "cchm"
|
||||
agouti = "A" in _resolve_allele_pair("A", mapped.get("A", ["?", "?"]))
|
||||
if not agouti and _e_family(mapped) is None:
|
||||
g1, g2 = _resolve_allele_pair("G", mapped.get("G", ["?", "?"]))
|
||||
grey = g1 == "g" and g2 == "g"
|
||||
if grey:
|
||||
return "Zobel" if both_cchm else "Zobel-Hell"
|
||||
return "Marder" if both_cchm else "Siam"
|
||||
# Name the colour as if C were full, then prefix 'CP-'.
|
||||
forced = dict(mapped)
|
||||
forced["C"] = ["C", "C"]
|
||||
base = _base_colour_for(forced)
|
||||
if not base:
|
||||
return None
|
||||
DILUTE = "Dilute "
|
||||
if base.startswith(DILUTE):
|
||||
return f"{DILUTE}CP-{base[len(DILUTE):]}{'' if both_cchm else '-Hell'}"
|
||||
return f"CP-{base}{'' if both_cchm else '-Hell'}"
|
||||
|
||||
|
||||
_CATEGORY_NAMES = {
|
||||
"Standard", "Colourpoint", "Dilute",
|
||||
"Fuchs", "Fuchsschimmel", "Schimmel",
|
||||
"Colourpoint Dilute",
|
||||
}
|
||||
|
||||
|
||||
def genotype_to_farbschlag(mapped):
|
||||
"""Resolve a parsed `mapped8locus` dict to its German Farbschlag (BASE name,
|
||||
WITHOUT Schecke/Rex modifiers), or `UNKNOWN_FARBSCHLAG`.
|
||||
|
||||
`mapped` is genotype.parse(...)['mapped8locus'] (allele '^'-form / '-' / missing
|
||||
loci tolerated). Modifiers (Schecke/Rex) are intentionally OMITTED — the import
|
||||
stores them as the genotype's Sp/Re loci and via the colour label, not in the
|
||||
catalog colorVarietyId. Mirror of catalog.ts genotypeToFarbschlag (minus the
|
||||
appended modifiers).
|
||||
"""
|
||||
if not mapped:
|
||||
return UNKNOWN_FARBSCHLAG
|
||||
# REW check: both C reduced (no full 'C') AND pink-eyed (pp).
|
||||
c0, c1 = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
|
||||
p0, p1 = _resolve_allele_pair("P", mapped.get("P", ["?", "?"]))
|
||||
c_reduced = lambda c: c in ("cchm", "ch")
|
||||
if c_reduced(c0) and c_reduced(c1) and p0 == "p" and p1 == "p":
|
||||
return "REW"
|
||||
base_name = _colourpoint_name(mapped) or _base_colour_for(mapped)
|
||||
if not base_name or base_name in _CATEGORY_NAMES:
|
||||
return UNKNOWN_FARBSCHLAG
|
||||
return base_name
|
||||
|
||||
|
||||
def farbschlag_from_genotype_string(raw):
|
||||
"""Convenience: raw genotype string -> Farbschlag base name (or UNKNOWN)."""
|
||||
return genotype_to_farbschlag(parse(raw).get("mapped8locus") or {})
|
||||
|
||||
@@ -5,6 +5,8 @@ import uuid
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import genotype as gt
|
||||
|
||||
# Prevent encoding crashes on Windows consoles when printing unicode
|
||||
if sys.platform.startswith('win'):
|
||||
try:
|
||||
@@ -422,33 +424,39 @@ def get_dedup_name_key(name):
|
||||
return "".join(c for c in n if c.isalnum())
|
||||
|
||||
def clean_color_name(c_desc):
|
||||
"""Normalise a free-text colour label to a catalog key + Schecke flag.
|
||||
|
||||
Returns (clean_name, is_schecke). A PARENTHETICAL „(schimmel)" is NOT a
|
||||
definitive Schimmel — the breeder writes it to mean „könnte sich später als
|
||||
Schimmel entpuppen" (ticket e22764aa). So we STRIP the „(…)" instead of
|
||||
folding it into the name (which used to turn „Blaufuchs(schimmel)" into the
|
||||
wrong „blaufuchsschimmel"); the still-uncertain Schimmel-modifier is carried
|
||||
by the genotype (ee[-] = Fuchs, Schimmel unknown), not the colour label.
|
||||
"""
|
||||
if not c_desc:
|
||||
return "", False
|
||||
|
||||
|
||||
# Lowercase and strip
|
||||
c = c_desc.lower().strip()
|
||||
|
||||
|
||||
# Check for Schecke
|
||||
is_schecke = False
|
||||
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
|
||||
is_schecke = True
|
||||
|
||||
# Standardize parentheticals for schimmel
|
||||
c = c.replace("(schimmel)", "schimmel")
|
||||
c = c.replace("(schimmel-hell)", "schimmel hell")
|
||||
c = c.replace("(schimmel hell)", "schimmel hell")
|
||||
|
||||
|
||||
# Strip schecke/sp markers and any trailing text starting from sp
|
||||
c = re.sub(r'\([- ]?sp(otted)?\)', '', c) # handles (-sp)
|
||||
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
|
||||
c = re.sub(r'[- ]?schecke\b.*', '', c)
|
||||
c = re.sub(r'[- ]?spotted\b.*', '', c)
|
||||
|
||||
# Strip any other parentheticals, symbols, or trailing stars/numbers
|
||||
|
||||
# Strip any other parentheticals (incl. „(schimmel)" = „möglich/unbestimmt"),
|
||||
# symbols, or trailing stars/numbers. The parenthetical is deliberately NOT
|
||||
# promoted to a definitive part of the colour name (ticket e22764aa).
|
||||
c = re.sub(r'\s*\(.*?\)\s*', ' ', c)
|
||||
c = re.sub(r'[²³*]', '', c)
|
||||
c = c.strip()
|
||||
|
||||
|
||||
# Mapping table for abbreviations, typos, and specific combinations
|
||||
mapping = {
|
||||
"antra": "anthrazit",
|
||||
@@ -476,27 +484,87 @@ def clean_color_name(c_desc):
|
||||
|
||||
return c, is_schecke
|
||||
|
||||
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
|
||||
if not color_val:
|
||||
return None, existing_genotype
|
||||
color_str = str(color_val).strip()
|
||||
clean_name, is_schecke = clean_color_name(color_str)
|
||||
# Match color in variety_map
|
||||
color_variety_id = None
|
||||
def _match_color_label(clean_name, variety_map):
|
||||
"""Map a cleaned colour label to a ColorVariety id (text-only path).
|
||||
|
||||
Exact name wins; otherwise pick the LONGEST/most-specific substring match
|
||||
(ticket 3f5942a2 — the old code broke on the FIRST substring hit, so „Goldfuchs"
|
||||
matched the shorter „Gold" first). Among substring candidates the longest seed
|
||||
name wins, then the longest clean_name overlap; ties broken deterministically.
|
||||
"""
|
||||
if not clean_name:
|
||||
return None
|
||||
if clean_name in variety_map:
|
||||
color_variety_id = variety_map[clean_name]
|
||||
else:
|
||||
for seed_name, seed_id in variety_map.items():
|
||||
if seed_name in clean_name or clean_name in seed_name:
|
||||
color_variety_id = seed_id
|
||||
break
|
||||
# Update genotype if it's a Schecke
|
||||
return variety_map[clean_name]
|
||||
candidates = []
|
||||
for seed_name, seed_id in variety_map.items():
|
||||
if not seed_name:
|
||||
continue
|
||||
if seed_name in clean_name or clean_name in seed_name:
|
||||
# Specificity score: prefer the longer seed name (more specific),
|
||||
# then the closeness of lengths so „goldfuchs" beats „gold" for the
|
||||
# label „goldfuchs".
|
||||
candidates.append((len(seed_name), -abs(len(seed_name) - len(clean_name)),
|
||||
seed_name, seed_id))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(reverse=True)
|
||||
return candidates[0][3]
|
||||
|
||||
|
||||
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
|
||||
"""Resolve a gerbil's stored ColorVariety id + genotype.
|
||||
|
||||
GENOTYPE WINS (ticket cluster genetics-farbschlag): when a parseable genotype
|
||||
is present and the genetics engine (genotype.genotype_to_farbschlag — a faithful
|
||||
Python mirror of catalog.ts) computes a KNOWN catalog variety, that variety is
|
||||
authoritative for colorVarietyId. The free-text colour label is only a fallback
|
||||
(no genotype, or genotype resolves to „Unbekannt"). This fixes the imports where
|
||||
the source label ignored a locus (dd → „Agouti" instead of „Dilute Agouti",
|
||||
ee → „Gold" instead of „Goldfuchs", parenthetical „(schimmel)", …).
|
||||
|
||||
Returns (color_variety_id, genotype). `genotype` is the (possibly Schecke-
|
||||
annotated) genotype STRING — never silently flips an explicit spsp to Spsp.
|
||||
"""
|
||||
if not color_val and not existing_genotype:
|
||||
return None, existing_genotype
|
||||
|
||||
clean_name, is_schecke = clean_color_name(str(color_val).strip()) if color_val else ("", False)
|
||||
|
||||
# 1) Genotype-derived variety (authoritative when it resolves to a known name).
|
||||
# GUARD (VORSICHTIG): only trust the genotype when it parsed CLEANLY enough to
|
||||
# decide a colour — both the C and E loci must be mapped. The breeder sometimes
|
||||
# writes the genotype in the COMPACT catalog notation („cchmcchm", „efef",
|
||||
# „chch") which this parser leaves UNMAPPED (it expects the bracketed „c[chm]"
|
||||
# form); a dropped C/E locus would silently read as wild-type and mis-recolour
|
||||
# an otherwise-correct animal (e.g. Marder→Schwarz, Orangeschimmel→Agouti). When
|
||||
# the parse is incomplete we keep the source text label instead.
|
||||
color_variety_id = None
|
||||
geno_name = None
|
||||
if existing_genotype:
|
||||
try:
|
||||
mapped = gt.parse(existing_genotype).get("mapped8locus") or {}
|
||||
except Exception:
|
||||
mapped = {}
|
||||
if mapped.get("C") and mapped.get("E"):
|
||||
fs = gt.genotype_to_farbschlag(mapped)
|
||||
if fs and fs != gt.UNKNOWN_FARBSCHLAG:
|
||||
geno_name = fs
|
||||
color_variety_id = variety_map.get(fs.strip().lower())
|
||||
|
||||
# 2) Fall back to the text label when the genotype gave nothing usable.
|
||||
if not color_variety_id:
|
||||
color_variety_id = _match_color_label(clean_name, variety_map)
|
||||
|
||||
# Update genotype if the LABEL says Schecke — but never override an explicit
|
||||
# Sp-locus already present in the source genotype (ticket e09d6f22: a source
|
||||
# „spsp" must NOT be flipped to „Spsp" just because the label looked scheckig;
|
||||
# the source genotype is authoritative for the Sp-locus). Only ADD Spsp when
|
||||
# the genotype carries no Sp token at all.
|
||||
genotype = existing_genotype
|
||||
if is_schecke:
|
||||
if genotype:
|
||||
if "spsp" in genotype:
|
||||
genotype = genotype.replace("spsp", "Spsp")
|
||||
elif "Spsp" not in genotype and "Sp" not in genotype:
|
||||
if "Sp" not in genotype and "sp" not in genotype:
|
||||
genotype = f"{genotype} Spsp".strip()
|
||||
else:
|
||||
canonical = variety_genotypes.get(color_variety_id)
|
||||
@@ -2948,6 +3016,51 @@ def main():
|
||||
|
||||
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
|
||||
|
||||
# ── isResident-Overrides (Mensch-Entscheidung) ────────────────────────────
|
||||
# NACH dem Dedup anwenden, sonst würde die Dedup-Zusammenführung (IsResident=True
|
||||
# falls eine Variante resident ist) sie wieder überschreiben. Matcht primär über
|
||||
# `externalRef` (präzise, auch für namenlose Tiere) oder sonst über
|
||||
# normalize(call-name)+ISO-dob (leere dob = name-only, für Vorfahren ohne Datum).
|
||||
_isres_by_extref = {}
|
||||
_isres_by_namedob = {}
|
||||
for d in _decisions:
|
||||
if "isResident" not in d:
|
||||
continue
|
||||
val = bool(d["isResident"])
|
||||
er = (d.get("externalRef") or "").strip()
|
||||
if er:
|
||||
_isres_by_extref[er] = val
|
||||
nm = d.get("name")
|
||||
if nm is not None:
|
||||
ck = normalize_name(get_call_name(nm or ""))
|
||||
iso = parse_date(d.get("dob")) if d.get("dob") else ""
|
||||
_isres_by_namedob[(ck, iso or "")] = val
|
||||
_isres_applied = 0
|
||||
if _isres_by_extref or _isres_by_namedob:
|
||||
for g in resolved_gerbils:
|
||||
er = g.get("ExternalRef") or ""
|
||||
ck = normalize_name(get_call_name(g.get("Name") or ""))
|
||||
iso = g.get("DateOfBirth") or ""
|
||||
if er and er in _isres_by_extref:
|
||||
val = _isres_by_extref[er]
|
||||
elif er and any(er.endswith(k) for k in _isres_by_extref):
|
||||
val = next(v for k, v in _isres_by_extref.items() if er.endswith(k))
|
||||
elif (ck, iso) in _isres_by_namedob:
|
||||
val = _isres_by_namedob[(ck, iso)]
|
||||
elif (ck, "") in _isres_by_namedob:
|
||||
val = _isres_by_namedob[(ck, "")]
|
||||
else:
|
||||
continue
|
||||
if g.get("IsResident") != val:
|
||||
g["IsResident"] = val
|
||||
# Ein Nicht-Bestandstier ist kein eigenes Zuchttier → den eigenen
|
||||
# Zucht-Breeder entfernen (nur, wenn er auf die eigene Zucht zeigt).
|
||||
if not val and g.get("OriginBreeder") == "Zucht der kleinen Chaoten":
|
||||
g["OriginBreeder"] = None
|
||||
_isres_applied += 1
|
||||
if _isres_applied:
|
||||
print(f"isResident-Overrides angewandt: {_isres_applied}")
|
||||
|
||||
# Apply age-based death threshold (6.0 years) to all resolved gerbils
|
||||
dt_now = datetime.now()
|
||||
for g in resolved_gerbils:
|
||||
|
||||
233
tools/import/rpro3_lookup.py
Normal file
233
tools/import/rpro3_lookup.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Triage-Helfer: Beantwortet die Gegenfragen der Züchterin zu RPRO3-Namensdubletten.
|
||||
|
||||
Liefert pro Variante (A/B/C … exakt wie im Rückfrage-Ticket) die Daten, die die
|
||||
Züchterin typischerweise sehen will: Geburtsdatum, Farbe, **Gencode (Fcode)**,
|
||||
Herkunft, Eltern (Vater/Mutter) sowie Nachzucht inkl. Co-Elternteil (Partner).
|
||||
|
||||
Die Variantenbildung/Label-Vergabe spiegelt rpro3_tickets.py + compare_rpro3.dedup
|
||||
exakt (Union-Find über gleiche Namen; Filter „informativ"; Sortierung
|
||||
(not is_own, -count) → A,B,C…). Im Gegensatz zum Ticket wird hier über den GESAMTEN
|
||||
Cluster aggregiert (rpro3_tickets kappt rids auf 4 – Nachzucht wäre sonst unvollständig).
|
||||
|
||||
Aufruf:
|
||||
python rpro3_lookup.py <_rpro3.db> "<Name>" [LETTERS]
|
||||
LETTERS optional, z. B. "B,C" → nur diese Varianten. Default: alle.
|
||||
python rpro3_lookup.py <_rpro3.db> "<Name>" --json → strukturiert (für Agenten)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json
|
||||
from collections import defaultdict
|
||||
import compare_rpro3 as C
|
||||
|
||||
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def informative(recs):
|
||||
return any(r["dob"] or r["farbe"] or r["origin"] for r in recs)
|
||||
|
||||
|
||||
def build_clusters(animals):
|
||||
"""Spiegelt compare_rpro3.dedup: Union-Find je Name, gibt {name: [cluster_recs,...]}."""
|
||||
def compat(x, y):
|
||||
return (not x) or (not y) or (x == y)
|
||||
|
||||
for a in animals:
|
||||
a["namek"] = C.norm_name(a["name"])
|
||||
a["dobk"] = a["dob"]
|
||||
a["farbek"] = C.norm_farbe(a["farbe"])
|
||||
a["origink"] = C.norm_origin(a["origin"])
|
||||
|
||||
by_name = defaultdict(list)
|
||||
for a in animals:
|
||||
if a["namek"] in C.PLACEHOLDER_NAMES:
|
||||
continue
|
||||
by_name[a["namek"]].append(a)
|
||||
|
||||
parent = {}
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
def union(x, y):
|
||||
parent.setdefault(x, x); parent.setdefault(y, y)
|
||||
parent[find(x)] = find(y)
|
||||
|
||||
def positive(a, b):
|
||||
agree = 0
|
||||
if a["dobk"] and b["dobk"] and a["dobk"] == b["dobk"]:
|
||||
agree += 1
|
||||
if a["farbek"] and b["farbek"] and a["farbek"] == b["farbek"]:
|
||||
agree += 1
|
||||
if a["origink"] and b["origink"] and a["origink"] == b["origink"]:
|
||||
agree += 1
|
||||
return agree
|
||||
|
||||
def conflict(a, b):
|
||||
c = 0
|
||||
if a["dobk"] and b["dobk"] and a["dobk"] != b["dobk"]:
|
||||
c += 1
|
||||
if a["farbek"] and b["farbek"] and a["farbek"] != b["farbek"]:
|
||||
c += 1
|
||||
if a["origink"] and b["origink"] and a["origink"] != b["origink"]:
|
||||
c += 1
|
||||
return c
|
||||
|
||||
for name, group in by_name.items():
|
||||
for a in group:
|
||||
parent.setdefault(a["rid"], a["rid"])
|
||||
n = len(group)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = group[i], group[j]
|
||||
comp = (compat(a["dobk"], b["dobk"]) and compat(a["farbek"], b["farbek"])
|
||||
and compat(a["origink"], b["origink"]))
|
||||
if comp and positive(a, b) >= 1 and conflict(a, b) == 0:
|
||||
union(a["rid"], b["rid"])
|
||||
|
||||
# Cluster je Name sammeln
|
||||
name_clusters = {}
|
||||
by_rid = {a["rid"]: a for a in animals}
|
||||
for name, group in by_name.items():
|
||||
roots = defaultdict(list)
|
||||
for a in group:
|
||||
roots[find(a["rid"])].append(a)
|
||||
name_clusters[name] = list(roots.values())
|
||||
return name_clusters
|
||||
|
||||
|
||||
def variants_for(animals, display_name):
|
||||
"""Liefert [(letter, recs)] für einen Namen – Reihenfolge wie im Ticket."""
|
||||
namek = C.norm_name(display_name)
|
||||
clusters = build_clusters(animals).get(namek, [])
|
||||
info = [recs for recs in clusters if informative(recs)]
|
||||
# Sortierung exakt wie rpro3_tickets.py: eigene Tiere zuerst, dann größere Cluster
|
||||
info.sort(key=lambda recs: (not any(r["src"] == "stamm" for r in recs), -len(recs)))
|
||||
return [(LETTERS[i], recs) for i, recs in enumerate(info)]
|
||||
|
||||
|
||||
def aggregate(recs, animals):
|
||||
"""Aggregiert Daten + Nachzucht über alle recs eines Clusters."""
|
||||
names = {r["name"] for r in recs}
|
||||
dob = sorted({C.iso(r["dob"]) for r in recs if r["dob"]})
|
||||
farbe = sorted({r["farbe"] for r in recs if r["farbe"]})
|
||||
fcode = sorted({r["fcode"] for r in recs if r["fcode"]})
|
||||
origin = sorted({r["origin"] for r in recs if r["origin"]})
|
||||
fathers = sorted({r["father"] for r in recs if r["father"]})
|
||||
mothers = sorted({r["mother"] for r in recs if r["mother"]})
|
||||
is_own = any(r["src"] == "stamm" for r in recs)
|
||||
rids = [r["rid"] for r in recs]
|
||||
|
||||
# Nachzucht: alle Tiere, deren Eltern-rid auf eine rid dieses Clusters zeigt
|
||||
rid_set = set(rids)
|
||||
kids = []
|
||||
seen = set()
|
||||
for a in animals:
|
||||
if str(a.get("mid_raw")) in rid_set or str(a.get("pid_raw")) in rid_set:
|
||||
co = a["father"] if a["mother"] in names else a["mother"]
|
||||
key = (C.norm_name(a["name"]), C.iso(a["dob"]) if a["dob"] else "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
kids.append({"name": a["name"], "dob": C.iso(a["dob"]) if a["dob"] else None,
|
||||
"farbe": a["farbe"], "fcode": a["fcode"], "partner": co})
|
||||
return {"names": sorted(names), "rids": rids, "count": len(recs),
|
||||
"is_own": is_own, "dob": dob, "farbe": farbe, "fcode": fcode,
|
||||
"origin": origin, "fathers": fathers, "mothers": mothers, "kids": kids}
|
||||
|
||||
|
||||
def fmt_block(letter, agg):
|
||||
src = "eigenes Tier" if agg["is_own"] else "externer Ahn"
|
||||
L = []
|
||||
L.append(f"**{letter}** ({src}, {agg['count']}× in RennmausPro):")
|
||||
L.append(f"• Geburtsdatum: {', '.join(agg['dob']) or 'unbekannt'}")
|
||||
L.append(f"• Farbe: {', '.join(agg['farbe']) or 'unbekannt'}")
|
||||
L.append(f"• Gencode: {', '.join(agg['fcode']) or 'unbekannt'}")
|
||||
L.append(f"• Herkunft: {', '.join(agg['origin']) or 'unbekannt'}")
|
||||
vat = ', '.join(agg['fathers']) or 'unbekannt'
|
||||
mut = ', '.join(agg['mothers']) or 'unbekannt'
|
||||
L.append(f"• Eltern: Vater {vat} · Mutter {mut}")
|
||||
if agg["kids"]:
|
||||
L.append("• Nachzucht:")
|
||||
for k in agg["kids"]:
|
||||
d = k["dob"] or "?"
|
||||
f = k["farbe"] or "?"
|
||||
gc = f" [{k['fcode']}]" if k["fcode"] else ""
|
||||
p = f" — Partner: {k['partner']}" if k["partner"] else ""
|
||||
L.append(f" – {k['name']} (geb. {d}, {f}{gc}){p}")
|
||||
else:
|
||||
L.append("• Nachzucht: keine in RennmausPro hinterlegt")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def variants_by_rids(animals, rid_groups):
|
||||
"""rid_groups: Liste von rid-Listen (eine je Variante/Buchstabe). Aggregiert pro Gruppe
|
||||
über den GANZEN Auto-Cluster (eine rid zieht ihren Cluster mit). Für Platzhalter-Namen
|
||||
(„...") und Freitext-Tickets, wo der Name nicht greift, aber rids bekannt sind."""
|
||||
from collections import defaultdict
|
||||
# Auto-Cluster wie build_clusters, aber global über rids indizieren
|
||||
clusters = build_clusters(animals)
|
||||
rid_to_recs = {}
|
||||
for recs_list in clusters.values():
|
||||
for recs in recs_list:
|
||||
for r in recs:
|
||||
rid_to_recs[r["rid"]] = recs
|
||||
out = []
|
||||
for i, grp in enumerate(rid_groups):
|
||||
merged, seen = [], set()
|
||||
for rid in grp:
|
||||
for r in rid_to_recs.get(rid, []):
|
||||
if r["rid"] not in seen:
|
||||
seen.add(r["rid"]); merged.append(r)
|
||||
if merged:
|
||||
out.append((LETTERS[i], merged))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
db = sys.argv[1]
|
||||
name = sys.argv[2]
|
||||
# --rids "u84,u83;u1" → Variante A=u84,u83 Variante B=u1 (per Semikolon getrennt)
|
||||
if name == "--rids":
|
||||
rid_spec = sys.argv[3]
|
||||
R = C.load_rpro3(db)
|
||||
groups = [[x.strip() for x in g.split(",") if x.strip()] for g in rid_spec.split(";") if g.strip()]
|
||||
vs = variants_by_rids(R["animals"], groups)
|
||||
print("=== (per rids) ===")
|
||||
for letter, recs in vs:
|
||||
print(fmt_block(letter, aggregate(recs, R["animals"])))
|
||||
print()
|
||||
return
|
||||
as_json = "--json" in sys.argv[3:]
|
||||
letters = None
|
||||
for a in sys.argv[3:]:
|
||||
if a != "--json":
|
||||
letters = {x.strip().upper() for x in a.split(",") if x.strip()}
|
||||
|
||||
R = C.load_rpro3(db)
|
||||
animals = R["animals"]
|
||||
vs = variants_for(animals, name)
|
||||
if not vs:
|
||||
print(f"(keine Varianten für „{name}“ gefunden)")
|
||||
return
|
||||
out = []
|
||||
for letter, recs in vs:
|
||||
if letters and letter not in letters:
|
||||
continue
|
||||
agg = aggregate(recs, animals)
|
||||
out.append((letter, agg))
|
||||
if as_json:
|
||||
print(json.dumps({"name": name, "variants": {l: a for l, a in out}},
|
||||
ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(f"=== {name} ===")
|
||||
for letter, agg in out:
|
||||
print(fmt_block(letter, agg))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,6 +28,11 @@ r = g.parse("uwuw")
|
||||
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
|
||||
r = g.parse("uw[d]uw[d]")
|
||||
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
|
||||
# GEN-5 (ticket 5151ab20 / Vance): the het 'Uwuw[d]' (one Underwhite, one dense
|
||||
# underwhite) must parse to the G locus as Gg — never crash on the [d] modifier.
|
||||
r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp")
|
||||
check("Uwuw[d] -> Gg (Vance, het dense underwhite)", r["mapped8locus"].get("G") == ["G", "g"])
|
||||
check("Uwuw[d] full string: nothing unmapped", r["unmappedTokens"] == [])
|
||||
|
||||
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
|
||||
check("Gg identical to Uwuw at G locus",
|
||||
@@ -86,6 +91,42 @@ check("Algierfuchs genotype: no unmapped tokens", r["unmappedTokens"] == [])
|
||||
check("looks_like_genotype sees Uw as G",
|
||||
g.looks_like_genotype("aa Cc Uwuw") is True)
|
||||
|
||||
|
||||
# ── genotype_to_farbschlag — Python mirror of catalog.ts genotypeToFarbschlag ──
|
||||
# Genetik/Farbschlag ist korrektheitskritisch: jeder Ticket-Fall bekommt einen
|
||||
# Regressionstest (Konvention). The base name is returned WITHOUT the Schecke/Rex
|
||||
# modifier (the import carries those via the Sp/Re loci, not the variety id).
|
||||
def fs(s):
|
||||
return g.genotype_to_farbschlag(g.parse(s)["mapped8locus"])
|
||||
|
||||
# Ticket 3f5942a2 — Goldfuchs (ee) NOT Gold (EE): a fox genotype must resolve to
|
||||
# a Fuchs variety, never the substring-shorter „Gold".
|
||||
check("3f5942a2: ee Fuchs -> Goldfuchs (not Gold)", fs("AA CC DD ee GG pp spsp") == "Goldfuchs")
|
||||
check("3f5942a2: ee[f] Fuchsschimmel -> Goldfuchsschimmel",
|
||||
fs("Aa C- D- ee[f] G- pp Spsp") == "Goldfuchsschimmel")
|
||||
# Ticket 1aac054f — namenloses Weibchen *13.08.2025: Kohlfuchsschimmel (not Gold).
|
||||
check("1aac054f: aa ee[f] -> Kohlfuchsschimmel",
|
||||
fs("aa Cc[chm] D- ee[f] Gg Pp Spsp") == "Kohlfuchsschimmel")
|
||||
# Ticket 998087e2 — dd must NOT be ignored: Dilute Agouti (not Agouti).
|
||||
check("998087e2: AA dd EE -> Dilute Agouti", fs("AA CC dd EE GG PP spsp") == "Dilute Agouti")
|
||||
check("998087e2 counter: AA DD EE -> Agouti (no dilute)", fs("AA CC DD EE GG PP spsp") == "Agouti")
|
||||
# Ticket 06217eb3 — dd Anthrazit: Dilute Anthrazit (not Anthrazit).
|
||||
check("06217eb3: aa dd gg -> Dilute Anthrazit", fs("aa CC dd Ee gg P- spsp") == "Dilute Anthrazit")
|
||||
# Ticket e22764aa — ee[-] = Fuchs (Schimmel-Modifier unbekannt) -> Blaufuchs,
|
||||
# NEVER Blaufuchsschimmel (the „(schimmel)" parenthetical is „möglich", not definitiv).
|
||||
check("e22764aa: aa ee[-] gg -> Blaufuchs (not …schimmel)",
|
||||
fs("aa C- D- ee[-] gg P- spsp") == "Blaufuchs")
|
||||
# Ticket cc9ea3fe / 1a508c04 — Mamta Mini Ee resolves to Agouti (AA, E_).
|
||||
check("Mamta Mini: AA Ee -> Agouti", fs("AA CC D- Ee Gg PP spsp") == "Agouti")
|
||||
# E-locus phenotype rules (breeder): ef/ef = Schimmel family, ef/e = Fuchsschimmel.
|
||||
check("efef agouti base -> Orangeschimmel", fs("AA CC DD e[f]e[f] GG PP spsp") == "Orangeschimmel")
|
||||
check("ef/e het -> Fuchsschimmel family (Kohlfuchsschimmel)",
|
||||
fs("aa CC DD ee[f] GG PP spsp") == "Kohlfuchsschimmel")
|
||||
# Unknown allele = copy of the visible partner (GEN-5): A? -> AA, D? -> DD.
|
||||
check("unknown copies known: AA C? DD ee GG pp -> Goldfuchs", fs("AA C- DD ee GG pp spsp") == "Goldfuchs")
|
||||
# An incomplete parse must NOT throw and must not be invented as a real colour.
|
||||
check("empty mapping -> Unbekannt", g.genotype_to_farbschlag({}) == g.UNKNOWN_FARBSCHLAG)
|
||||
|
||||
if check.failed:
|
||||
print(f"\n{check.failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -432,6 +432,61 @@ check("contracts: animal-less record has empty Animals list",
|
||||
_sale3 and _sale3[0]["Animals"] == [])
|
||||
|
||||
|
||||
# ── resolve_color_and_genotype + clean_color_name (genetics-farbschlag cluster) ──
|
||||
# A tiny synthetic variety_map (name->id) with the keys these cases need.
|
||||
_VM = {
|
||||
"gold": "ID-gold", "goldfuchs": "ID-goldfuchs", "goldfuchsschimmel": "ID-gfs",
|
||||
"agouti": "ID-agouti", "dilute agouti": "ID-dagouti",
|
||||
"anthrazit": "ID-anthrazit", "dilute anthrazit": "ID-danthrazit",
|
||||
"blaufuchs": "ID-blaufuchs", "blaufuchsschimmel": "ID-bfs",
|
||||
"kohlfuchsschimmel": "ID-kfs", "marder": "ID-marder", "schwarz": "ID-schwarz",
|
||||
"orangeschimmel": "ID-orange",
|
||||
}
|
||||
_VG = {}
|
||||
|
||||
|
||||
def _rc(color, geno):
|
||||
return m.resolve_color_and_genotype(color, geno, _VM, _VG)[0]
|
||||
|
||||
|
||||
# Ticket 3f5942a2 — specificity: „Goldfuchs"-label must NOT collapse to „Gold".
|
||||
check("3f5942a2 label: 'Goldfuchs' -> goldfuchs (not gold)",
|
||||
m._match_color_label("goldfuchs", _VM) == "ID-goldfuchs")
|
||||
# Genotype wins: ee fox genotype overrides a stale „Gold" label.
|
||||
check("3f5942a2 genotype wins: ee -> Goldfuchs over 'Gold' label",
|
||||
_rc("Gold", "AA CC DD ee GG pp spsp") == "ID-goldfuchs")
|
||||
# Ticket 998087e2 — dd ignored by label: genotype gives Dilute Agouti.
|
||||
check("998087e2: dd genotype -> Dilute Agouti over 'Agouti' label",
|
||||
_rc("Agouti", "AA CC dd EE GG PP spsp") == "ID-dagouti")
|
||||
# Ticket 06217eb3 — Dilute Anthrazit.
|
||||
check("06217eb3: dd genotype -> Dilute Anthrazit over 'Anthrazit'",
|
||||
_rc("Anthrazit", "aa CC dd Ee gg P- spsp") == "ID-danthrazit")
|
||||
# Ticket 1aac054f — Kohlfuchsschimmel over a stale 'Gold' label.
|
||||
check("1aac054f: ee[f] genotype -> Kohlfuchsschimmel over 'Gold'",
|
||||
_rc("Gold", "aa Cc[chm] D- ee[f] Gg Pp Spsp") == "ID-kfs")
|
||||
# Ticket e22764aa — „Blaufuchs(schimmel)" parenthetical is NOT definitive; the
|
||||
# cleaned label is „blaufuchs" and the ee[-] genotype confirms Blaufuchs.
|
||||
_cn, _sc = m.clean_color_name("Blaufuchs(schimmel)")
|
||||
check("e22764aa: '(schimmel)' stripped, not promoted -> 'blaufuchs'", _cn == "blaufuchs")
|
||||
check("e22764aa: ee[-] genotype -> Blaufuchs (not Blaufuchsschimmel)",
|
||||
_rc("Blaufuchs(schimmel)", "aa C- D- ee[-] gg P- spsp") == "ID-blaufuchs")
|
||||
# Ticket e09d6f22 — a Schecke-looking LABEL must not flip an explicit source spsp
|
||||
# to Spsp (the source genotype is authoritative for the Sp-locus).
|
||||
_, _g_spsp = m.resolve_color_and_genotype("Kohlfuchsschimmel, hell",
|
||||
"aa Cc[chm] D- ee[f] Gg Pp spsp", _VM, _VG)
|
||||
check("e09d6f22: explicit spsp kept (label-Schecke does not force Spsp)",
|
||||
"Spsp" not in _g_spsp and "spsp" in _g_spsp)
|
||||
# VORSICHTIG guard: a COMPACT-notation genotype (cchmcchm/efef) the parser can't
|
||||
# read must fall back to the text label, NOT mis-recolour (e.g. Marder->Schwarz).
|
||||
check("guard: compact 'cchmcchm' unparsable -> keep label 'Marder'",
|
||||
_rc("Marder", "aa cchmcchm DD EE GG PP spsp rere") == "ID-marder")
|
||||
check("guard: compact 'efef' unparsable -> keep label 'Orangeschimmel'",
|
||||
_rc("Orangeschimmel", "AA CC DD efef GG PP spsp rere") == "ID-orange")
|
||||
# A genuinely Schecke label with no Sp in the genotype still appends Spsp.
|
||||
_, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP", _VM, _VG)
|
||||
check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add)
|
||||
|
||||
|
||||
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
|
||||
# (Only when the pipeline has already been run; tolerant if the file is absent.)
|
||||
import os as _os, json as _json
|
||||
@@ -504,17 +559,30 @@ if _os.path.exists(_resolved):
|
||||
# Sunny von PZ Karl: father corrected Hiro → Bill von Privat.
|
||||
_check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly von Privat")
|
||||
|
||||
# Danielle: mother = Ella *10.06.2019 (the elder one), father unknown.
|
||||
# Danielle: mother = Ella *10.06.2019, father = Makoto (sibling pairing; Ticket 4692fd5c).
|
||||
_dan = _find("Danielle von den Kleinen")
|
||||
_df, _dm = _parents(_dan)
|
||||
check("Danielle: mother is Ella", (_dm or "") == "Ella")
|
||||
check("Danielle: father unknown (sibling pairing)", _df is None)
|
||||
check("Danielle: father is Makoto", (_df or "").startswith("Makoto"))
|
||||
if _dan and _dan.get("LitterId"):
|
||||
_dl = _L.get(_dan["LitterId"])
|
||||
_dmom = _G.get(_dl.get("MotherId")) if _dl else None
|
||||
check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)",
|
||||
_dmom is not None and _dmom.get("DateOfBirth") == "2019-06-10")
|
||||
|
||||
# Catelyn (Ticket 7bbc045c): father Eddard Stark of Sunset Glow, mother Milena.
|
||||
_check_parents("Catelyn", "Catelyn Stark von den Kleinen", "Eddard Stark", "Milena")
|
||||
|
||||
# Gaida (Ticket ba63325a): Geschwisterverpaarung Zhuāngzǐ × Zaibunissa (beide *2020-02-21).
|
||||
_check_parents("Gaida", "Gaida von den Kleinen Chaoten", "Zhuāngzǐ", "Zaibunissa")
|
||||
|
||||
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): nur Vorfahren → isResident False.
|
||||
for _tag, _ref in [("Bentley", "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003"),
|
||||
("Alexandria", "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004"),
|
||||
("Bugatti", "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003")]:
|
||||
_g = next((g for g in _d["gerbils"] if g.get("ExternalRef") == _ref), None)
|
||||
check(f"{_tag}: isResident override == False", _g is not None and _g.get("IsResident") is False)
|
||||
|
||||
# Cherry Berry's Quqquluuruu: gender override female (box colour misread).
|
||||
_cherry = _find("Cherry Berry")
|
||||
check("Cherry Berry: gender override female",
|
||||
@@ -534,6 +602,59 @@ if _os.path.exists(_resolved):
|
||||
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
|
||||
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
|
||||
len(_bucks) == 1)
|
||||
|
||||
# ── genetics-farbschlag cluster: the STORED colorVarietyId is now genotype-
|
||||
# correct for the ticket animals. Build the id→name map from the authoritative
|
||||
# ApplicationContext.cs catalog (same source the pipeline uses for the ids).
|
||||
import re as _re
|
||||
_app = _os.path.abspath(_os.path.join(_os.path.dirname(__file__),
|
||||
"../../GerbilManagerWebAPI/ApplicationContext.cs"))
|
||||
_idname = {}
|
||||
if _os.path.exists(_app):
|
||||
_cm = _re.search(r"catalog\s*=\s*\{(.*?)\};", open(_app, encoding="utf-8").read(), _re.DOTALL)
|
||||
if _cm:
|
||||
for _i, (_n, _g, _so) in enumerate(_re.findall(
|
||||
r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', _cm.group(1))):
|
||||
_idname[f"00000000-0000-0000-0000-{_i + 1:012d}"] = _n
|
||||
|
||||
def _by_ref(ref):
|
||||
return next((g for g in _d["gerbils"] if g.get("ExternalRef") == ref), None)
|
||||
|
||||
def _cv_name(g):
|
||||
return _idname.get(g.get("ColorVarietyId")) if g else None
|
||||
|
||||
if _idname:
|
||||
# Ticket 1aac054f — namenloses Weibchen *13.08.2025 -> Kohlfuchsschimmel.
|
||||
_t1 = _by_ref("stammbaum-unbekannt-13082025-2")
|
||||
check("1aac054f: nameless *13.08.2025 stored as Kohlfuchsschimmel",
|
||||
_cv_name(_t1) == "Kohlfuchsschimmel")
|
||||
# Ticket e09d6f22 — same litter, *-3: spsp (NOT Schecke) + Kohlfuchsschimmel.
|
||||
_t2 = _by_ref("stammbaum-unbekannt-13082025-3")
|
||||
check("e09d6f22: Sp-locus is spsp (no Schecke)",
|
||||
_t2 is not None and "Spsp" not in (_t2.get("Genotype") or "")
|
||||
and "spsp" in (_t2.get("Genotype") or ""))
|
||||
check("e09d6f22: stored as Kohlfuchsschimmel", _cv_name(_t2) == "Kohlfuchsschimmel")
|
||||
# Ticket 06217eb3 — Dilute Anthrazit (dd).
|
||||
_t3 = _by_ref("stammbaum-unbekannt-27052025")
|
||||
check("06217eb3: nameless dd-Weibchen stored as Dilute Anthrazit",
|
||||
_cv_name(_t3) == "Dilute Anthrazit")
|
||||
# Ticket e22764aa — Blaufuchs (NOT Blaufuchsschimmel).
|
||||
_t4 = _by_ref("stammbaum-unbekannt-16012026")
|
||||
check("e22764aa: '(schimmel)' animal stored as Blaufuchs",
|
||||
_cv_name(_t4) == "Blaufuchs")
|
||||
# Ticket 3f5942a2 — named fox animals are Goldfuchs (ee), not Gold (EE).
|
||||
_banjo = _find("Banjo of Fiomi")
|
||||
check("3f5942a2: Banjo of Fiomi stored as Goldfuchs",
|
||||
_cv_name(_banjo) == "Goldfuchs")
|
||||
|
||||
# ── Mamta Mini (cc9ea3fe / 1a508c04): Ee[-] resolved to Ee + parents linked. ──
|
||||
_mamta = _find("Mamta Mini")
|
||||
check("Mamta Mini: E-locus resolved to Ee (no unknown [-])",
|
||||
_mamta is not None and "Ee[-]" not in (_mamta.get("Genotype") or "")
|
||||
and "Ee" in (_mamta.get("Genotype") or ""))
|
||||
_mf, _mm = _parents(_mamta)
|
||||
check("Mamta Mini: father Geely, mother Gaida linked at the litter",
|
||||
(_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida"))
|
||||
else:
|
||||
print("note: output/resolved_import.json not present — skipped integration assertions")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user