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.
|
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
`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,
|
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
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
|
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
|
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||||
durable Erkenntnisse ablegen.
|
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
|
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||||
|
|
||||||
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
**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("Open", reopened.GetProperty("status").GetString());
|
||||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
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}");
|
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
|
||||||
var delAgain = await client.DeleteAsync($"/feedback/{id}");
|
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
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?" });
|
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
|
||||||
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
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]
|
[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);
|
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 ────
|
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Request> Requests => Set<Request>();
|
public DbSet<Request> Requests => Set<Request>();
|
||||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
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<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
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),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
("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) ---
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
("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),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
("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),
|
("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),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
("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 / Siam / CP- series (IDs 51-66) ---
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string Text,
|
string Text,
|
||||||
DateTimeOffset? At);
|
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>
|
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||||
public record FeedbackDto(
|
public record FeedbackDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -43,7 +56,17 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||||
string? AgentContext,
|
string? AgentContext,
|
||||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
/// <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>
|
/// <summary>
|
||||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||||
@@ -56,5 +79,15 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? Question,
|
string? Question,
|
||||||
string? Answer,
|
string? Answer,
|
||||||
string? FixNote,
|
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>
|
/// </summary>
|
||||||
public static class FeedbackEndpoints
|
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)
|
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||||
@@ -51,21 +57,50 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
group.MapGet("/", async (ApplicationContext db) =>
|
group.MapGet("/", async (ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
// 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
|
return TypedResults.Ok(rows
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
.Select(ToDto)
|
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||||
.ToList());
|
.ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
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);
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
return TypedResults.NotFound();
|
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 (input.Message is not null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input.Message))
|
if (string.IsNullOrWhiteSpace(input.Message))
|
||||||
@@ -133,6 +168,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
entity.ResolvedAt = resolved
|
entity.ResolvedAt = resolved
|
||||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||||
: null;
|
: 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).
|
// 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;
|
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();
|
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));
|
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>> (
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
Guid id, ApplicationContext db) =>
|
Guid id, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
@@ -162,7 +240,88 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (entity is null)
|
if (entity is null)
|
||||||
return TypedResults.NotFound();
|
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();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
@@ -172,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
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>
|
/// <summary>
|
||||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
/// 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
|
/// 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,
|
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.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
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="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
|
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -35,6 +36,12 @@
|
|||||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||||
</ItemGroup>
|
</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>
|
<ItemGroup>
|
||||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
<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;
|
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();
|
var result = new DedupResult();
|
||||||
|
decisions ??= Rpro3Decisions.Empty;
|
||||||
|
var splitGroup = decisions.BuildSplitGroups();
|
||||||
|
|
||||||
foreach (var a in animals)
|
foreach (var a in animals)
|
||||||
{
|
{
|
||||||
@@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
for (int j = i + 1; j < group.Count; j++)
|
for (int j = i + 1; j < group.Count; j++)
|
||||||
{
|
{
|
||||||
var a = group[i]; var b = group[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);
|
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)
|
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
|
||||||
Union(a.Rid, b.Rid);
|
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
|
// Cluster sammeln
|
||||||
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
||||||
foreach (var a in animals)
|
foreach (var a in animals)
|
||||||
|
|||||||
@@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
{
|
{
|
||||||
private readonly ApplicationContext _db;
|
private readonly ApplicationContext _db;
|
||||||
private readonly string _photoRoot;
|
private readonly string _photoRoot;
|
||||||
|
private readonly Rpro3Decisions _decisions;
|
||||||
|
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
|
||||||
|
|
||||||
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
_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 ─────────────────────────
|
// ───────────────────────── ANALYZE ─────────────────────────
|
||||||
|
|
||||||
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||||
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
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);
|
var plan = BuildPlan(data, dedup);
|
||||||
|
|
||||||
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
||||||
@@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
||||||
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
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);
|
var plan = BuildPlan(data, dedup);
|
||||||
|
|
||||||
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
|
// 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 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);
|
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
|
var gp = new GerbilPlan
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -419,7 +442,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
Genotype = CleanGenotype(fcode),
|
Genotype = CleanGenotype(fcode),
|
||||||
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
||||||
OriginContactId = HerkContactId(herkId),
|
OriginContactId = HerkContactId(herkId),
|
||||||
IsResident = resident,
|
IsResident = residentOverride ?? resident,
|
||||||
IsCastrated = members.Any(m => m.IsCastrated),
|
IsCastrated = members.Any(m => m.IsCastrated),
|
||||||
MotherRid = rep.MidRaw,
|
MotherRid = rep.MidRaw,
|
||||||
FatherRid = rep.PidRaw,
|
FatherRid = rep.PidRaw,
|
||||||
@@ -440,6 +463,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
|||||||
}
|
}
|
||||||
|
|
||||||
gp.Notes = BuildNotes(members);
|
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);
|
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||||
plan.Gerbils[id] = gp;
|
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
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
|
||||||
Name = "Polarfuchsschimmel",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 37
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
|
||||||
Name = "Algierfuchsschimmel",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 38
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
|
||||||
Name = "Blaufuchsschimmel",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 40
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
|
||||||
Name = "Goldfuchsschimmel",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 43
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
|
||||||
Name = "Rotfuchsschimmel",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 46
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel, hell",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 48
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||||
Name = "Dilute Blaufuchs",
|
Name = "Dilute Blaufuchs",
|
||||||
SortOrder = 53
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
@@ -893,6 +893,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -906,6 +909,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("EntityName")
|
b.Property<string>("EntityName")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -915,6 +921,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid?>("GerbilId")
|
b.Property<Guid?>("GerbilId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool?>("Helpful")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<Guid?>("LitterId")
|
b.Property<Guid?>("LitterId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -925,6 +934,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("Question")
|
b.Property<string>("Question")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ReopenedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -948,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("Feedback");
|
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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1602,6 +1646,32 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("WaitingListEntries");
|
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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -55,6 +55,33 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||||
public DateTimeOffset? ResolvedAt { get; set; }
|
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>
|
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||||
public string? Question { get; set; }
|
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.RequestSyncService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||||
|
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ app.MapCmsEndpoints();
|
|||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
|
app.MapPushEndpoints();
|
||||||
app.MapAcquisitionEndpoints();
|
app.MapAcquisitionEndpoints();
|
||||||
app.MapSaleReservationEndpoints();
|
app.MapSaleReservationEndpoints();
|
||||||
app.MapWaitingListEndpoints();
|
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"
|
"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,
|
fixNote: null,
|
||||||
agentContext: null,
|
agentContext: null,
|
||||||
thread: [],
|
thread: [],
|
||||||
|
reopenedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
category: null,
|
||||||
|
helpful: null,
|
||||||
|
attachments: [],
|
||||||
}
|
}
|
||||||
db.feedback.push(created)
|
db.feedback.push(created)
|
||||||
return json(route, 201, created)
|
return json(route, 201, created)
|
||||||
}
|
}
|
||||||
if (method === 'GET') {
|
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)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
@@ -454,8 +513,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
answer?: string
|
answer?: string
|
||||||
fixNote?: string
|
fixNote?: string
|
||||||
agentContext?: string
|
agentContext?: string
|
||||||
|
category?: string
|
||||||
|
helpful?: boolean
|
||||||
}
|
}
|
||||||
const row = db.feedback[idx]
|
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 (typeof body.message === 'string') {
|
||||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||||
row.message = body.message.trim()
|
row.message = body.message.trim()
|
||||||
@@ -504,6 +568,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
? s
|
? s
|
||||||
: 'Open'
|
: 'Open'
|
||||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
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.
|
// Changelog (fixNote) — laienverständlich, sichtbar.
|
||||||
if (typeof body.fixNote === 'string') {
|
if (typeof body.fixNote === 'string') {
|
||||||
@@ -515,10 +583,17 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
const c = body.agentContext.trim()
|
const c = body.agentContext.trim()
|
||||||
row.agentContext = c.length === 0 ? null : c
|
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)
|
return json(route, 200, row)
|
||||||
}
|
}
|
||||||
if (method === 'DELETE') {
|
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, 204)
|
||||||
}
|
}
|
||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ export function seedDb(): MockDb {
|
|||||||
userAgent: null,
|
userAgent: null,
|
||||||
createdAt: '2026-06-10T09:00:00Z',
|
createdAt: '2026-06-10T09:00:00Z',
|
||||||
status: 'Resolved',
|
status: 'Resolved',
|
||||||
|
category: 'Stammbaum',
|
||||||
resolvedAt: '2026-06-12T14:00:00Z',
|
resolvedAt: '2026-06-12T14:00:00Z',
|
||||||
question: null,
|
question: null,
|
||||||
answer: null,
|
answer: null,
|
||||||
@@ -480,6 +481,29 @@ export function seedDb(): MockDb {
|
|||||||
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
|
{ 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',
|
id: 'feedback-open',
|
||||||
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
|
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 open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
|
||||||
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
|
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
|
||||||
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
|
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')
|
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
|
||||||
|
|
||||||
// „Offen": nur das offene Ticket.
|
// „Offen": nur das offene Ticket.
|
||||||
@@ -131,6 +132,20 @@ test.describe('Meine Tickets', () => {
|
|||||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
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 ({
|
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -173,7 +188,9 @@ test.describe('Meine Tickets', () => {
|
|||||||
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
|
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 page.goto('/hilfe/tickets')
|
||||||
await selectView(page, tt.filters.closed)
|
await selectView(page, tt.filters.closed)
|
||||||
|
|
||||||
@@ -185,8 +202,134 @@ test.describe('Meine Tickets', () => {
|
|||||||
acceptNextDialog(page)
|
acceptNextDialog(page)
|
||||||
await card.getByRole('button', { name: tt.delete }).click()
|
await card.getByRole('button', { name: tt.delete }).click()
|
||||||
|
|
||||||
|
// Aus „Geschlossen" verschwunden …
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||||
).toHaveCount(0)
|
).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.
|
* Basis-URL der GerbilManagerWebAPI.
|
||||||
* Dev (Vite dev server / e2e mock): absolute URL, damit Playwright-Route-Interception greift.
|
* 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.
|
* 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 =
|
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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number | null
|
readonly status: number | null
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
|
/** 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'
|
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). */
|
/** Which view a report was filed from (matches the backend Context contract). */
|
||||||
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
||||||
|
|
||||||
@@ -50,6 +58,22 @@ export interface Feedback {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
status: FeedbackStatus
|
status: FeedbackStatus
|
||||||
resolvedAt: string | null
|
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). */
|
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
|
||||||
question: string | null
|
question: string | null
|
||||||
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
||||||
@@ -85,6 +109,10 @@ export interface FeedbackUpdate {
|
|||||||
fixNote?: string
|
fixNote?: string
|
||||||
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
|
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
|
||||||
agentContext?: string
|
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> {
|
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||||
@@ -106,7 +134,50 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
|
|||||||
return updateFeedback(id, { answer })
|
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> {
|
export function deleteFeedback(id: string): Promise<void> {
|
||||||
return api.delete(`${RESOURCE}/${id}`)
|
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 { de } from '../strings/de'
|
||||||
import GerbilIcon from './GerbilIcon'
|
import GerbilIcon from './GerbilIcon'
|
||||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||||
|
import { useScrollRestoration } from '../hooks/useScrollRestoration'
|
||||||
import './appShell.css'
|
import './appShell.css'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|||||||
*/
|
*/
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<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
|
* dialog captures the current URL and a client timestamp itself and POSTs everything
|
||||||
* to /feedback. Success/error is surfaced via the existing toast system.
|
* 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 { de } from '../strings/de'
|
||||||
import { ApiError } from '../api/client'
|
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 { useToast } from './toast'
|
||||||
import './reportErrorDialog.css'
|
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 {
|
export interface ReportErrorContext {
|
||||||
context: FeedbackContext
|
context: FeedbackContext
|
||||||
gerbilId?: string | null
|
gerbilId?: string | null
|
||||||
@@ -58,8 +71,49 @@ function ReportErrorDialogBody({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
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.
|
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +150,7 @@ function ReportErrorDialogBody({
|
|||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await submitFeedback({
|
const created = await submitFeedback({
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
context: context.context,
|
context: context.context,
|
||||||
gerbilId: context.gerbilId ?? null,
|
gerbilId: context.gerbilId ?? null,
|
||||||
@@ -106,6 +160,14 @@ function ReportErrorDialogBody({
|
|||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
clientTimestamp: new Date().toISOString(),
|
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 {
|
try {
|
||||||
localStorage.removeItem(draftKey)
|
localStorage.removeItem(draftKey)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -161,6 +223,41 @@ function ReportErrorDialogBody({
|
|||||||
rows={5}
|
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">
|
||||||
<div className="report-error__debug-title">{t.debugTitle}</div>
|
<div className="report-error__debug-title">{t.debugTitle}</div>
|
||||||
<dl className="report-error__debug-list">
|
<dl className="report-error__debug-list">
|
||||||
|
|||||||
@@ -147,3 +147,24 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1.1rem;
|
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,
|
wildType,
|
||||||
extractGenotypeFlags,
|
extractGenotypeFlags,
|
||||||
displayGenotypeSafe,
|
displayGenotypeSafe,
|
||||||
|
resolveAllelePair,
|
||||||
|
inferUnknownsFromParents,
|
||||||
} from '../genotype'
|
} from '../genotype'
|
||||||
import { combineLocus } from '../punnett'
|
import { combineLocus } from '../punnett'
|
||||||
import { LOCI, type LocusKey } from '../loci'
|
import { LOCI, type LocusKey } from '../loci'
|
||||||
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Partially-unknown parents (wildcards)', () => {
|
describe('Partially-unknown parents (wildcards)', () => {
|
||||||
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
|
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||||
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
|
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||||
// = effectively 3/4 A, 1/4 a ; mother always a.
|
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||||
// Offspring: 3/4 Aa, 1/4 aa.
|
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||||
|
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||||
const father = makeGenotype({
|
const father = makeGenotype({
|
||||||
A: ['A', '?'],
|
A: ['A', '?'],
|
||||||
C: ['C', 'C'],
|
C: ['C', 'C'],
|
||||||
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
|||||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||||
const result = breed(father, mother)
|
const result = breed(father, mother)
|
||||||
|
|
||||||
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
|
expect(result.offspring).toHaveLength(1)
|
||||||
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
|
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||||
expect(agouti.probability.text).toBe('3/4')
|
expect(result.offspring[0].probability.text).toBe('1')
|
||||||
expect(schwarz.probability.text).toBe('1/4')
|
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 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 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 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 Gold')
|
||||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
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', () => {
|
it('Farbarten (categories) never appear as computed results', () => {
|
||||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
// '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')
|
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)
|
// 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('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')
|
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
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)
|
// 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('')
|
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
|
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
* 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 {
|
import {
|
||||||
makeGenotype,
|
makeGenotype,
|
||||||
|
resolveAllelePair,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
wildType,
|
wildType,
|
||||||
WILDCARD,
|
|
||||||
type AllelePair,
|
type AllelePair,
|
||||||
type Genotype,
|
type Genotype,
|
||||||
} from './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: '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: '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 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/3g: c^chm colourpoint varieties ──
|
||||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
|
|||||||
* variety specifically.
|
* variety specifically.
|
||||||
*/
|
*/
|
||||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
|
||||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
|
||||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
// back to the wild-type reading (most-dominant colour / unmarked marker).
|
||||||
const alleles = LOCI[locus].alleles
|
const [x, y] = resolveAllelePair(locus, g[locus])
|
||||||
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))
|
|
||||||
if (locus === 'E') {
|
if (locus === 'E') {
|
||||||
if (x === y) return x // ee->'e', efef->'ef', EE->'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;
|
// 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).
|
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||||
*/
|
*/
|
||||||
function eFamily(g: Genotype): string | null {
|
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 === 'e') return 'Fuchs'
|
||||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
/** 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] {
|
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||||
const alleles = LOCI[locus].alleles
|
return resolveAllelePair(locus, g[locus])
|
||||||
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]
|
* 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. */
|
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||||
function baseColourFor(g: Genotype): string | null {
|
function baseColourFor(g: Genotype): string | null {
|
||||||
const family = eFamily(g)
|
const family = eFamily(g)
|
||||||
const base = family
|
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)
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||||
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||||
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
// 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).
|
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||||
const agouti = resolvedPair(g, 'A').includes('A')
|
const agouti = resolvedPair(g, 'A').includes('A')
|
||||||
if (!agouti) {
|
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
|
||||||
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
|
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
|
||||||
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
|
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
|
||||||
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
|
// must derive its base generically like the A- branch, so e.g.
|
||||||
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
|
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
|
||||||
// dd dilute or ee Fuchs) is named from the resolved base colour, so
|
if (!agouti && eFamily(g) === null) {
|
||||||
// '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 [g1, g2] = resolvedPair(g, 'G')
|
||||||
const grey = g1 === 'g' && g2 === 'g'
|
const grey = g1 === 'g' && g2 === 'g'
|
||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
}
|
}
|
||||||
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
|
// Colourpoint base derivation: name the colour as if C were full, then prefix
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
|
||||||
if (!base) return null
|
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
|
||||||
const DILUTE = 'Dilute '
|
|
||||||
if (base.startsWith(DILUTE)) {
|
|
||||||
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
|
||||||
}
|
|
||||||
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
|
||||||
}
|
|
||||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
// 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 {
|
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||||
const base = wildType()
|
const base = wildType()
|
||||||
const out = {} as Record<LocusKey, AllelePair>
|
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) {
|
for (const locus of LOCUS_ORDER) {
|
||||||
const token = entry.tokens[locus]
|
const token = entry.tokens[locus]
|
||||||
if (!token) {
|
if (!token) {
|
||||||
out[locus] = base[locus]
|
out[locus] = base[locus]
|
||||||
continue
|
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
|
// 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.
|
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||||
|
|||||||
@@ -236,25 +236,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 48,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,25 +236,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"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,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
"canonicalGenotype": "aa CC dd ee gg PP spsp",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"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]
|
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 {
|
function assertAllele(locus: LocusKey, allele: string): void {
|
||||||
if (allele === WILDCARD) return
|
if (allele === WILDCARD) return
|
||||||
if (!LOCI[locus].alleles.includes(allele)) {
|
if (!LOCI[locus].alleles.includes(allele)) {
|
||||||
@@ -180,27 +212,27 @@ function normalizeToken(tok: string): string | null {
|
|||||||
let t = tok
|
let t = tok
|
||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
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
|
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
|
||||||
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
|
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
|
||||||
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
|
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
|
||||||
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
|
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
|
||||||
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
|
t = t.replace(/uw\[d\]/gi, 'uw')
|
||||||
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
|
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
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\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan 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
|
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
|
||||||
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
|
// form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
|
||||||
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
|
// NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
|
||||||
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
|
// recessive fox with an unknown partner) is genetically impossible and is rejected below.
|
||||||
// fox allele with an unknown partner) is genetically impossible and is left to
|
|
||||||
// be rejected by splitToken (invalid → genotypeInvalid path).
|
|
||||||
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
||||||
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
|
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||||
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
|
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||||
// strips only the bracket part; the leading allele stays.
|
// 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])e\[-\]/g, '?')
|
||||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
t = t.replace(/(?<=[A-Za-z])c\[-\]/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]
|
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||||
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
||||||
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
|
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
|
||||||
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
|
// genetically impossible — fox is recessive, so a fox allele is only visible homozygous
|
||||||
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
|
// ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
|
||||||
// the genotypeInvalid message instead of silently mis-computing the colour.
|
// instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
|
||||||
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
|
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)
|
acc[locus] = canonicalPair(locus, a, b)
|
||||||
}
|
}
|
||||||
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
|
|||||||
export function hasUnknown(g: Genotype): boolean {
|
export function hasUnknown(g: Genotype): boolean {
|
||||||
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
|
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,
|
fromJSON,
|
||||||
hasUnknown,
|
hasUnknown,
|
||||||
displayGenotypeSafe,
|
displayGenotypeSafe,
|
||||||
|
resolveAllelePair,
|
||||||
|
inferUnknownsFromParents,
|
||||||
WILDCARD,
|
WILDCARD,
|
||||||
} from './genotype'
|
} from './genotype'
|
||||||
export type { Genotype, AllelePair } from './genotype'
|
export type {
|
||||||
|
Genotype,
|
||||||
|
AllelePair,
|
||||||
|
ParentInferenceResult,
|
||||||
|
ParentInferredLocus,
|
||||||
|
} from './genotype'
|
||||||
|
|
||||||
export { LOCI, LOCUS_ORDER } from './loci'
|
export { LOCI, LOCUS_ORDER } from './loci'
|
||||||
export type { LocusKey, LocusDef } from './loci'
|
export type { LocusKey, LocusDef } from './loci'
|
||||||
|
|||||||
@@ -10,72 +10,40 @@
|
|||||||
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
||||||
* before combining, so a parent known only by phenotype can still be paired.
|
* before combining, so a parent known only by phenotype can still be paired.
|
||||||
*/
|
*/
|
||||||
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
import { add, frac, multiply, type Fraction, ONE } from './fraction'
|
||||||
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||||
import {
|
import {
|
||||||
canonicalPair,
|
canonicalPair,
|
||||||
|
resolveAllelePair,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
WILDCARD,
|
|
||||||
type AllelePair,
|
type AllelePair,
|
||||||
type Genotype,
|
type Genotype,
|
||||||
} from './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). */
|
/** A probability distribution over outcomes of type T (keyed by a string). */
|
||||||
export interface DistEntry<T> {
|
export interface DistEntry<T> {
|
||||||
readonly value: T
|
readonly value: T
|
||||||
readonly probability: Fraction
|
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> {
|
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
|
||||||
const weights = new Map<string, Fraction>()
|
const weights = new Map<string, Fraction>()
|
||||||
const addWeight = (allele: string, w: Fraction) => {
|
const addWeight = (allele: string, w: Fraction) => {
|
||||||
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
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:
|
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return weights
|
return weights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,17 +50,41 @@ interface Resolved<T> {
|
|||||||
export function useInfiniteList<T>(
|
export function useInfiniteList<T>(
|
||||||
loader: (page: number) => Promise<Paged<T>>,
|
loader: (page: number) => Promise<Paged<T>>,
|
||||||
resetKey: string,
|
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> {
|
): InfiniteListState<T> {
|
||||||
const loaderRef = useRef(loader)
|
const loaderRef = useRef(loader)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loaderRef.current = loader
|
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 })
|
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
|
||||||
// Adjust state during render when the query signature changes — the recommended
|
// Adjust state during render when the query signature changes — the recommended
|
||||||
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
||||||
if (req.key !== resetKey) {
|
if (req.key !== resetKey) {
|
||||||
setReq({ key: resetKey, page: 1, nonce: 0 })
|
setReq({ key: resetKey, page: 1, nonce: 0 })
|
||||||
|
setRestoreTarget(0) // Filter/Sortierung geändert → kein Seiten-Restore mehr
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryId = `${req.nonce}:${req.key}`
|
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 })), [])
|
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 }
|
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}`,
|
filter: status === '' ? undefined : `status==${status}`,
|
||||||
}),
|
}),
|
||||||
`${status}`,
|
`${status}`,
|
||||||
|
'anfragen',
|
||||||
)
|
)
|
||||||
const sentinelRef = useInfiniteSentinel(requests)
|
const sentinelRef = useInfiniteSentinel(requests)
|
||||||
const { items, total, loading } = requests
|
const { items, total, loading } = requests
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function BeckenPage() {
|
|||||||
const list = useInfiniteList(
|
const list = useInfiniteList(
|
||||||
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||||
`${filter ?? ''}`,
|
`${filter ?? ''}`,
|
||||||
|
'becken',
|
||||||
)
|
)
|
||||||
const sentinelRef = useInfiniteSentinel(list)
|
const sentinelRef = useInfiniteSentinel(list)
|
||||||
const { items, total, loading } = list
|
const { items, total, loading } = list
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
|
|||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
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 type { Gender, GerbilStatus } from '../api/types'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
||||||
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
|
|||||||
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
||||||
|
|
||||||
const geno = describeGenotype(g.genotype)
|
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 lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||||
|
|
||||||
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
|
|||||||
<dl className="ak-kvlist">
|
<dl className="ak-kvlist">
|
||||||
<Kv label={t.fields.genotype}>
|
<Kv label={t.fields.genotype}>
|
||||||
<code className="ak-genotype">{geno.display}</code>
|
<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>
|
||||||
<Kv label={t.detail.resolvedPrefix}>
|
<Kv label={t.detail.resolvedPrefix}>
|
||||||
{geno.farbschlag}
|
{(() => {
|
||||||
|
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{resolvedFarbschlag}
|
||||||
{storedColorName &&
|
{storedColorName &&
|
||||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
|
||||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
storedColorName !==
|
||||||
|
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Kv>
|
</Kv>
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Binary file not shown.
@@ -64,6 +64,7 @@ export default function KontaktePage() {
|
|||||||
(page) =>
|
(page) =>
|
||||||
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||||
filter,
|
filter,
|
||||||
|
'kontakte',
|
||||||
)
|
)
|
||||||
const sentinelRef = useInfiniteSentinel(list)
|
const sentinelRef = useInfiniteSentinel(list)
|
||||||
const { items, total, hasMore, loading, loadingMore } = list
|
const { items, total, hasMore, loading, loadingMore } = list
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import {
|
|||||||
listFeedback,
|
listFeedback,
|
||||||
updateFeedback,
|
updateFeedback,
|
||||||
deleteFeedback,
|
deleteFeedback,
|
||||||
|
restoreFeedback,
|
||||||
|
uploadAttachment,
|
||||||
|
deleteAttachment,
|
||||||
|
attachmentUrl,
|
||||||
|
readFileAsUpload,
|
||||||
type FeedbackTicket,
|
type FeedbackTicket,
|
||||||
} from '../api/feedback'
|
} from '../api/feedback'
|
||||||
import { getGerbil } from '../api/gerbils'
|
import { getGerbil } from '../api/gerbils'
|
||||||
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
|
import PushToggle from '../components/PushToggle'
|
||||||
import './tickets.css'
|
import './tickets.css'
|
||||||
|
|
||||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||||
@@ -227,19 +233,24 @@ function formatDate(iso: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Gefilterte Ansichten (Filter-Chips). */
|
/** 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.
|
* - „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
|
* - „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"
|
* geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen"
|
||||||
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
|
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
|
||||||
* - „Geschlossen" (closed): Resolved.
|
* - „Geschlossen" (closed): Resolved.
|
||||||
*/
|
*/
|
||||||
function viewOf(status: FeedbackTicket['status']): TicketView {
|
function viewOf(ticket: Pick<FeedbackTicket, 'status' | 'deletedAt'>): TicketView {
|
||||||
if (status === 'Resolved') return 'closed'
|
if (ticket.deletedAt) return 'deleted'
|
||||||
if (status === 'NeedsInfo') return 'dialog'
|
if (ticket.status === 'Resolved') return 'closed'
|
||||||
|
if (ticket.status === 'NeedsInfo') return 'dialog'
|
||||||
return 'open'
|
return 'open'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,15 +260,39 @@ export default function TicketsPage() {
|
|||||||
// Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste
|
// 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
|
// 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.
|
// 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)
|
const [userPicked, setUserPicked] = useState(false)
|
||||||
|
|
||||||
async function handleToggleStatus(ticket: FeedbackTicket) {
|
async function handleToggleStatus(ticket: FeedbackTicket) {
|
||||||
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
|
|
||||||
try {
|
try {
|
||||||
await updateFeedback(ticket.id, { status: next })
|
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()
|
tickets.reload()
|
||||||
toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast)
|
toast.success(t.reopenedToast)
|
||||||
|
} else {
|
||||||
|
await updateFeedback(ticket.id, { status: 'Resolved' })
|
||||||
|
tickets.reload()
|
||||||
|
toast.success(t.resolvedToast)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
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
|
const rows = tickets.data
|
||||||
|
|
||||||
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
|
// 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).
|
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 }
|
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0, deleted: 0 }
|
||||||
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1
|
for (const ticket of rows ?? []) c[viewOf(ticket)] += 1
|
||||||
return c
|
return c
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
@@ -309,7 +397,7 @@ export default function TicketsPage() {
|
|||||||
const focusView = useMemo<TicketView | null>(() => {
|
const focusView = useMemo<TicketView | null>(() => {
|
||||||
if (!focusId || !rows) return null
|
if (!focusId || !rows) return null
|
||||||
const target = rows.find((r) => r.id === focusId)
|
const target = rows.find((r) => r.id === focusId)
|
||||||
return target ? viewOf(target.status) : null
|
return target ? viewOf(target) : null
|
||||||
}, [focusId, rows])
|
}, [focusId, rows])
|
||||||
|
|
||||||
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
|
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
|
||||||
@@ -318,9 +406,32 @@ export default function TicketsPage() {
|
|||||||
const activeView: TicketView =
|
const activeView: TicketView =
|
||||||
counts[desiredView] > 0
|
counts[desiredView] > 0
|
||||||
? desiredView
|
? 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.
|
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -331,7 +442,10 @@ export default function TicketsPage() {
|
|||||||
el.classList.add('ticket-card--focus')
|
el.classList.add('ticket-card--focus')
|
||||||
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
|
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
|
||||||
return () => clearTimeout(timer)
|
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,
|
// 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).
|
// 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,
|
dialog: t.emptyDialog,
|
||||||
open: t.emptyOpen,
|
open: t.emptyOpen,
|
||||||
closed: t.emptyClosed,
|
closed: t.emptyClosed,
|
||||||
|
deleted: t.emptyDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -429,6 +544,7 @@ export default function TicketsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
<p className="tickets-subtitle">{t.subtitle}</p>
|
<p className="tickets-subtitle">{t.subtitle}</p>
|
||||||
|
<PushToggle />
|
||||||
|
|
||||||
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
|
{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}>
|
<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
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -446,6 +562,11 @@ export default function TicketsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserPicked(true)
|
setUserPicked(true)
|
||||||
setView(v)
|
setView(v)
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('tickets-view', v)
|
||||||
|
} catch {
|
||||||
|
/* ignorieren */
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.filters[v]}
|
{t.filters[v]}
|
||||||
@@ -454,12 +575,38 @@ export default function TicketsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
<div className="tickets-search">
|
||||||
<p className="tickets-empty">{emptyText[activeView]}</p>
|
<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 }}>
|
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
|
||||||
<ul className="tickets-list">
|
<ul className="tickets-list">
|
||||||
{visible.map((ticket) => (
|
{displayed.map((ticket) => (
|
||||||
<TicketCard
|
<TicketCard
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
ticket={ticket}
|
ticket={ticket}
|
||||||
@@ -467,6 +614,10 @@ export default function TicketsPage() {
|
|||||||
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
||||||
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
||||||
onDelete={() => handleDelete(ticket)}
|
onDelete={() => handleDelete(ticket)}
|
||||||
|
onRestore={() => handleRestore(ticket)}
|
||||||
|
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
|
||||||
|
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
|
||||||
|
onDeleteAttachment={handleDeleteAttachment}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -484,6 +635,10 @@ interface TicketCardProps {
|
|||||||
onSaveMessage: (message: string) => Promise<void>
|
onSaveMessage: (message: string) => Promise<void>
|
||||||
onAnswer: (answer: string) => Promise<void>
|
onAnswer: (answer: string) => Promise<void>
|
||||||
onDelete: () => 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. */
|
/** 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 toast = useToast()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [draft, setDraft] = useState(ticket.message)
|
const [draft, setDraft] = useState(ticket.message)
|
||||||
@@ -526,7 +694,21 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|||||||
const [answering, setAnswering] = useState(false)
|
const [answering, setAnswering] = useState(false)
|
||||||
const entityLink = entityHref(ticket)
|
const entityLink = entityHref(ticket)
|
||||||
const resolved = ticket.status === 'Resolved'
|
const resolved = ticket.status === 'Resolved'
|
||||||
|
const deleted = !!ticket.deletedAt
|
||||||
const badge = statusBadge(ticket.status)
|
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)
|
const [answerEditing, setAnswerEditing] = useState(false)
|
||||||
// Erstantwort: Rückfrage offen, noch keine Antwort.
|
// Erstantwort: Rückfrage offen, noch keine Antwort.
|
||||||
const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
|
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.
|
// Info noch korrigieren, BEVOR die KI das Ticket bearbeitet.
|
||||||
const canAmend = !!ticket.answer && ticket.status === 'Answered'
|
const canAmend = !!ticket.answer && ticket.status === 'Answered'
|
||||||
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
|
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
|
||||||
const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft)
|
// Im Papierkorb (deleted) ist nichts interaktiv außer „Wiederherstellen".
|
||||||
// Bearbeiten (Beschreibung) ist gesperrt, solange eine Rückfrage offen ist (NeedsInfo):
|
const showAnswerForm = !deleted && (canAnswerNew || answerEditing || (canAmend && !!answerDraft))
|
||||||
// dann soll die Züchterin ANTWORTEN, nicht den Text ändern.
|
// Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt
|
||||||
const canEditMessage = ticket.status !== 'NeedsInfo'
|
// 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() {
|
function startAmend() {
|
||||||
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
|
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
|
||||||
@@ -593,6 +778,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|||||||
<div className="ticket-card__top">
|
<div className="ticket-card__top">
|
||||||
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
|
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
|
||||||
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
||||||
|
{ticket.category && <span className="ticket-card__category">{ticket.category}</span>}
|
||||||
{ticket.entityName &&
|
{ticket.entityName &&
|
||||||
(entityLink ? (
|
(entityLink ? (
|
||||||
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
|
<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>
|
</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">
|
<dl className="ticket-card__meta">
|
||||||
<div>
|
<div>
|
||||||
<dt>{t.createdLabel}</dt>
|
<dt>{t.createdLabel}</dt>
|
||||||
@@ -734,8 +1015,26 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|||||||
<dd>{formatDate(ticket.resolvedAt)}</dd>
|
<dd>{formatDate(ticket.resolvedAt)}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
<div className="ticket-card__actions">
|
<div className="ticket-card__actions">
|
||||||
|
{deleted ? (
|
||||||
|
<button type="button" className="btn btn--primary" onClick={onRestore}>
|
||||||
|
{t.restore}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button type="button" className="btn" onClick={onToggleStatus}>
|
<button type="button" className="btn" onClick={onToggleStatus}>
|
||||||
{resolved ? t.reopen : t.markResolved}
|
{resolved ? t.reopen : t.markResolved}
|
||||||
</button>
|
</button>
|
||||||
@@ -747,6 +1046,8 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|||||||
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
||||||
{t.delete}
|
{t.delete}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function VertraegeListPage() {
|
|||||||
const contracts = useInfiniteList(
|
const contracts = useInfiniteList(
|
||||||
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
|
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
|
||||||
'contracts',
|
'contracts',
|
||||||
|
'vertraege',
|
||||||
)
|
)
|
||||||
const sentinelRef = useInfiniteSentinel(contracts)
|
const sentinelRef = useInfiniteSentinel(contracts)
|
||||||
const { items, total: totalCount, loading } = contracts
|
const { items, total: totalCount, loading } = contracts
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default function WuerfeListPage() {
|
|||||||
const litters = useInfiniteList(
|
const litters = useInfiniteList(
|
||||||
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
||||||
`${filter}|${orderBy}`,
|
`${filter}|${orderBy}`,
|
||||||
|
'wuerfe',
|
||||||
)
|
)
|
||||||
const littersSentinelRef = useInfiniteSentinel(litters)
|
const littersSentinelRef = useInfiniteSentinel(litters)
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,20 @@
|
|||||||
color: var(--ak-warn);
|
color: var(--ak-warn);
|
||||||
font-weight: 600;
|
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 ---------- */
|
/* ---------- Character chips ---------- */
|
||||||
.ak-cgroup {
|
.ak-cgroup {
|
||||||
|
|||||||
@@ -311,6 +311,148 @@
|
|||||||
word-break: break-word;
|
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 {
|
.ticket-card__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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',
|
resolvedPrefix: 'Errechnet',
|
||||||
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
|
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
|
||||||
genotypeNotSet: 'Kein Genotyp hinterlegt.',
|
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',
|
testMating: 'Probeverpaarung',
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Liste',
|
back: 'Zurück zur Liste',
|
||||||
@@ -1197,6 +1201,12 @@ export const de = {
|
|||||||
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
|
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
|
||||||
error: 'Fehlerbericht konnte nicht gesendet werden.',
|
error: 'Fehlerbericht konnte nicht gesendet werden.',
|
||||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
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". */
|
/** Toast nach erfolgreichem "ID kopieren". */
|
||||||
idCopied: 'ID kopiert',
|
idCopied: 'ID kopiert',
|
||||||
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
||||||
@@ -1217,20 +1227,31 @@ export const de = {
|
|||||||
entityLabel: 'Datensatz',
|
entityLabel: 'Datensatz',
|
||||||
createdLabel: 'Gesendet am',
|
createdLabel: 'Gesendet am',
|
||||||
resolvedLabel: 'Gelöst am',
|
resolvedLabel: 'Gelöst am',
|
||||||
|
reopenedLabel: 'Wieder geöffnet am',
|
||||||
markResolved: 'Als gelöst markieren',
|
markResolved: 'Als gelöst markieren',
|
||||||
reopen: 'Wieder öffnen',
|
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',
|
edit: 'Bearbeiten',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Wird gespeichert …',
|
saving: 'Wird gespeichert …',
|
||||||
delete: 'Löschen',
|
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',
|
editTitle: 'Fehlerbericht bearbeiten',
|
||||||
editLabel: 'Beschreibung',
|
editLabel: 'Beschreibung',
|
||||||
saved: 'Änderung gespeichert.',
|
saved: 'Änderung gespeichert.',
|
||||||
resolvedToast: 'Ticket als gelöst markiert.',
|
resolvedToast: 'Ticket als gelöst markiert.',
|
||||||
reopenedToast: 'Ticket wieder geöffnet.',
|
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.',
|
updateError: 'Ticket konnte nicht aktualisiert werden.',
|
||||||
deleteError: 'Ticket konnte nicht gelöscht werden.',
|
deleteError: 'Ticket konnte nicht gelöscht werden.',
|
||||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||||
@@ -1253,11 +1274,39 @@ export const de = {
|
|||||||
open: 'Offen',
|
open: 'Offen',
|
||||||
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
|
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
|
||||||
closed: 'Geschlossen',
|
closed: 'Geschlossen',
|
||||||
|
/** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */
|
||||||
|
deleted: 'Gelöscht',
|
||||||
},
|
},
|
||||||
/** Leertext je gefilterter Ansicht. */
|
/** Leertext je gefilterter Ansicht. */
|
||||||
emptyDialog: 'Aktuell keine Tickets im Dialog.',
|
emptyDialog: 'Aktuell keine Tickets im Dialog.',
|
||||||
emptyOpen: 'Keine offenen Tickets.',
|
emptyOpen: 'Keine offenen Tickets.',
|
||||||
emptyClosed: 'Noch keine geschlossenen 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. */
|
/** Changelog (fixNote) auf geschlossenen Tickets. */
|
||||||
changelogLabel: 'Was wurde geändert',
|
changelogLabel: 'Was wurde geändert',
|
||||||
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_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.",
|
"_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": [
|
"resolutions": [
|
||||||
{
|
{
|
||||||
"name": "Firefly von den Kleinen Chaoten",
|
"name": "Firefly von den Kleinen Chaoten",
|
||||||
@@ -354,20 +354,97 @@
|
|||||||
{
|
{
|
||||||
"name": "Danielle von den Kleinen Chaoten",
|
"name": "Danielle von den Kleinen Chaoten",
|
||||||
"dob": "04.03.2020",
|
"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.",
|
"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",
|
"mother": "Ella",
|
||||||
"motherDob": "10.06.2019",
|
"motherDob": "10.06.2019",
|
||||||
"source": "Züchterin 2026-06-22 — Tickets f77aa4a6 / 4692fd5c (Danielle echte Mutter Ella)"
|
"source": "Züchterin 2026-06-23 — Ticket 4692fd5c (Vater Makoto)",
|
||||||
|
"father": "Makoto von den Kleinen Chaoten",
|
||||||
|
"fatherDob": "16.07.2019"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Unbekannt",
|
"name": "Unbekannt",
|
||||||
"dob": "15.02.2024",
|
"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.",
|
"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": [
|
"mergeExternalRefs": [
|
||||||
["stammbaum-unbekannt-15022024-2", "stammbaum-unbekannt-15022024"],
|
[
|
||||||
["stammbaum-unbekannt-13112023", "stammbaum-unbekannt-13112022"]
|
"stammbaum-unbekannt-15022024-2",
|
||||||
|
"stammbaum-unbekannt-15022024"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"stammbaum-unbekannt-13112023",
|
||||||
|
"stammbaum-unbekannt-13112022"
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
|
"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": [
|
"addAnimals": [
|
||||||
@@ -377,6 +454,16 @@
|
|||||||
"zucht": "Zucht der kleinen Chaoten",
|
"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.",
|
"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)"
|
"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.)"""
|
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||||
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
||||||
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no 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:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
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", ""))
|
nc, zc = canon_pair(r.get("name", ""))
|
||||||
dob = norm_dob(r.get("dob", ""))
|
dob = norm_dob(r.get("dob", ""))
|
||||||
if zc:
|
if zc:
|
||||||
@@ -1110,14 +1120,17 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
decisions_name[(nc, dob)] = r
|
decisions_name[(nc, dob)] = r
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not decisions_full and not decisions_name:
|
if not decisions_full and not decisions_name and not decisions_ref:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for a in merged:
|
for a in merged:
|
||||||
nc, zc = canon_pair(a["name"])
|
nc, zc = canon_pair(a["name"])
|
||||||
dob = norm_dob(a["dob"])
|
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:
|
if not d:
|
||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
|
|||||||
@@ -176,3 +176,261 @@ def looks_like_genotype(text):
|
|||||||
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
||||||
n += 1
|
n += 1
|
||||||
return n >= 3
|
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
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import genotype as gt
|
||||||
|
|
||||||
# Prevent encoding crashes on Windows consoles when printing unicode
|
# Prevent encoding crashes on Windows consoles when printing unicode
|
||||||
if sys.platform.startswith('win'):
|
if sys.platform.startswith('win'):
|
||||||
try:
|
try:
|
||||||
@@ -422,6 +424,15 @@ def get_dedup_name_key(name):
|
|||||||
return "".join(c for c in n if c.isalnum())
|
return "".join(c for c in n if c.isalnum())
|
||||||
|
|
||||||
def clean_color_name(c_desc):
|
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:
|
if not c_desc:
|
||||||
return "", False
|
return "", False
|
||||||
|
|
||||||
@@ -433,18 +444,15 @@ def clean_color_name(c_desc):
|
|||||||
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
|
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
|
||||||
is_schecke = True
|
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
|
# 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)?\)', '', c) # handles (-sp)
|
||||||
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
|
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'[- ]?schecke\b.*', '', c)
|
||||||
c = re.sub(r'[- ]?spotted\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'\s*\(.*?\)\s*', ' ', c)
|
||||||
c = re.sub(r'[²³*]', '', c)
|
c = re.sub(r'[²³*]', '', c)
|
||||||
c = c.strip()
|
c = c.strip()
|
||||||
@@ -476,27 +484,87 @@ def clean_color_name(c_desc):
|
|||||||
|
|
||||||
return c, is_schecke
|
return c, is_schecke
|
||||||
|
|
||||||
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
|
def _match_color_label(clean_name, variety_map):
|
||||||
if not color_val:
|
"""Map a cleaned colour label to a ColorVariety id (text-only path).
|
||||||
return None, existing_genotype
|
|
||||||
color_str = str(color_val).strip()
|
Exact name wins; otherwise pick the LONGEST/most-specific substring match
|
||||||
clean_name, is_schecke = clean_color_name(color_str)
|
(ticket 3f5942a2 — the old code broke on the FIRST substring hit, so „Goldfuchs"
|
||||||
# Match color in variety_map
|
matched the shorter „Gold" first). Among substring candidates the longest seed
|
||||||
color_variety_id = None
|
name wins, then the longest clean_name overlap; ties broken deterministically.
|
||||||
|
"""
|
||||||
|
if not clean_name:
|
||||||
|
return None
|
||||||
if clean_name in variety_map:
|
if clean_name in variety_map:
|
||||||
color_variety_id = variety_map[clean_name]
|
return variety_map[clean_name]
|
||||||
else:
|
candidates = []
|
||||||
for seed_name, seed_id in variety_map.items():
|
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:
|
if seed_name in clean_name or clean_name in seed_name:
|
||||||
color_variety_id = seed_id
|
# Specificity score: prefer the longer seed name (more specific),
|
||||||
break
|
# then the closeness of lengths so „goldfuchs" beats „gold" for the
|
||||||
# Update genotype if it's a Schecke
|
# 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
|
genotype = existing_genotype
|
||||||
if is_schecke:
|
if is_schecke:
|
||||||
if genotype:
|
if genotype:
|
||||||
if "spsp" in genotype:
|
if "Sp" not in genotype and "sp" not in genotype:
|
||||||
genotype = genotype.replace("spsp", "Spsp")
|
|
||||||
elif "Spsp" not in genotype and "Sp" not in genotype:
|
|
||||||
genotype = f"{genotype} Spsp".strip()
|
genotype = f"{genotype} Spsp".strip()
|
||||||
else:
|
else:
|
||||||
canonical = variety_genotypes.get(color_variety_id)
|
canonical = variety_genotypes.get(color_variety_id)
|
||||||
@@ -2948,6 +3016,51 @@ def main():
|
|||||||
|
|
||||||
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
|
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
|
# Apply age-based death threshold (6.0 years) to all resolved gerbils
|
||||||
dt_now = datetime.now()
|
dt_now = datetime.now()
|
||||||
for g in resolved_gerbils:
|
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"])
|
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
|
||||||
r = g.parse("uw[d]uw[d]")
|
r = g.parse("uw[d]uw[d]")
|
||||||
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
|
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)
|
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
|
||||||
check("Gg identical to Uwuw at G locus",
|
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",
|
check("looks_like_genotype sees Uw as G",
|
||||||
g.looks_like_genotype("aa Cc Uwuw") is True)
|
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:
|
if check.failed:
|
||||||
print(f"\n{check.failed} test(s) FAILED")
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -432,6 +432,61 @@ check("contracts: animal-less record has empty Animals list",
|
|||||||
_sale3 and _sale3[0]["Animals"] == [])
|
_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 ──
|
# ── 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.)
|
# (Only when the pipeline has already been run; tolerant if the file is absent.)
|
||||||
import os as _os, json as _json
|
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.
|
# Sunny von PZ Karl: father corrected Hiro → Bill von Privat.
|
||||||
_check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly 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")
|
_dan = _find("Danielle von den Kleinen")
|
||||||
_df, _dm = _parents(_dan)
|
_df, _dm = _parents(_dan)
|
||||||
check("Danielle: mother is Ella", (_dm or "") == "Ella")
|
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"):
|
if _dan and _dan.get("LitterId"):
|
||||||
_dl = _L.get(_dan["LitterId"])
|
_dl = _L.get(_dan["LitterId"])
|
||||||
_dmom = _G.get(_dl.get("MotherId")) if _dl else None
|
_dmom = _G.get(_dl.get("MotherId")) if _dl else None
|
||||||
check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)",
|
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")
|
_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 Berry's Quqquluuruu: gender override female (box colour misread).
|
||||||
_cherry = _find("Cherry Berry")
|
_cherry = _find("Cherry Berry")
|
||||||
check("Cherry Berry: gender override female",
|
check("Cherry Berry: gender override female",
|
||||||
@@ -534,6 +602,59 @@ if _os.path.exists(_resolved):
|
|||||||
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
|
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
|
||||||
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
|
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
|
||||||
len(_bucks) == 1)
|
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:
|
else:
|
||||||
print("note: output/resolved_import.json not present — skipped integration assertions")
|
print("note: output/resolved_import.json not present — skipped integration assertions")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user