Compare commits
216 Commits
feature/ge
...
aa4552ff84
| Author | SHA1 | Date | |
|---|---|---|---|
| aa4552ff84 | |||
| 18d74ff3f3 | |||
| 72ad706ca6 | |||
| a66739e84d | |||
| 00dba91a5c | |||
| c7b054e909 | |||
| 00dc724605 | |||
| 156ede120e | |||
| db2c29769e | |||
| ae7d171131 | |||
| 6a64698601 | |||
| 1469a7f3dc | |||
| 6123b8ef9e | |||
| 0b0ebd5fad | |||
| 4ec0f787dd | |||
| d380a0c2ef | |||
| 40c92c70d4 | |||
| 4e7cd21b70 | |||
| 3ad276637c | |||
| b4dc37de78 | |||
| d25d2ee152 | |||
| 55e79cda51 | |||
| 23f028123d | |||
| 4e30dc7fe3 | |||
| c15dd78bc7 | |||
| dcf0e40f9b | |||
| b9021fe577 | |||
| ce1704c3b7 | |||
| 9c6ca2118a | |||
| 8bfa2a7911 | |||
| 5edd84046d | |||
| d27a389f0e | |||
| b437d09312 | |||
| 5069c0eb1e | |||
| f1c1069821 | |||
| 3f31a2efbc | |||
| 4c0cba854a | |||
| ac06cadb23 | |||
| e1299a1a63 | |||
| 5175c3229a | |||
| 5e14124322 | |||
| aa473b764e | |||
| 2befe53df9 | |||
| cf672c0933 | |||
| a31fea17fa | |||
| 438c816820 | |||
| 9c98c37d2a | |||
| b9e5b031c4 | |||
| 1845d37476 | |||
| 2868aad369 | |||
| 03ad9e6e81 | |||
| 18efab6996 | |||
| 070a896072 | |||
| 3715e32303 | |||
| cb2ee03a14 | |||
| 41ff973f9a | |||
| d2993418b6 | |||
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 | |||
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 | |||
| 5732398e60 | |||
| 0185446bfe | |||
| 04d189bd2f | |||
| 7d8166c555 | |||
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| a42966ad8e | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| c452b69dd6 | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| c26ac5c6dd | |||
| 2c3f342905 | |||
| f20a42a94b |
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook session-start --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook stop --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook precompact --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
181
.claude/skills/ticket-triage/SKILL.md
Normal file
181
.claude/skills/ticket-triage/SKILL.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
name: ticket-triage
|
||||
description: >-
|
||||
Fehlerberichte (Tickets) der Züchterin abarbeiten: neue Tickets per Subagent
|
||||
triagieren, Rückfragen an die Tickets hängen, beantwortete Tickets wieder
|
||||
aufgreifen, Fixes gebündelt umsetzen und beim Schließen dokumentieren, was
|
||||
gefixt wurde. Nutze diesen Skill, wenn der Nutzer „Tickets abarbeiten /
|
||||
triagieren", „Fehlerberichte bearbeiten" o. Ä. verlangt.
|
||||
---
|
||||
|
||||
# Ticket-Triage & Abarbeitung
|
||||
|
||||
Tickets entstehen über das „Fehler melden"-Feature (`POST /feedback`) und werden
|
||||
unter **Hilfe → Meine Tickets** (`/hilfe/tickets`) verwaltet. Dieser Skill
|
||||
automatisiert das Triagieren, Rückfragen-Stellen, Umsetzen und Schließen.
|
||||
|
||||
## Status-Lebenszyklus (Feedback.Status)
|
||||
|
||||
| Status | Bedeutung | Wer ist dran |
|
||||
|---|---|---|
|
||||
| `Open` | Neues Ticket, noch nicht triagiert | **KI** (triagieren) |
|
||||
| `NeedsInfo` | KI hat eine **Rückfrage** angehängt | Züchterin (antworten) |
|
||||
| `Answered` | Züchterin hat geantwortet | **KI** (wieder aufgreifen, umsetzen) |
|
||||
| `Resolved` | Erledigt — mit `fixNote` (Changelog) | — |
|
||||
|
||||
**Zu bearbeiten sind `Open` (neu) und `Answered` (KI wieder dran).** `NeedsInfo`
|
||||
wartet auf die Züchterin, `Resolved` ist fertig — beide überspringen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- App läuft (Aspire): `dotnet run --project GerbilManager.AppHost`; API auf
|
||||
http://localhost:5179. Siehe `CLAUDE.md` (Architektur, Import→DB-Workflow,
|
||||
Domänenwissen: 6-Jahre-Lebensspanne, Geschwisterverpaarung, Genotyp/E-Locus,
|
||||
`isResident`).
|
||||
- Feedback überlebt den Import-Ingest (FK-freie Spalten) — Tickets bleiben
|
||||
erhalten, auch wenn die DB neu befüllt wird.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Tickets holen & einteilen
|
||||
`curl -s http://localhost:5179/feedback` (neueste zuerst). Encoding-Falle: über
|
||||
eine Datei holen (`curl -o raw.json`) und mit `python … .decode('utf-8')` lesen,
|
||||
nicht via Windows-stdin-Pipe (sonst Mojibake). Test-/Dummy-Tickets ausschließen.
|
||||
Filtere auf `status in (Open, Answered)`. Bei `Answered` zusätzlich das Feld
|
||||
`answer` (Antwort der Züchterin) mitnehmen — darauf basiert die Umsetzung.
|
||||
|
||||
### 2. Triage per Subagent (ein Agent pro Ticket)
|
||||
Nutze die **Workflow-Orchestrierung** (`pipeline`) — einen Triage-Agenten pro
|
||||
Ticket. args ASCII-sicher halten (nur id/context/gerbilId/litterId übergeben; die
|
||||
deutschen Nachrichten holt der Agent selbst aus `GET /feedback`). Defensive im
|
||||
Script: `const tickets = Array.isArray(args) ? args : JSON.parse(args)`.
|
||||
|
||||
Jeder Triage-Agent:
|
||||
1. liest `CLAUDE.md` + `docs/ticket-triage.md` (falls vorhanden),
|
||||
2. holt seinen Ticket-Text (+ `answer` bei `Answered`) aus `GET /feedback`,
|
||||
3. untersucht die echten Daten über die API (`GET /gerbils/{id}`, `/litters/{id}`,
|
||||
Eltern verfolgen; `provenance` zeigt Import-Herkunft inkl. verworfener Werte),
|
||||
4. liefert strukturiert (StructuredOutput-Schema): `ticketId, title, category,
|
||||
rootCause, recommendation, fixLayer (import-data|import-logic|backend|frontend|
|
||||
genetics-engine), affectedAreas[], overlapKey (Cluster-Slug), effort (S/M/L),
|
||||
confidence, needsUserInfo`,
|
||||
5. **terminale Aktion am Ticket selbst** (jeder Triage-Agent schließt seinen
|
||||
Vorgang ab, lässt nichts offen liegen): braucht es Infos von der Züchterin →
|
||||
**Rückfrage ordentlich stellen** (siehe 3); ist die Ursache rein eine Daten-/
|
||||
Code-Korrektur ohne Rückfrage → an den passenden Umsetzungs-Cluster (4)
|
||||
übergeben (NICHT selbst querschnittlich Dateien ändern).
|
||||
|
||||
Sammle alle Ergebnisse in **eine Datei** `docs/ticket-triage.md` (Cluster-/
|
||||
Überschneidungs-Matrix + offene Rückfragen) — token-schonend per Python aus dem
|
||||
Workflow-Ergebnis generieren, nicht 70 KB in den Kontext laden.
|
||||
|
||||
### 3. Rückfragen ordentlich stellen (durch den Triage-Agenten)
|
||||
Wenn ein Ticket Infos der Züchterin braucht, hängt der Triage-Agent die Rückfrage
|
||||
**direkt selbst** ans Ticket:
|
||||
`PUT /feedback/{id}` mit `{"question": "<Rückfrage>"}` → Status wird `NeedsInfo`.
|
||||
Anforderungen an eine gute Rückfrage: **eine konkrete Frage** (kein Sammelsurium),
|
||||
in **einfacher, freundlicher Sprache** für die Züchterin (keine Code-/Fachbegriffe),
|
||||
mit dem nötigen Kontext (welches Tier/welcher Wurf, was unklar ist), und mit einer
|
||||
klaren Handlungsaufforderung (z. B. „Wie heißt die Mutter von X?"). Die Züchterin
|
||||
beantwortet es in der App; beim nächsten Lauf erscheint es als `Answered` und wird
|
||||
wieder aufgegriffen.
|
||||
|
||||
### 4. Umsetzen — gebündelt nach Cluster (Überschneidungen vermeiden!)
|
||||
Die Triage-Matrix zeigt, welche Tickets sich Dateien teilen. Bündele nach
|
||||
**datei-disjunkten Workstreams**, damit parallele Subagenten nicht kollidieren:
|
||||
- **Genetik-Engine** → `gerbil-manager-web/src/genetics/**`, `tools/import/genotype.py`.
|
||||
- **Stammbaum-Import** (Eltern/Dedup/Gender/`isResident`) → `tools/import/extract.py`,
|
||||
`merge_and_resolve.py`, `conflict-decisions.json`.
|
||||
- **Frontend-Anzeige** (z. B. Nicht-Bestandstiere ausblenden) → `GerbilDetailPage.tsx`,
|
||||
`de.ts`.
|
||||
Gemeinsam genutzte Dateien (`de.ts`, `GerbilDetailPage.tsx`) gehören **genau einem**
|
||||
Workstream — sonst sequenziell. Datenkorrekturen, die die Züchterin nennt, als
|
||||
Override in `conflict-decisions.json` kodieren (data-driven, re-ingest-stabil).
|
||||
Subagenten committen NICHT und re-ingesten NICHT — das koordiniert der Haupt-Loop.
|
||||
|
||||
### 5. Import neu einspielen (bei Daten-/Import-Änderungen)
|
||||
`python tools/import/extract.py` → `extract_contracts.py` → `merge_and_resolve.py`,
|
||||
dann `POST /import/ingest-resolved` (wischt + lädt neu). Danach betroffene Tickets
|
||||
gegen die API verifizieren.
|
||||
|
||||
### 6. Ticket schließen — MIT Changelog (durch den bearbeitenden Subagenten)
|
||||
Der Subagent/Cluster, der den Fix umgesetzt hat, schließt **seine** Tickets selbst
|
||||
ab — aber erst **nachdem der Fix verifiziert und (bei Daten-/Import-Fixes) live
|
||||
eingespielt ist** (Schritt 5), damit „erledigt" auch stimmt:
|
||||
`PUT /feedback/{id}` mit `{"status": "Resolved", "fixNote": "<verständliche
|
||||
Erklärung, was korrigiert wurde>"}`.
|
||||
**Die `fixNote` ist ein Changelog AUSSCHLIESSLICH für die Züchterin (Laiin) —
|
||||
kein technisches Geschwafel.** Harte Regel:
|
||||
- Schreib, **was sich für SIE sichtbar geändert hat**, in Alltagssprache; 1–3 Sätze.
|
||||
- KEINE Dateinamen, Funktionen, Feldnamen, IDs, Status-Codes, „Resolver/Migration/
|
||||
Genotyp-Parser/isResident/conflict-decisions" o. Ä. Nenne Tiere/Würfe bei ihrem
|
||||
**Namen**, nicht per ID.
|
||||
- Sag ggf., was sie tun soll (z. B. „Seite neu laden").
|
||||
|
||||
Gut: „Die Mutter von **Yuki** war falsch — sie ist jetzt **Izumi** (vorher stand
|
||||
da Benjiro). Bitte den Stammbaum neu laden."
|
||||
Gut: „**Hagrid** wird nicht mehr als dein eigenes Tier geführt, sondern nur noch
|
||||
als Vorfahre — seine Würfe und der Charakter-Bereich werden nicht mehr angezeigt."
|
||||
Schlecht (NICHT so): „assign_parent_roles korrigiert, motherId via
|
||||
conflict-decisions.json gesetzt, re-ingest." / „isResident=false gesetzt."
|
||||
|
||||
### Abschluss-Regel (wichtig)
|
||||
**Kein bearbeitetes Ticket bleibt offen liegen.** Jedes `Open`/`Answered`-Ticket
|
||||
endet nach einem Lauf in genau einem Endzustand:
|
||||
- **`NeedsInfo`** — Rückfrage wurde ordentlich gestellt (Info fehlt), oder
|
||||
- **`Resolved`** — Fix umgesetzt + verifiziert, mit `fixNote`-Changelog.
|
||||
Kann ein Ticket weder gefixt noch sinnvoll erfragt werden, stelle trotzdem eine
|
||||
klärende Rückfrage (NeedsInfo) statt es offen zu lassen.
|
||||
|
||||
## Mehrrundige Rückfragen & Kontext-Kontinuität
|
||||
|
||||
Der Dialog kann mehrere Runden haben: `Open → (Rückfrage) NeedsInfo → (Antwort)
|
||||
Answered → (KI prüft) ggf. neue Rückfrage NeedsInfo → Answered → … → Resolved`.
|
||||
Jede **weitere Antwort** der Züchterin bringt das Ticket wieder auf `Answered` und
|
||||
damit erneut in die Bearbeitung (Schritt 1 filtert `Answered` immer mit ein).
|
||||
|
||||
**Kontext-Träger ist das Ticket, nicht der Subagent.** Subagenten sind
|
||||
sitzungsgebunden — antwortet die Züchterin später (neue Session), existiert der
|
||||
ursprüngliche Agent nicht mehr. Daher MUSS der gesamte Verlauf am Ticket hängen
|
||||
(Frage/Antwort je Runde) + der Triage-Eintrag in `docs/ticket-triage.md`. Beim
|
||||
Wiederaufgreifen eines `Answered`-Tickets lädt der Subagent diesen vollständigen
|
||||
Verlauf + die frühere Triage und macht **mit demselben Kontext** weiter (statt bei
|
||||
null neu zu untersuchen) — praktisch „derselbe Vorgang, fortgesetzt".
|
||||
|
||||
**Innerhalb derselben Session** kann man stattdessen denselben Subagenten direkt
|
||||
fortsetzen (SendMessage an seine Agent-ID, Kontext bleibt erhalten) — nur dann ist
|
||||
echte Agent-Kontinuität möglich.
|
||||
|
||||
Das Datenmodell unterstützt Mehrrunden über einen **Verlauf/Thread** am Ticket
|
||||
(mehrere Frage/Antwort-Einträge mit Autor + Zeit), nicht nur ein einzelnes
|
||||
Frage/Antwort-Paar — so geht bei weiteren Antworten nichts verloren.
|
||||
|
||||
### Internes Agenten-Kontextfeld (für die Züchterin unsichtbar)
|
||||
|
||||
Das Ticket hat ein **internes Kontextfeld** (`agentContext`, NICHT in der Züchterin-
|
||||
UI sichtbar) — das **Arbeitsgedächtnis** des Agenten. Damit ersetzt das Ticket den
|
||||
sitzungsgebundenen Subagenten-Kontext:
|
||||
- **Schreiben:** Bei jeder Bearbeitung legt der Agent dort seinen Arbeitsstand ab
|
||||
(kompaktes JSON/Markdown): Befunde, vermutete Ursache, geprüfte Hypothesen,
|
||||
betroffene Dateien/Daten, der Plan, **worauf gewartet wird** (welche Info die
|
||||
Rückfrage klären soll) und schon erledigte Teilschritte.
|
||||
- **Lesen:** Beim Wiederaufgreifen eines `Answered`-Tickets liest der Agent zuerst
|
||||
`agentContext` (+ Verlauf + Triage-Eintrag) und setzt **dort an, wo er aufgehört
|
||||
hat** — keine Neu-Untersuchung von null.
|
||||
- **Trennung:** `agentContext` ist intern/technisch (darf IDs/Dateinamen/Hypothesen
|
||||
enthalten); die **sichtbaren** Felder bleiben laienverständlich (Frage, Antwort,
|
||||
`fixNote`). `agentContext` wird in der Tickets-Seite NICHT angezeigt.
|
||||
- `PUT /feedback/{id}` mit `{"agentContext": "<arbeitsstand>"}` aktualisiert es,
|
||||
ohne den Status zu ändern.
|
||||
|
||||
## Verifikation vor „fertig"
|
||||
- Code-Fixes: `npx tsc --noEmit`, `npx vitest run`, betroffene `npx playwright test`,
|
||||
`dotnet build`/`dotnet test`, Python-`test_*.py` — grün.
|
||||
- Daten-Fixes: in `resolved_import.json` stichprobenartig prüfen, dann re-ingest +
|
||||
API-Check des konkreten Tiers/Wurfs.
|
||||
- Jedes erledigte Ticket auf `Resolved` mit `fixNote` gesetzt.
|
||||
|
||||
## Hinweise
|
||||
- Pro Ticket genau ein Triage-Agent; Umsetzung gebündelt nach Cluster.
|
||||
- Encoding: deutsche Texte als UTF-8 (Datei, nicht stdin-Pipe).
|
||||
- Nichts committen, bis der Haupt-Loop verifiziert hat; auf `main` arbeiten.
|
||||
@@ -9,10 +9,10 @@
|
||||
# 2. npm test + npm run build (Frontend)
|
||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||
#
|
||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
||||
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||
# Images:
|
||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: truenas:13000
|
||||
REGISTRY: git.rismer.de
|
||||
REGISTRY_OWNER: gulum
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
NODE_VERSION: "22"
|
||||
@@ -48,6 +48,26 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: dotnet-ef Tool installieren
|
||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||
run: |
|
||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||
run: >
|
||||
dotnet ef migrations has-pending-model-changes
|
||||
--project GerbilManagerWebAPI
|
||||
--startup-project GerbilManagerWebAPI
|
||||
--no-build --configuration Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -131,6 +131,18 @@ $RECYCLE.BIN/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||
GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
# Runtime: gespeicherte Vertrags-Dateien
|
||||
GerbilManagerWebAPI/contract-storage/
|
||||
|
||||
# Agent-Worktrees (lokal, nicht versionieren)
|
||||
.claude/worktrees/
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
203
CLAUDE.md
Normal file
203
CLAUDE.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# CLAUDE.md — GerbilManager
|
||||
|
||||
Leitfaden für die Arbeit an diesem Repo. Bitte vor Änderungen lesen.
|
||||
|
||||
## Was ist das?
|
||||
|
||||
Verwaltungs-App für eine **Rennmaus-Zucht** („Zucht der kleinen Chaoten"): Tiere,
|
||||
Würfe, Stammbäume/Ahnentafeln, Genetik (Farbschläge/Genotypen), Gehege, Kontakte
|
||||
(Züchter/Abnehmer), Abgabeverträge, Statistik und eine öffentliche CMS-Webseite.
|
||||
Single-User (die Züchterin), Daten größtenteils aus bestehenden Quelldateien
|
||||
importiert.
|
||||
|
||||
## Architektur / Projekte
|
||||
|
||||
| Projekt | Zweck |
|
||||
|---|---|
|
||||
| `GerbilManager.AppHost` | **.NET Aspire** Orchestrator — startet PostgreSQL (Container) + WebAPI |
|
||||
| `GerbilManagerWebAPI` | Backend: ASP.NET **Minimal API**, EF Core + Postgres, Endpoints in `Endpoints/`, Entities in `Models/`, DTOs in `Dtos/` |
|
||||
| `GerbilManager.ServiceDefaults` | Aspire-Defaults (Telemetry/Health) |
|
||||
| `GerbilManager.Tests` | Backend-Tests (xUnit) |
|
||||
| `gerbil-manager-web` | Frontend: **React + Vite + TypeScript** (React Router, react-d3-tree für den Stammbaum) |
|
||||
| `tools/import` | **Python**-Import-Pipeline (zero-dep), wandelt Quelldateien → `resolved_import.json` |
|
||||
|
||||
## App starten (IMMER über Aspire, nicht nur die WebAPI)
|
||||
|
||||
```bash
|
||||
dotnet run --project GerbilManager.AppHost
|
||||
```
|
||||
- WebAPI: http://localhost:5179 · OpenAPI: `/openapi/v1.json`
|
||||
- Aspire-Dashboard: https://localhost:17004 (Login-Token steht im AppHost-Log)
|
||||
- EF-Migrationen werden beim Start automatisch angewandt (`db.Database.Migrate()` in `Program.cs`).
|
||||
- Postgres läuft als Container (Name wechselt pro Run, z. B. `postgres-xxxx`); die
|
||||
**Daten liegen in einem persistenten Volume** → überleben Neustarts. DB-Name: `gerbilmanager`.
|
||||
|
||||
## Import → DB (3-stufig, WICHTIG)
|
||||
|
||||
Die DB wird **nicht** direkt von Python beschrieben. Nach jeder Import-Code-Änderung
|
||||
neu generieren UND neu einspielen, sonst zeigt die App den alten Stand.
|
||||
|
||||
```bash
|
||||
# 1. Stammbäume + Wurfchronik → animals.json/litters.json (+ Fotos)
|
||||
python tools/import/extract.py
|
||||
# 2. Abgabeverträge (DOCX) → contracts.json
|
||||
python tools/import/extract_contracts.py
|
||||
# 3. Dedup, Eltern-Resolver, Provenance, Vertrags-Anreicherung → resolved_import.json
|
||||
python tools/import/merge_and_resolve.py
|
||||
# 4. In die laufende DB laden (API muss laufen): WISCHT + lädt neu
|
||||
curl -X POST http://localhost:5179/import/ingest-resolved
|
||||
```
|
||||
|
||||
- **`IngestResolvedService` wischt** Gerbils/Litters/Contacts/Photos/Health/Weights/
|
||||
**SaleContracts** und lädt aus `resolved_import.json` neu. Importierte SaleContracts
|
||||
sind Teil des Payloads → werden bei jedem Ingest neu erzeugt (idempotent, IDs deterministisch).
|
||||
- **Feedback überlebt den Ingest** (wird nicht gewischt) — daher hat `Feedback` nur
|
||||
**lose, nullable Guid-Spalten ohne FK** (GerbilId/LitterId/ContactId). Dieses Muster
|
||||
für alles nutzen, was Re-Ingests überleben soll.
|
||||
- `resolved_import.json` ist **gitignored** (Runtime-Output); `review-report.md` ist getrackt.
|
||||
- Deterministische GUIDs via `generate_guid("...")` → stabile IDs über Re-Ingests.
|
||||
|
||||
### Quelldateien (lokal/Netzwerk)
|
||||
- Stammbäume: `C:\Users\gulum\dev\Sttammbäume\*.xlsx`
|
||||
- Wurfchronik: `C:\Users\gulum\dev\Wurfchronik der Kleine Chaoten Teil1.xlsx`
|
||||
- Abgabeverträge: `\\truenas\Datengrab\Rennmäuse\Verträge\*.docx` (~1,4k; kann zeitweise offline sein)
|
||||
|
||||
## Build & Test
|
||||
|
||||
**Frontend** (`cd gerbil-manager-web`):
|
||||
```bash
|
||||
npx tsc --noEmit # Typecheck (muss sauber sein)
|
||||
npx vitest run # Unit-Tests
|
||||
npx eslint <dateien> # Lint
|
||||
npx playwright test [spec]# e2e (Projekte: desktop + phone), läuft gegen Mock-API
|
||||
```
|
||||
- e2e nutzt einen **Mock-Modus** (`e2e/mock-api.ts` + `e2e/mock-data.ts`), kein echtes Backend.
|
||||
Neue API-Routen/Felder dort spiegeln. `skipUnlessMock()` am Testanfang.
|
||||
|
||||
**Backend**:
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet test # GerbilManager.Tests
|
||||
dotnet ef migrations add <Name> --project GerbilManagerWebAPI # neue Migration
|
||||
```
|
||||
- Zum Bauen muss eine laufende WebAPI ggf. gestoppt werden (Datei-Lock auf `bin`).
|
||||
|
||||
**Python** (`cd tools/import`, zero-dep, exit 0 = pass):
|
||||
```bash
|
||||
python test_extract.py test_extract_docx.py test_genotype.py test_merge_resolve.py test_extract_contracts.py
|
||||
```
|
||||
|
||||
## Konventionen (bitte einhalten)
|
||||
|
||||
- **Alle deutschen UI-Texte in `gerbil-manager-web/src/strings/de.ts`** — niemals in
|
||||
Komponenten hartkodieren.
|
||||
- **EF-Migration** für jede Schema-Änderung; wird beim Aspire-Start angewandt.
|
||||
- **Provenance/Datenherkunft** liegt als **JSON-Text-Spalte** (`Provenance`) auf
|
||||
Gerbil/Contact/Litter → JSON-Feld-Erweiterungen brauchen **keine** Migration.
|
||||
- **Lose nullable Guid-Spalten ohne FK** für Tabellen, die den Ingest-Wipe überleben.
|
||||
- Commits: nur wenn der Nutzer es verlangt; auf `main` wird gearbeitet (Remote: `http://truenas:13000/Gulum/GerbilManager.git`). Commit-Messages auf Deutsch, Format `feat(scope): …`.
|
||||
- Subagenten committen NICHT — Haupt-Loop verifiziert und committet.
|
||||
|
||||
## Domänenwissen (Rennmaus-Zucht)
|
||||
|
||||
- **Lebensspanne ~6 Jahre** → ein Elternteil kann max. ~6 Jahre älter als sein Kind
|
||||
sein und muss vorher geboren sein (`parent_age_plausible` im Import; verwirft
|
||||
unplausible Eltern-Links).
|
||||
- **Geschwisterverpaarung** (Vollgeschwister als Eltern): erkannt, wenn Vater & Mutter
|
||||
**dieselbe `litterId`** ODER **dasselbe Geburtsdatum** teilen (gleiches DOB = starkes
|
||||
Indiz). UI: Diagramm führt den Vorfahren-Ast zusammen (Verweis-Knoten); Akte zeigt
|
||||
„⚭ Geschwisterverpaarung"-Chip.
|
||||
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel.
|
||||
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||
geringste Spalten-Fehlausrichtung bestimmt (`extract._attach_photos`).
|
||||
|
||||
## Stolperfallen
|
||||
|
||||
- **Aspire-Containername wechselt** pro Run, das Daten-Volume bleibt → DB-Inhalt
|
||||
überlebt Neustarts (auch alte Schema-Stände → Migrationskonflikte möglich, s. u.).
|
||||
- **Migrations-Historie vs. committete Migration-IDs**: Wird eine Migration angewandt
|
||||
und danach mit neuem Zeitstempel neu generiert, sieht EF sie als „pending" und der
|
||||
Start crasht („column … already exists"). Schema ist dann korrekt — nur die
|
||||
`__EFMigrationsHistory`-IDs müssen an die committeten Dateinamen angeglichen werden.
|
||||
- **PowerShell + psql**: PascalCase-Identifier (`"__EFMigrationsHistory"`) über
|
||||
PowerShell→docker→psql zerschießen das Quoting. Stattdessen Bash-Heredoc:
|
||||
`docker exec -i -e PGPASSWORD=$PW <container> psql -U postgres -d gerbilmanager <<'SQL' … SQL`.
|
||||
Passwort: `docker exec <container> printenv POSTGRES_PASSWORD`.
|
||||
- **Direkte DB-Schreibzugriffe** (psql) werden vom Auto-Mode-Classifier blockiert —
|
||||
vorher beim Nutzer rückversichern.
|
||||
- Bash-Tool ist **Git-Bash (POSIX)**, das andere Tool ist **PowerShell** — je eigene Syntax.
|
||||
|
||||
## Wo liegt was (Feature-Landkarte)
|
||||
|
||||
- **Stammbaum-Viewer**: `gerbil-manager-web/src/pages/StammbaumPage.tsx`,
|
||||
`src/pedigree/*` (build.ts = Baum-Aufbau + Geschwister-Erkennung; types.ts).
|
||||
Desktop passt den ganzen Baum ein; Touch: Long-Press öffnet das Kontextmenü
|
||||
(ID kopieren / Fehler melden).
|
||||
- **Datenherkunft/Nachverfolgung**: `src/components/ProvenanceDialog.tsx`; erzeugt im
|
||||
Import via `build_entity_provenance`/`_build_*_history` (chronologische, datei-
|
||||
attribuierte `history[]`, inkl. „⚠ … verworfen — Grund; … verwendet").
|
||||
- **Fehler melden (Feedback)**: `src/components/ReportErrorDialog.tsx`,
|
||||
`src/api/feedback.ts`, Backend `Endpoints/FeedbackEndpoints.cs` + `Models/Feedback.cs`.
|
||||
- **Verträge**: `Models/SaleContract.cs`, `Endpoints/ContractEndpoints.cs`,
|
||||
`ContractGenerator.cs`, Frontend `src/pages/Vertraege*`; Import: `extract_contracts.py`
|
||||
+ `enrich_from_contracts` in `merge_and_resolve.py`.
|
||||
- **Import-Kernlogik**: `tools/import/merge_and_resolve.py` (Dedup, Eltern-Resolver mit
|
||||
Gender-/Alters-Plausibilität, Litter-/Sibling-Dedup, Provenance, Vertrags-Anreicherung).
|
||||
|
||||
## Verifikations-Checkliste vor „fertig"
|
||||
|
||||
1. `npx tsc --noEmit`, `npx vitest run`, `npx eslint` sauber.
|
||||
2. Betroffene `npx playwright test`-Specs grün.
|
||||
3. `dotnet build` + `dotnet test` grün.
|
||||
4. Bei Import-Änderungen: `python test_*.py` grün + `resolved_import.json` plausibel
|
||||
(Stichprobe), danach App neu starten + `POST /import/ingest-resolved`.
|
||||
|
||||
## Gedächtnis (MemPalace) — IMMER nutzen
|
||||
|
||||
Dieses Projekt nutzt **MemPalace** als KI-Langzeitgedächtnis (lokal, kein API-Key).
|
||||
Die Anbindung ist im Repo persistiert und lädt automatisch:
|
||||
- `.mcp.json` (committet) registriert den MCP-Server `mempalace-mcp` → **19 MCP-Tools**
|
||||
(`mempalace_search`, `mempalace_kg_query/add/invalidate/timeline`, `mempalace_add_drawer`,
|
||||
`mempalace_diary_*` …). Aktiv ab Claude-Neustart (MCP-Server einmal bestätigen).
|
||||
- `.claude/settings.json` (committet) verdrahtet die Hooks
|
||||
`mempalace hook run --hook {session-start,stop,precompact} --harness claude-code`:
|
||||
**SessionStart** lädt relevanten Kontext automatisch, **Stop/PreCompact** persistieren.
|
||||
- Voraussetzung: `mempalace` ist installiert und auf PATH (`~/.local/bin`). Nach `git clone`
|
||||
ggf. MemPalace installieren; `mempalace.yaml` (Raum-Config) ist bewusst **gitignored**
|
||||
(per-project, lokal). Der Palast selbst liegt lokal unter `~/.mempalace` (nicht im Repo).
|
||||
|
||||
**Arbeitsweise pro Session:** zu Aufgabenbeginn relevantes Wissen abrufen
|
||||
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||
durable Erkenntnisse ablegen.
|
||||
|
||||
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||
|
||||
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
||||
künftige Sessions braucht — Architektur-/Design-Entscheidungen und ihr *Warum*, Domänenregeln
|
||||
(z. B. 6-Jahre-Lebensspanne, Geschwisterverpaarung), Konventionen, Stolperfallen, bestätigte
|
||||
Nutzer-Präferenzen, wiederkehrende Workflows.
|
||||
**NICHT anlegen**: transienter Task-Status; was Code/Git/CLAUDE.md ohnehin festhalten;
|
||||
Dinge, die nur für das aktuelle Gespräch zählen.
|
||||
|
||||
**WIE** (anlegen/pflegen):
|
||||
- Fakt mit Gültigkeit → `mempalace_kg_add`. Freitext-Notiz/Erkenntnis → `mempalace_add_drawer`
|
||||
(vorher `mempalace_check_duplicate`).
|
||||
- Ändert/erledigt sich ein Fakt → `mempalace_kg_invalidate` (setzt End-Datum, **nicht löschen** —
|
||||
der Graph ist temporal). So bleibt nichts veraltet stehen.
|
||||
- Repo-Dateien werden über `mempalace mine <dir>` automatisch gefilet; `mempalace sync --apply
|
||||
--wing gerbilmanager` entfernt Drawers gelöschter/verschobener Dateien.
|
||||
|
||||
**WO** (welcher Speicher):
|
||||
- **MemPalace-Palast** (`~/.mempalace`, lokal): reichhaltiges, durchsuchbares Recall — der Default
|
||||
für Memories.
|
||||
- **CLAUDE.md** (Repo, committet): die **kanonischen** Projektfakten/Konventionen, die geteilt und
|
||||
versioniert sein müssen. Wichtige durable Erkenntnisse hier spiegeln, damit sie im Repo (nicht nur
|
||||
lokal) liegen.
|
||||
|
||||
Faustregel: durable + projektweit kanonisch → **CLAUDE.md** *und* Palast; sonstiges durable Wissen →
|
||||
Palast; veraltetes → invalidieren, nicht stehen lassen.
|
||||
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ERWERB: acquisition data per animal (purchase date/price/note).
|
||||
/// - POST creates, GET ?gerbilId= filters, PUT updates, DELETE removes.
|
||||
/// - CRITICAL: acquisition rows survive the import re-ingest wipe (loose, FK-free
|
||||
/// GerbilId/SourceContactId), exactly like Feedback.
|
||||
/// </summary>
|
||||
public class AcquisitionEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public AcquisitionEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_roundtrip_create_filter_update_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// CREATE
|
||||
var resp = await client.PostAsJsonAsync("/acquisitions", new
|
||||
{
|
||||
gerbilId,
|
||||
sourceContactId = contactId,
|
||||
date = "2025-03-14",
|
||||
price = 25.50m,
|
||||
note = "Auf der Börse gekauft.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal(25.50m, created.GetProperty("price").GetDecimal());
|
||||
Assert.Equal("2025-03-14", created.GetProperty("date").GetString());
|
||||
|
||||
// GET ?gerbilId= returns it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
a => a.GetProperty("note").GetString() == "Auf der Börse gekauft.");
|
||||
|
||||
// a different gerbilId yields nothing
|
||||
var other = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.Empty(other.EnumerateArray());
|
||||
|
||||
// UPDATE
|
||||
var put = await client.PutAsJsonAsync($"/acquisitions/{id}", new
|
||||
{
|
||||
gerbilId,
|
||||
sourceContactId = (Guid?)null,
|
||||
date = "2025-04-01",
|
||||
price = 30m,
|
||||
note = "Korrigiert.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
var afterPut = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions/{id}")).RootElement;
|
||||
Assert.Equal(30m, afterPut.GetProperty("price").GetDecimal());
|
||||
Assert.Equal("Korrigiert.", afterPut.GetProperty("note").GetString());
|
||||
Assert.True(afterPut.GetProperty("sourceContactId").ValueKind == JsonValueKind.Null);
|
||||
|
||||
// DELETE
|
||||
var del = await client.DeleteAsync($"/acquisitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var gone = await client.GetAsync($"/acquisitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquisition_survives_ingest_wipe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "acq-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("acq-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// An acquisition referencing the gerbil + contact the wipe will delete.
|
||||
var acqId = Guid.NewGuid();
|
||||
db.AcquisitionRecords.Add(new AcquisitionRecord
|
||||
{
|
||||
Id = acqId,
|
||||
GerbilId = fatherId,
|
||||
SourceContactId = contactId,
|
||||
Date = new DateOnly(2025, 3, 14),
|
||||
Price = 25.50m,
|
||||
Note = "Auf der Börse gekauft.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// The acquisition row survives even though the gerbil + contact were wiped/recreated.
|
||||
var survivor = await db.AcquisitionRecords.SingleAsync(a => a.Id == acqId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId);
|
||||
Assert.Equal(contactId, survivor.SourceContactId);
|
||||
Assert.Equal(25.50m, survivor.Price);
|
||||
Assert.Equal("Auf der Börse gekauft.", survivor.Note);
|
||||
Assert.Equal(1, await db.AcquisitionRecords.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||
public class CmsPublishTests
|
||||
{
|
||||
private static string TempRoot() =>
|
||||
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
var files = new Dictionary<string, string>
|
||||
{
|
||||
["index.html"] = "<html>start</html>",
|
||||
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||
["assets/site.css"] = "body {}",
|
||||
};
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||
Assert.Equal("<html>start</html>",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||
Assert.Equal("version-2", content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_mehrfach_ohne_Fehler()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||
}
|
||||
|
||||
Assert.Equal("v3",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(
|
||||
Path.Combine(root, "live", "index.html"),
|
||||
System.Text.Encoding.UTF8);
|
||||
Assert.Equal(german, content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
|
||||
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gehege-Reinigungszyklus (RennmausPro becken_tb): Enclosure trägt Maße (Size),
|
||||
/// Kapazität (Capacity), letzte Reinigung (LastCleanedDate) und Reinigungsintervall
|
||||
/// (CleaningCycleDays). NextCleaningDate = LastCleanedDate + CleaningCycleDays wird
|
||||
/// berechnet ausgegeben. "mark-cleaned" setzt die letzte Reinigung auf heute.
|
||||
/// </summary>
|
||||
public class EnclosureEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public EnclosureEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_persists_cleaning_fields_and_computes_next_cleaning()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Reinigungs-Becken",
|
||||
notes = "Test",
|
||||
size = "120×50 cm",
|
||||
capacity = 6,
|
||||
lastCleanedDate = "2026-06-01",
|
||||
cleaningCycleDays = 14,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("120×50 cm", dto.GetProperty("size").GetString());
|
||||
Assert.Equal(6, dto.GetProperty("capacity").GetInt32());
|
||||
Assert.Equal("2026-06-01", dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(14, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||
// 2026-06-01 + 14 Tage = 2026-06-15
|
||||
Assert.Equal("2026-06-15", dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextCleaning_is_null_without_cycle_or_lastCleaned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Nur letzte Reinigung, kein Zyklus -> keine nächste fällige Reinigung.
|
||||
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken ohne Zyklus",
|
||||
lastCleanedDate = "2026-06-01",
|
||||
});
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("nextCleaningDate").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("cleaningCycleDays").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_updates_cleaning_fields()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken-Edit",
|
||||
cleaningCycleDays = 7,
|
||||
})).Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
|
||||
var put = await client.PutAsJsonAsync($"/enclosures/{id}", new
|
||||
{
|
||||
name = "Becken-Edit",
|
||||
size = "80×40 cm",
|
||||
capacity = 4,
|
||||
lastCleanedDate = "2026-05-20",
|
||||
cleaningCycleDays = 10,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var dto = JsonDocument.Parse(await client.GetStringAsync($"/enclosures/{id}")).RootElement;
|
||||
Assert.Equal("80×40 cm", dto.GetProperty("size").GetString());
|
||||
Assert.Equal(4, dto.GetProperty("capacity").GetInt32());
|
||||
Assert.Equal("2026-05-20", dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(10, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||
Assert.Equal("2026-05-30", dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkCleaned_sets_lastCleaned_to_today()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken-Mark",
|
||||
cleaningCycleDays = 21,
|
||||
lastCleanedDate = "2020-01-01",
|
||||
})).Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
|
||||
var resp = await client.PostAsync($"/enclosures/{id}/mark-cleaned", null);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
Assert.Equal(today.ToString("yyyy-MM-dd"), dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(today.AddDays(21).ToString("yyyy-MM-dd"), dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkCleaned_unknown_id_returns_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsync($"/enclosures/{Guid.NewGuid()}/mark-cleaned", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
}
|
||||
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal file
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXHIBITION: show/exhibition results & awards per animal (/exhibitions).
|
||||
/// - POST creates a row; GET (optionally filtered by gerbilId) returns it, newest first.
|
||||
/// - PUT edits, DELETE removes; unknown ids return 404; an empty EventName is rejected (400).
|
||||
/// - CRITICAL: exhibition rows survive the import re-ingest wipe (loose, FK-free GerbilId).
|
||||
/// </summary>
|
||||
public class ExhibitionEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public ExhibitionEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_creates_and_get_filters_by_gerbilId()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/exhibitions", new
|
||||
{
|
||||
gerbilId,
|
||||
entityName = "Krümel",
|
||||
eventName = "Nationale Rennmausschau 2026",
|
||||
date = "2026-03-15T00:00:00Z",
|
||||
placement = "1. Platz",
|
||||
award = "Best in Show",
|
||||
note = "Sehr ausgeglichenes Tier.",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Equal("Nationale Rennmausschau 2026", created.GetProperty("eventName").GetString());
|
||||
Assert.Equal("1. Platz", created.GetProperty("placement").GetString());
|
||||
Assert.Equal("Best in Show", created.GetProperty("award").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// GET filtered to this gerbil returns the row.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
x => x.GetProperty("id").GetString() == id
|
||||
&& x.GetProperty("eventName").GetString() == "Nationale Rennmausschau 2026");
|
||||
|
||||
// GET filtered to a DIFFERENT gerbil does not.
|
||||
var other = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.DoesNotContain(other.EnumerateArray(), x => x.GetProperty("id").GetString() == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_lifecycle_edit_and_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var create = await client.PostAsJsonAsync("/exhibitions", new
|
||||
{
|
||||
eventName = "Lokalschau",
|
||||
entityName = "Balu",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Edit: change placement + add a date.
|
||||
var edit = await client.PutAsJsonAsync($"/exhibitions/{id}", new
|
||||
{
|
||||
placement = "2. Platz",
|
||||
date = "2025-11-01T00:00:00Z",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
|
||||
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("2. Platz", edited.GetProperty("placement").GetString());
|
||||
Assert.Equal("Lokalschau", edited.GetProperty("eventName").GetString());
|
||||
|
||||
// Delete -> 204, then 404 on subsequent edit/delete.
|
||||
var del = await client.DeleteAsync($"/exhibitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{id}", new { eventName = "x" })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_empty_event_name()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/exhibitions", new { eventName = " " });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_and_delete_unknown_id_return_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var missing = Guid.NewGuid();
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{missing}", new { eventName = "x" })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{missing}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exhibition_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "exhibition-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("exhibition-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// An exhibition result referencing the gerbil that the wipe will delete/recreate.
|
||||
var exhId = Guid.NewGuid();
|
||||
db.ExhibitionResults.Add(new ExhibitionResult
|
||||
{
|
||||
Id = exhId,
|
||||
GerbilId = fatherId,
|
||||
EntityName = "Papa",
|
||||
EventName = "Nationale Rennmausschau 2026",
|
||||
Date = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Placement = "1. Platz",
|
||||
Award = "Best in Show",
|
||||
Note = "Tolles Tier.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but the exhibition row is untouched.
|
||||
var survivor = await db.ExhibitionResults.SingleAsync(x => x.Id == exhId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Nationale Rennmausschau 2026", survivor.EventName);
|
||||
Assert.Equal("1. Platz", survivor.Placement);
|
||||
Assert.Equal("Best in Show", survivor.Award);
|
||||
Assert.Equal(1, await db.ExhibitionResults.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||
LitterId = litter.Id,
|
||||
EnclosureId = enclosure.Id,
|
||||
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
|
||||
var row = lines[1];
|
||||
Assert.Contains("Krümel", row);
|
||||
Assert.Contains("Weiblich", row);
|
||||
Assert.Contains("Aktiv", row);
|
||||
Assert.Contains("Zucht", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
407
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
407
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink.
|
||||
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
|
||||
/// - Validation: an empty message is rejected with 400.
|
||||
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
|
||||
/// </summary>
|
||||
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_persists_row_with_debug_context()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Der Stammbaum zeigt den falschen Vater.",
|
||||
context = "stammbaum",
|
||||
gerbilId,
|
||||
litterId = (Guid?)null,
|
||||
entityName = "Krümel",
|
||||
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
|
||||
clientTimestamp = "2026-06-22T12:00:00Z",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
|
||||
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
|
||||
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// A freshly created ticket starts Open with no ResolvedAt.
|
||||
Assert.Equal("Open", created.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// GET returns it (newest first)
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."
|
||||
&& f.GetProperty("status").GetString() == "Open");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Gewicht wird falsch gerundet.",
|
||||
context = "gerbil-detail",
|
||||
entityName = "Balu",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
|
||||
// List contains it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id);
|
||||
|
||||
// Edit message
|
||||
var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." });
|
||||
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
|
||||
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString());
|
||||
Assert.Equal("Open", edited.GetProperty("status").GetString());
|
||||
|
||||
// Empty message is rejected
|
||||
var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
// Mark resolved -> ResolvedAt is set
|
||||
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", resolved.GetProperty("status").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Reopen -> ResolvedAt cleared
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" });
|
||||
Assert.Equal(HttpStatusCode.OK, reopen.StatusCode);
|
||||
var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Open", reopened.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Delete -> 204, then 404 on subsequent edit/delete
|
||||
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var delAgain = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
|
||||
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create an open ticket.
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Wurf B hat ein falsches Geburtsdatum.",
|
||||
context = "litter-detail",
|
||||
entityName = "Wurf B",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet.
|
||||
var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" });
|
||||
Assert.Equal(HttpStatusCode.OK, ask.StatusCode);
|
||||
var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind);
|
||||
|
||||
// Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained.
|
||||
var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." });
|
||||
Assert.Equal(HttpStatusCode.OK, reply.StatusCode);
|
||||
var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Answered", answered.GetProperty("status").GetString());
|
||||
Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_on_resolved_ticket_keeps_it_resolved()
|
||||
{
|
||||
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();
|
||||
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" });
|
||||
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_resolve_with_fixNote_stores_breeder_changelog()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Resolve the ticket together with a breeder-friendly fix note (changelog).
|
||||
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "Resolved",
|
||||
fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
|
||||
dto.GetProperty("fixNote").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// The fix note survives a re-fetch via GET.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("id").GetString() == id
|
||||
&& f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_agentContext_round_trips_without_changing_status()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Setting only the internal agent context must NOT change the lifecycle status.
|
||||
var put = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Open", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
|
||||
dto.GetProperty("agentContext").GetString());
|
||||
|
||||
// It is returned by GET as well (for tooling) but the UI never renders it.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("id").GetString() == id
|
||||
&& f.GetProperty("agentContext").GetString()!.Contains("rootCause"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_question_pushes_prior_QandA_into_thread()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Round 1: maintainer asks, breeder answers.
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" });
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." });
|
||||
|
||||
// Round 2: a NEW question is asked while the prior Q&A exists.
|
||||
var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" });
|
||||
Assert.Equal(HttpStatusCode.OK, ask2.StatusCode);
|
||||
var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement;
|
||||
|
||||
// Current open exchange = the new question, fresh (no answer yet), status NeedsInfo.
|
||||
Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind);
|
||||
|
||||
// The prior round was archived into the thread (oldest first): maintainer Q then breeder A.
|
||||
var thread = dto.GetProperty("thread");
|
||||
Assert.Equal(JsonValueKind.Array, thread.ValueKind);
|
||||
Assert.Equal(2, thread.GetArrayLength());
|
||||
Assert.Equal("maintainer", thread[0].GetProperty("role").GetString());
|
||||
Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString());
|
||||
Assert.Equal("breeder", thread[1].GetProperty("role").GetString());
|
||||
Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString());
|
||||
|
||||
// Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds).
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." });
|
||||
var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" });
|
||||
var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength());
|
||||
Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_and_delete_unknown_id_return_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var missing = Guid.NewGuid();
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/feedback/{missing}", new { message = "x" })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_rejects_empty_message()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A feedback report referencing the gerbil + litter that the wipe will delete.
|
||||
var feedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = feedbackId,
|
||||
Message = "Bitte prüfen.",
|
||||
Context = "gerbil-detail",
|
||||
GerbilId = fatherId,
|
||||
LitterId = litterId,
|
||||
EntityName = "Papa",
|
||||
Url = "http://localhost/rennmaeuse/papa",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Answered",
|
||||
ResolvedAt = null,
|
||||
Question = "Welches Datum stimmt?",
|
||||
Answer = "Der 2. Juni.",
|
||||
AnsweredAt = DateTimeOffset.UtcNow,
|
||||
FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
|
||||
AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
|
||||
Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
|
||||
});
|
||||
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||
var contactFeedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = contactFeedbackId,
|
||||
Message = "Adresse stimmt nicht.",
|
||||
Context = "contact-detail",
|
||||
ContactId = contactId,
|
||||
EntityName = "Test Breeder",
|
||||
Url = "http://localhost/kontakte/test-breeder",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(litterId, survivor.LitterId);
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe
|
||||
// The new question/answer columns survive the wipe too.
|
||||
Assert.Equal("Welches Datum stimmt?", survivor.Question);
|
||||
Assert.Equal("Der 2. Juni.", survivor.Answer);
|
||||
Assert.NotNull(survivor.AnsweredAt);
|
||||
// The new fix-note / agent-context / thread columns survive the wipe too.
|
||||
Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
|
||||
Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
|
||||
Assert.Contains("Erste Frage?", survivor.Thread);
|
||||
|
||||
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||
Assert.Equal(contactId, contactSurvivor.ContactId);
|
||||
Assert.Equal("contact-detail", contactSurvivor.Context);
|
||||
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
|
||||
Assert.Equal(2, await db.Feedback.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
|
||||
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public GerbilProfilePhotoTests(ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private async Task<Guid> CreateGerbil(string name)
|
||||
{
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
foreach (var (fn, so) in photos)
|
||||
db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbilId,
|
||||
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
|
||||
{
|
||||
var id = await CreateGerbil("NoPhoto");
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
|
||||
{
|
||||
var id = await CreateGerbil("PhotoGerbil");
|
||||
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
|
||||
{
|
||||
var id = await CreateGerbil("ListPhoto");
|
||||
InsertPhotos(id, ("list-photo.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync("/gerbils");
|
||||
var root = JsonDocument.Parse(json).RootElement;
|
||||
var items = root.GetProperty("items").EnumerateArray().ToList();
|
||||
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
|
||||
Assert.NotEqual(default, gerbil);
|
||||
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
|
||||
}
|
||||
}
|
||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
|
||||
public class GerbilSearchTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
|
||||
JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
originBreeder,
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
|
||||
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
|
||||
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
|
||||
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
|
||||
Assert.Equal(expected, GerbilSearch.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_filter_matches_across_separators()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
|
||||
// The client normalizes the user's term the same way the column is normalized.
|
||||
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
|
||||
Assert.Contains("Clan-Kleine-Chaoten", json);
|
||||
|
||||
// a term that doesn't normalize-match must NOT return it
|
||||
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
|
||||
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
|
||||
{
|
||||
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
|
||||
|
||||
// distinct-values dropdown source
|
||||
var breeders = await _client.GetStringAsync("/gerbils/breeders");
|
||||
Assert.Contains("Zucht der kleinen Chaoten", breeders);
|
||||
|
||||
// Gridify filter on the field
|
||||
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
|
||||
Assert.Contains("Herkunftstier", filtered);
|
||||
}
|
||||
}
|
||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||
public class GerbilStatusTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||
|
||||
// 1) DateOfDeath → Deceased, always overrides user input
|
||||
[Fact]
|
||||
public void DateOfDeath_set_returns_Deceased()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// DateOfDeath wins even when also Abgegeben
|
||||
[Fact]
|
||||
public void DateOfDeath_wins_over_Abgegeben()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// 2) Abgegeben without death date → GivenAway
|
||||
[Fact]
|
||||
public void ReceiverContact_set_returns_GivenAway()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||
[Fact]
|
||||
public void OlderThan6y_without_death_returns_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
|
||||
[Fact]
|
||||
public void ExactlyMaxAge_is_not_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
|
||||
// (threshold = "older than" so >= means Deceased)
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// One day before threshold is alive
|
||||
[Fact]
|
||||
public void OneDayBeforeMaxAge_is_alive()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||
[Fact]
|
||||
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
|
||||
{
|
||||
var dob = Today.AddYears(-7);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 4) Default → Breeding
|
||||
[Fact]
|
||||
public void No_special_condition_returns_Breeding()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 4a) User sets Pet → kept
|
||||
[Fact]
|
||||
public void User_Pet_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Pet, status);
|
||||
}
|
||||
|
||||
// 4b) User sets ForSale → kept
|
||||
[Fact]
|
||||
public void User_ForSale_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.ForSale, status);
|
||||
}
|
||||
|
||||
// DateOfBirth null → age rule does not fire
|
||||
[Fact]
|
||||
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
}
|
||||
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
|
||||
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
|
||||
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
|
||||
/// </summary>
|
||||
public class ImportDocxServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ImportDocxServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
// One open connection keeps the :memory: SQLite DB alive across calls.
|
||||
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
|
||||
{
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
||||
return (db, conn);
|
||||
}
|
||||
|
||||
private void WriteLitters(object litters) =>
|
||||
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(litters));
|
||||
|
||||
private void WriteAnimals(object animals) =>
|
||||
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(animals));
|
||||
|
||||
// ── Test 1: dry-run shows correct counts without writing ─────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_counts_new_animal_without_writing()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
||||
var litter = new Litter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Testwurf",
|
||||
Date = new DateOnly(2023, 5, 1),
|
||||
};
|
||||
db.Litters.Add(litter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
|
||||
|
||||
Assert.False(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
|
||||
Assert.Equal(1, report.GoHomeFilled);
|
||||
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
||||
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 2: execute creates animal with all fields + FK constraints ───────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
var litter = new Litter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Testwurf",
|
||||
Date = new DateOnly(2023, 5, 1),
|
||||
};
|
||||
db.Litters.Add(litter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, report.ContactsCreated);
|
||||
|
||||
var gerbil = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal("Pepper", gerbil.Name);
|
||||
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
||||
Assert.Equal(Gender.female, gerbil.Gender);
|
||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
|
||||
Assert.False(gerbil.IsResident);
|
||||
Assert.Equal("docx", gerbil.ImportSource);
|
||||
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.Equal("Max Mustermann", contact.Name);
|
||||
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: idempotency — second run creates zero ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_is_idempotent_second_run_creates_zero()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
||||
owner = "", abgabeDate = "01.05.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, first.Created);
|
||||
|
||||
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_enriches_existing_animal_does_not_duplicate()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
|
||||
var existing = new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Eddie",
|
||||
DateOfBirth = new DateOnly(2022, 8, 10),
|
||||
Gender = Gender.male,
|
||||
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
|
||||
IsResident = false,
|
||||
};
|
||||
db.Gerbils.Add(existing);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
// Same name+dob → enrich, not create
|
||||
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
|
||||
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(0, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
|
||||
|
||||
// GoHomeDate was enriched
|
||||
var updated = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 5: Deceased status when deathDate set ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
||||
owner = "", abgabeDate = "",
|
||||
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
||||
});
|
||||
|
||||
await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
var gerbil = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
|
||||
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
|
||||
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_works_under_retrying_execution_strategy()
|
||||
{
|
||||
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||
// InvalidOperationException when it detects a user-initiated transaction
|
||||
// that was NOT opened through the strategy. This test wires the same check
|
||||
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||
// without a live Npgsql instance.
|
||||
//
|
||||
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||
// → OnFirstExecution: no tx yet → OK
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseSqlite(conn)
|
||||
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
|
||||
{
|
||||
// P0 regression: two animals in the same litter with the same normalized name
|
||||
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
|
||||
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
|
||||
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
|
||||
// this test an authoritative regression gate.
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||
owner = "Eva Müller", abgabeDate = "10.08.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" },
|
||||
// Identical name+wsCode+litterDob → base ExternalRef collision
|
||||
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||
owner = "Lena Braun", abgabeDate = "11.08.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" },
|
||||
});
|
||||
|
||||
// Must NOT throw unique-key violation
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(2, report.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
|
||||
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
|
||||
// Both start with the docx- prefix
|
||||
Assert.All(refs, r => Assert.StartsWith("docx-", r));
|
||||
// Must be distinct (unique index enforces this in SQLite)
|
||||
Assert.Equal(2, refs.Distinct().Count());
|
||||
// Second occurrence gets the -2 suffix
|
||||
Assert.Single(refs, r => r.EndsWith("-2"));
|
||||
|
||||
// Idempotent re-run: zero new, still 2 in DB
|
||||
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||
/// </summary>
|
||||
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||
{
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||
: IExecutionStrategyFactory
|
||||
{
|
||||
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||
{
|
||||
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
// ExternalRef is set on created litters
|
||||
var litters = await db.Litters.ToListAsync();
|
||||
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||
|
||||
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
@@ -236,6 +261,54 @@ namespace GerbilManager.Tests
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
|
||||
{
|
||||
// Regression for "Unable to save changes because a circular dependency was detected":
|
||||
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
|
||||
// Triggered when a gerbil is both parent (in one synth litter) and offspring
|
||||
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
|
||||
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
|
||||
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
|
||||
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
// must NOT throw InvalidOperationException (circular dependency)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
|
||||
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
|
||||
// both should be litter-linked
|
||||
Assert.NotNull(alpha.LitterId);
|
||||
Assert.NotNull(beta.LitterId);
|
||||
// deferred FK update must have set the litter parents correctly
|
||||
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
|
||||
Assert.Equal(beta.Id, alphaLitter.MotherId);
|
||||
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
|
||||
Assert.Equal(alpha.Id, betaLitter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||
{
|
||||
@@ -548,27 +621,32 @@ namespace GerbilManager.Tests
|
||||
[Fact]
|
||||
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||
{
|
||||
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
|
||||
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
|
||||
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
|
||||
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
|
||||
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
|
||||
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
|
||||
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
|
||||
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
|
||||
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
|
||||
|
||||
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
|
||||
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||
Assert.NotNull(cpFuchs);
|
||||
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||
|
||||
// New entries at IDs 62-66 exist with correct names
|
||||
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||
|
||||
// Total count is exactly 66
|
||||
Assert.Equal(66, db.ColorVarieties.Count());
|
||||
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
|
||||
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
|
||||
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
|
||||
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
|
||||
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
|
||||
|
||||
// Total count is exactly 70
|
||||
Assert.Equal(70, db.ColorVarieties.Count());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -580,6 +658,74 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(new DateOnly(y, m, d), date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||
{
|
||||
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||
// stable ExternalRef without throwing.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
// Run 1: load the animal normally
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
|
||||
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||
g.Name = "Umbenannt Tier";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||
{
|
||||
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
// Exact Agouti genotype, no explicit Farbschlag name
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||
Assert.NotNull(tier.ColorVarietyId);
|
||||
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||
Assert.Equal("Agouti", variety!.Name);
|
||||
// Report counter should reflect the genotype derivation
|
||||
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
private const string LittersJson = """
|
||||
[
|
||||
|
||||
261
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
261
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,261 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
public class IngestResolvedServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private Guid ContactId { get; set; }
|
||||
private Guid FatherId { get; set; }
|
||||
private Guid ContractId { get; set; }
|
||||
private Guid UnknownGerbilId { get; set; }
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
||||
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
ContactId = contactId;
|
||||
FatherId = fatherId;
|
||||
ContractId = Guid.NewGuid();
|
||||
UnknownGerbilId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = contactId,
|
||||
Name = "Test Breeder",
|
||||
ZuchtName = "Test Zucht",
|
||||
City = "Test City",
|
||||
Email = "test@example.com",
|
||||
Homepage = "",
|
||||
Phone = "",
|
||||
Address = "",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||
}
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = litterId,
|
||||
Name = "Wurf A",
|
||||
Date = "2026-01-01",
|
||||
TotalBorn = 5,
|
||||
DeathsWithin8Weeks = 0,
|
||||
FatherId = fatherId,
|
||||
MotherId = motherId,
|
||||
ExpectedGoHomeDate = (string?)null,
|
||||
Notes = "Test litter notes",
|
||||
PairingCode = "PC01",
|
||||
ExternalRef = "ext-litter-1",
|
||||
LitterLetter = "A",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||
}
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = fatherId,
|
||||
Name = "Papa",
|
||||
Gender = "male",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-papa",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "papa",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = motherId,
|
||||
Name = "Mama",
|
||||
Gender = "female",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-mama",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "mama",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
Provenance = (string?)null
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = photoId,
|
||||
GerbilId = fatherId,
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
},
|
||||
SaleContracts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = ContractId,
|
||||
ContactId = contactId,
|
||||
Price = 35.0m,
|
||||
HandoverDate = "2023-09-29",
|
||||
ContractDate = "2023-09-29",
|
||||
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
||||
// One known animal + one unknown id (must be skipped defensively).
|
||||
Animals = new[] { fatherId, UnknownGerbilId }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
var result = await service.RunAsync();
|
||||
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Verify counts
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
Assert.Equal(1, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
||||
|
||||
// Verify relations
|
||||
var litter = await db.Litters.SingleAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
||||
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
||||
var photo = await db.GerbilPhotos.SingleAsync();
|
||||
|
||||
Assert.Equal(father.Id, litter.FatherId);
|
||||
Assert.Equal(mother.Id, litter.MotherId);
|
||||
Assert.Equal(father.Id, photo.GerbilId);
|
||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||
|
||||
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
||||
Assert.NotNull(father.Provenance);
|
||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||
// The chronological, file-attributed history survives ingest verbatim
|
||||
// (stored as opaque JSON on the text column — no schema for it).
|
||||
Assert.Contains("history", father.Provenance);
|
||||
Assert.Contains("Genotyp aus", father.Provenance);
|
||||
Assert.Null(mother.Provenance);
|
||||
|
||||
// Contact + litter provenance also round-trips through the ingest.
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.NotNull(contact.Provenance);
|
||||
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
||||
|
||||
Assert.NotNull(litter.Provenance);
|
||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||
|
||||
// SaleContracts are created from the payload, and animal links to
|
||||
// unknown gerbils are skipped defensively (only the known father links).
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Equal(ContactId, contract.ContactId);
|
||||
Assert.Equal(35.0m, contract.Price);
|
||||
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
||||
var link = Assert.Single(contract.Animals);
|
||||
Assert.Equal(FatherId, link.GerbilId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
|
||||
// Re-ingest wipes then recreates from the (deterministic) payload —
|
||||
// the contract survives by being part of the payload, no duplicate PK.
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
|
||||
public class LitterMortalityTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static int? GetInt(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : null;
|
||||
|
||||
private async Task<JsonElement> CreateAndGet(object body)
|
||||
{
|
||||
var post = await _client.PostAsJsonAsync("/litters", body);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_round_trips()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Wurf Mortalitätstest",
|
||||
date = "2026-05-01",
|
||||
totalBorn = 6,
|
||||
deathsWithin8Weeks = 2,
|
||||
});
|
||||
Assert.Equal(6, GetInt(el, "totalBorn"));
|
||||
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_defaults_null()
|
||||
{
|
||||
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
|
||||
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Ungültig",
|
||||
date = "2026-05-03",
|
||||
totalBorn = 4,
|
||||
deathsWithin8Weeks = 5,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("TotalBorn", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_DeathsWithin8Weeks_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Negativ",
|
||||
date = "2026-05-04",
|
||||
deathsWithin8Weeks = -1,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("negativ", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Kein TotalBorn",
|
||||
date = "2026-05-05",
|
||||
deathsWithin8Weeks = 3,
|
||||
});
|
||||
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
}
|
||||
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Names;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("reinen JSON-Array", prompt);
|
||||
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||
Assert.Contains("name", prompt);
|
||||
Assert.Contains("meaning", prompt);
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("thematischen Kategorien", prompt);
|
||||
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
// codes are mapped to German descriptions
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Griechische Mythologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||
Assert.Contains("5", prompt);
|
||||
Assert.DoesNotContain("Buchstaben", prompt);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||
{
|
||||
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||
Assert.Equal(expectedDescription, entry.Description);
|
||||
Assert.True(entry.Thematic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||
Assert.Contains(expectedDescription, prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
Assert.Contains("Disney-Charaktere", prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||
{
|
||||
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||
var result = NameSuggestionService.ParseSuggestions(json);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Astrid", result[0].Name);
|
||||
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||
Assert.Equal("Altnordisch", result[0].Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Aiko", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Luna", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||
{
|
||||
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Sol", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||
{
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||
}
|
||||
|
||||
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 5);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||
|
||||
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal(2, result.Suggestions!.Count);
|
||||
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var handler = new StubHandler(_ => Canned(fenced));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────
|
||||
|
||||
private static NameSuggestionService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new NameSuggestionService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private static string? GetNested(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||
|
||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||
{
|
||||
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||
// when multiple tests in the same fixture share the SQLite connection.
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
notes = "Eine Notiz",
|
||||
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
[Fact]
|
||||
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange: create a fully-populated gerbil
|
||||
var id = await CreateFullGerbil("ForSale-Tier");
|
||||
|
||||
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||
JsonContent.Create(new { status = "ForSale" }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: re-read and check all fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange
|
||||
var id = await CreateFullGerbil("Charakter-Tier");
|
||||
|
||||
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
characterTraits = new[] { "handzahm" },
|
||||
characterNote = "Neue Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||
{
|
||||
// Arrange: gerbil with import-set fields
|
||||
var id = await CreateFullGerbil("EditForm-Tier");
|
||||
|
||||
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
name = "EditForm-Tier (umbenannt)",
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
notes = "Aktualisierte Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name updated, import fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||
{
|
||||
// A minimal POST (only name) must still produce a valid gerbil
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
}
|
||||
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf.
|
||||
/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ
|
||||
/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten)
|
||||
/// - unbekannter Code / unbekannter Typ => id=null
|
||||
/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet,
|
||||
/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können.
|
||||
/// </summary>
|
||||
public class RefsEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RefsEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
// Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig),
|
||||
// sowie je eine eindeutige Entität pro Typ.
|
||||
private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001");
|
||||
private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002");
|
||||
private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888");
|
||||
private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff");
|
||||
private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef");
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture)
|
||||
|
||||
db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female });
|
||||
db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male });
|
||||
db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" });
|
||||
db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) });
|
||||
db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<JsonElement>> Resolve(params object[] refs)
|
||||
{
|
||||
var resp = await _factory.CreateClient()
|
||||
.PostAsJsonAsync("/refs/resolve", new { refs });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
return doc.RootElement.EnumerateArray().ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_full_guid_per_type()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = TierA.ToString() },
|
||||
new { type = "kontakt", code = Kontakt.ToString() },
|
||||
new { type = "wurf", code = Wurf.ToString() },
|
||||
new { type = "gehege", code = Gehege.ToString() });
|
||||
|
||||
Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Krümel", rows[0].GetProperty("name").GetString());
|
||||
Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString());
|
||||
Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString());
|
||||
Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString());
|
||||
Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_short_prefix_when_unique()
|
||||
{
|
||||
await SeedAsync();
|
||||
// 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig.
|
||||
var rows = await Resolve(new { type = "kontakt", code = "11112222" });
|
||||
Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tolerates_dashes_and_case_in_code()
|
||||
{
|
||||
await SeedAsync();
|
||||
// Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges.
|
||||
var rows = await Resolve(new { type = "gehege", code = "1234-5678" });
|
||||
Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ambiguous_prefix_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null.
|
||||
var rows = await Resolve(new { type = "tier", code = "abcdef" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind);
|
||||
Assert.Equal("tier", rows[0].GetProperty("type").GetString());
|
||||
Assert.Equal("abcdef", rows[0].GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_code_and_unknown_type_resolve_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = "fffffffe" }, // unbekannter Code
|
||||
new { type = "schubladen", code = "11112222" }); // unbekannter Typ
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Too_short_code_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre).
|
||||
var rows = await Resolve(new { type = "kontakt", code = "1111" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
}
|
||||
}
|
||||
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// - Voller CRUD-Round-Trip (POST/GET[?gerbilId=]/PUT/DELETE).
|
||||
/// - Validierung: ohne Tier (weder GerbilId noch GerbilName) → 400.
|
||||
/// - CRITICAL: Rücknahme-Zeilen überleben den Import-Re-Ingest-Wipe
|
||||
/// (lose, FK-freie GerbilId/FromContactId — wie Feedback).
|
||||
/// </summary>
|
||||
public class ReturnRecordEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public ReturnRecordEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_round_trip_create_list_update_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// POST
|
||||
var resp = await client.PostAsJsonAsync("/returns", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-01T00:00:00Z",
|
||||
returnPrice = 0m,
|
||||
originalPrice = 15m,
|
||||
originalSaleDate = "2025-09-01T00:00:00Z",
|
||||
fromContactId = contactId,
|
||||
fromContactName = "Familie Meier",
|
||||
note = "Allergie in der Familie",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal("Krümel", created.GetProperty("gerbilName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal("Familie Meier", created.GetProperty("fromContactName").GetString());
|
||||
|
||||
// GET (unfiltered) returns it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// GET ?gerbilId= filters
|
||||
var filtered = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.All(filtered.EnumerateArray(),
|
||||
r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
|
||||
Assert.Contains(filtered.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
var other = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.Empty(other.EnumerateArray());
|
||||
|
||||
// PUT updates
|
||||
var putResp = await client.PutAsJsonAsync($"/returns/{id}", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-02T00:00:00Z",
|
||||
returnPrice = 5m,
|
||||
note = "Korrigierter Grund",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, putResp.StatusCode);
|
||||
var updated = JsonDocument.Parse(await putResp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Korrigierter Grund", updated.GetProperty("note").GetString());
|
||||
Assert.Equal(5m, updated.GetProperty("returnPrice").GetDecimal());
|
||||
|
||||
// DELETE removes
|
||||
var delResp = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.DoesNotContain(afterDelete.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// DELETE again → 404
|
||||
var delAgain = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_record_without_animal()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/returns", new { note = "irgendwas" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_unknown_id_returns_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PutAsJsonAsync($"/returns/{Guid.NewGuid()}", new { gerbilName = "X" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnRecord_survives_ingest_wipe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "returns-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("returns-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A return record referencing the gerbil + contact that the wipe will delete.
|
||||
var recordId = Guid.NewGuid();
|
||||
db.ReturnRecords.Add(new ReturnRecord
|
||||
{
|
||||
Id = recordId,
|
||||
GerbilId = fatherId,
|
||||
GerbilName = "Papa",
|
||||
ReturnDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ReturnPrice = 0m,
|
||||
FromContactId = contactId,
|
||||
FromContactName = "Test Breeder",
|
||||
Note = "kam zurück",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// The gerbils/contacts were wiped & recreated, but the return record is untouched.
|
||||
var survivor = await db.ReturnRecords.SingleAsync(r => r.Id == recordId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(contactId, survivor.FromContactId);
|
||||
Assert.Equal("Papa", survivor.GerbilName);
|
||||
Assert.Equal("Test Breeder", survivor.FromContactName);
|
||||
Assert.Equal(1, await db.ReturnRecords.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
337
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
337
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using GerbilManagerWebAPI.Import.Rpro3;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests für den RennmausPro-III-Importer (Reader + Dedup + Execute).
|
||||
/// Die meisten Tests bauen eine hermetische Mini-SQLite im RPRO3-Schema; ein optionaler
|
||||
/// Test gegen die echte entpackte DB läuft nur, wenn sie lokal vorhanden ist.
|
||||
/// </summary>
|
||||
public class Rpro3ImportTests
|
||||
{
|
||||
static Rpro3ImportTests()
|
||||
{
|
||||
// CP1252 für die Tests verfügbar machen (die Produktion registriert es im Reader-Ctor).
|
||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
// ── Unit: Decode / JDN / Genotyp ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void JdnToDate_converts_known_julian_day_numbers()
|
||||
{
|
||||
// Blacky _BIRTH = 2455036.0 → 2009-07-23 (siehe rpro3-vergleich.md).
|
||||
Assert.Equal(new DateOnly(2009, 7, 23), Rpro3Reader.JdnToDate(2455036.0));
|
||||
Assert.Null(Rpro3Reader.JdnToDate(0));
|
||||
Assert.Null(Rpro3Reader.JdnToDate(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmartDecode_prefers_utf8_then_cp1252()
|
||||
{
|
||||
var utf8 = System.Text.Encoding.UTF8.GetBytes("männlich");
|
||||
Assert.Equal("männlich", Rpro3Reader.SmartDecode(utf8));
|
||||
|
||||
var cp1252 = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
|
||||
// CP1252-Bytes sind kein gültiges UTF-8 → Fallback dekodiert korrekt.
|
||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||
Assert.Equal("männlich", Rpro3Reader.SmartDecode(cp1252));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanGenotype_strips_brackets_keeps_dash()
|
||||
{
|
||||
Assert.Equal("aa Ccchm DD EE GG Pp spsp", Rpro3Reader_CleanGenotype("aa Cc[chm] DD EE GG Pp spsp"));
|
||||
Assert.Equal("Kim AA C- Dd", Rpro3Reader_CleanGenotype("Kim AA C- Dd"));
|
||||
}
|
||||
|
||||
private static string? Rpro3Reader_CleanGenotype(string s)
|
||||
{
|
||||
// CleanGenotype ist internal in Rpro3ImportService → über InternalsVisibleTo erreichbar.
|
||||
var m = typeof(Rpro3ImportService).GetMethod("CleanGenotype",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (string?)m!.Invoke(null, new object?[] { s });
|
||||
}
|
||||
|
||||
// ── Unit: Dedup ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Dedup_merges_compatible_same_name_records()
|
||||
{
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Alice", new DateOnly(2010, 7, 19), "Schimmel", "Clan A"),
|
||||
Ext("u2", "Alice", new DateOnly(2010, 7, 19), "Schimmel", ""), // kompatibel → merge
|
||||
Ext("u3", "Alice", null, "Schimmel", "Clan A"), // kompatibel → merge
|
||||
Ext("u4", "Alice", new DateOnly(2001, 1, 1), "Schwarz", "Clan B"), // Konflikt → eigener Cluster
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals);
|
||||
// Mind. ein Merge-Cluster (u1/u2/u3), u4 separat → Name "Alice" mehrdeutig.
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(3, r.MergeClusters.Values.First().Count);
|
||||
Assert.Contains(r.Ambiguous, a => a.Name == "Alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_does_not_merge_placeholder_names()
|
||||
{
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "unbekannt", null, "", ""),
|
||||
Ext("u2", "unbekannt", null, "", ""),
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.Equal(2, r.Placeholders.Count);
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_imports_animals_litters_contacts_and_is_idempotent()
|
||||
{
|
||||
var dbPath = BuildMiniRpro3();
|
||||
try
|
||||
{
|
||||
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
|
||||
Assert.Equal(2, data.Animals.Count(a => a.Src == Rpro3Src.Stamm));
|
||||
Assert.True(data.Animals.Count(a => a.Src == Rpro3Src.Fremd) >= 2);
|
||||
|
||||
using var efConn = new SqliteConnection("DataSource=:memory:");
|
||||
efConn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
|
||||
using var ctx = new ApplicationContext(opts);
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var service = new Rpro3ImportService(ctx, config, null);
|
||||
|
||||
var analyze = await service.AnalyzeAsync(data, photosProvided: false, availablePhotoFiles: null);
|
||||
Assert.Equal(2, analyze.Counts.OwnAnimals);
|
||||
Assert.True(analyze.NewVsCurrentNew >= 2);
|
||||
|
||||
var first = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.True(first.GerbilsImported >= 4); // 2 stamm + ≥2 fremd
|
||||
Assert.Equal(1, first.LittersImported >= 1 ? 1 : 0); // mind. 1 Wurf importiert
|
||||
var gerbilCount1 = await ctx.Gerbils.CountAsync();
|
||||
var litterCount1 = await ctx.Litters.CountAsync();
|
||||
|
||||
// Re-Run: idempotent — gleiche Zeilenzahl, keine Dubletten.
|
||||
var second = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(gerbilCount1, await ctx.Gerbils.CountAsync());
|
||||
Assert.Equal(litterCount1, await ctx.Litters.CountAsync());
|
||||
|
||||
// Pedigree: Wurf hat Mutter + Vater verknüpft (resolved gegen importierte Tiere).
|
||||
var litter = await ctx.Litters.FirstAsync(l => l.Name!.StartsWith("Wurf"));
|
||||
Assert.NotNull(litter.MotherId);
|
||||
Assert.NotNull(litter.FatherId);
|
||||
|
||||
// Genotyp wurde aus _FCODE übernommen + Klammern entfernt.
|
||||
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
|
||||
Assert.Equal("aa Ccchm DD EE GG Pp spsp", blacky.Genotype);
|
||||
Assert.True(blacky.IsResident);
|
||||
|
||||
// Encoding: "männlich" korrekt dekodiert (Latin-1-Quelle).
|
||||
Assert.Equal(Gender.male, blacky.Gender);
|
||||
}
|
||||
finally { try { File.Delete(dbPath); } catch { } }
|
||||
}
|
||||
|
||||
// ── Integration: Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung) ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_imports_feature_tables_and_is_idempotent()
|
||||
{
|
||||
var dbPath = BuildMiniRpro3();
|
||||
try
|
||||
{
|
||||
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
|
||||
// Reader hat alle Feature-Tabellen erfasst.
|
||||
Assert.Equal(2, data.Becken.Count);
|
||||
Assert.Single(data.Acquisitions);
|
||||
Assert.Single(data.Abgeben);
|
||||
Assert.Single(data.Abstat);
|
||||
Assert.Single(data.Getback);
|
||||
Assert.Single(data.Nachfrage);
|
||||
Assert.Single(data.Ausstellungen);
|
||||
Assert.Equal(1, data.StammBecken["1"]); // Blacky → Becken 1
|
||||
|
||||
using var efConn = new SqliteConnection("DataSource=:memory:");
|
||||
efConn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
|
||||
using var ctx = new ApplicationContext(opts);
|
||||
ctx.Database.EnsureCreated();
|
||||
var service = new Rpro3ImportService(ctx, new ConfigurationBuilder().Build(), null);
|
||||
|
||||
var res = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(2, res.EnclosuresImported);
|
||||
Assert.Equal(1, res.AcquisitionsImported);
|
||||
Assert.Equal(2, res.ReservationsImported); // abgeben(Blacky, reserviert) + abstat(Kuke, abgegeben)
|
||||
Assert.Equal(1, res.ReturnsImported);
|
||||
Assert.Equal(1, res.WaitingListImported);
|
||||
Assert.Equal(1, res.ExhibitionsImported);
|
||||
|
||||
// Becken → Gehege; Blacky ist Becken 1 zugeordnet.
|
||||
Assert.Equal(2, await ctx.Enclosures.CountAsync());
|
||||
var kaefig = await ctx.Enclosures.FirstAsync(e => e.Name == "Käfig A");
|
||||
Assert.Equal("80x35x40", kaefig.Size);
|
||||
Assert.Equal(3, kaefig.Capacity);
|
||||
Assert.Equal(60, kaefig.CleaningCycleDays);
|
||||
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
|
||||
Assert.Equal(kaefig.Id, blacky.EnclosureId);
|
||||
|
||||
// Erwerb verknüpft auf Blacky mit Preis/Datum.
|
||||
var acq = await ctx.AcquisitionRecords.SingleAsync();
|
||||
Assert.Equal(blacky.Id, acq.GerbilId);
|
||||
Assert.Equal(9.5m, acq.Price);
|
||||
Assert.NotNull(acq.Date);
|
||||
|
||||
// Reservierung: Blacky reserviert für Lisa M.
|
||||
var resv = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == blacky.Id);
|
||||
Assert.Equal("reserviert", resv.Status);
|
||||
Assert.Equal("Lisa M.", resv.ContactName);
|
||||
|
||||
// Abgabe-Abschluss: Kuke → abgegeben.
|
||||
var kuke = await ctx.Gerbils.FirstAsync(g => g.Name == "Kuke");
|
||||
var kukeRes = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == kuke.Id);
|
||||
Assert.Equal("abgegeben", kukeRes.Status);
|
||||
Assert.Equal(15.0m, kukeRes.Price);
|
||||
|
||||
// Rücknahme: Blacky kam zurück.
|
||||
var ret = await ctx.ReturnRecords.SingleAsync();
|
||||
Assert.Equal(blacky.Id, ret.GerbilId);
|
||||
Assert.Equal(15.0m, ret.OriginalPrice);
|
||||
Assert.Equal("Lisa M.", ret.FromContactName);
|
||||
|
||||
// Warteliste: männlicher Schimmel.
|
||||
var wl = await ctx.WaitingListEntries.SingleAsync();
|
||||
Assert.Equal("male", wl.WishGender);
|
||||
Assert.Equal("Schimmel", wl.WishColor);
|
||||
Assert.Equal("offen", wl.Status);
|
||||
|
||||
// Ausstellung: Blacky, 1. Platz, BOB.
|
||||
var exh = await ctx.ExhibitionResults.SingleAsync();
|
||||
Assert.Equal(blacky.Id, exh.GerbilId);
|
||||
Assert.Equal("Schau 2011", exh.EventName);
|
||||
Assert.Equal("1. Platz", exh.Placement);
|
||||
Assert.Equal("BOB", exh.Award);
|
||||
|
||||
// Re-Run: idempotent — keine Dubletten in den Feature-Tabellen.
|
||||
await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(2, await ctx.Enclosures.CountAsync());
|
||||
Assert.Equal(1, await ctx.AcquisitionRecords.CountAsync());
|
||||
Assert.Equal(2, await ctx.SaleReservations.CountAsync()); // Blacky + Kuke
|
||||
Assert.Equal(1, await ctx.ReturnRecords.CountAsync());
|
||||
Assert.Equal(1, await ctx.WaitingListEntries.CountAsync());
|
||||
Assert.Equal(1, await ctx.ExhibitionResults.CountAsync());
|
||||
}
|
||||
finally { try { File.Delete(dbPath); } catch { } }
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) =>
|
||||
new() { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Dob = dob, Farbe = farbe, Origin = origin };
|
||||
|
||||
/// <summary>Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten).</summary>
|
||||
private static string BuildMiniRpro3()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "mini-rpro3-" + Guid.NewGuid().ToString("N") + ".db");
|
||||
using var conn = new SqliteConnection($"DataSource={path}");
|
||||
conn.Open();
|
||||
void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); }
|
||||
|
||||
Exec("CREATE TABLE herk_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
|
||||
Exec("CREATE TABLE abn_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
|
||||
Exec("CREATE TABLE baum_tb (id, _MID, _PID)");
|
||||
Exec("CREATE TABLE stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE, _BECKEN)");
|
||||
Exec("CREATE TABLE fremd_tb (id INTEGER, _NAME, _SEX, _BIRTH, _ZB, _HERK, _MID, _PID, _TODAM, _KASTRAT_DATE)");
|
||||
Exec("CREATE TABLE wurf_tb (id INTEGER, _MID, _PID, _AM, _BEZ, _BEM)");
|
||||
Exec("CREATE TABLE wurftier_tb (id, _WID, _NAME, _SEX, _BIRTH, _SID, _TODAM, _WARUM, _ABAM, _ABN, _PRICE, _ZB, _FEHLER)");
|
||||
Exec("CREATE TABLE color_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE wurfcolor_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE fremdcolor_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE tod_tb (tid, _AM, _WARUM)");
|
||||
Exec("CREATE TABLE waage_tb (_TID, _GRAMM, _DATE, _BEM)");
|
||||
Exec("CREATE TABLE jungwaage_tb (_TID, _WID, _GRAMM, _DATE, _BEM)");
|
||||
Exec("CREATE TABLE krank_tb (_TID, _DATE, _BEM, _MED)");
|
||||
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
|
||||
Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)");
|
||||
// Feature-Tabellen.
|
||||
Exec("CREATE TABLE becken_tb (id INTEGER, _BEZ, _SIZE, _MENGE, _CLEANED, _CYCLUS, _NEXT)");
|
||||
Exec("CREATE TABLE herktier_tb (id INTEGER, _TID, _DATE, _PRICE, _BEM, _ART)");
|
||||
Exec("CREATE TABLE abgeben_tb (tid, _RES, _BEM, _TERMIN, _ABN, _BEZ)");
|
||||
Exec("CREATE TABLE abstat_tb (tid, _IDABN, _AM, _PRICE, _ART, _BEM)");
|
||||
Exec("CREATE TABLE getback_tb (id INTEGER, _TID, _ZAM, _ZPREIS, _AM, _PREIS, _ABN, _BEM)");
|
||||
Exec("CREATE TABLE nachfrage_tb (id INTEGER, _COLOR, _SEX, _DATE, _ABN, _CODE, _VOM, _STATUS, _ART)");
|
||||
Exec("CREATE TABLE ausz_tb (id INTEGER, _TID, _VERANSTALTUNG, _DATE, _ORT, _PLATZ, _AUSZ, _EXTRA, _JUROR, _BEM)");
|
||||
|
||||
// Kontakte
|
||||
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')");
|
||||
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (61,'M.Knoss GG','Clan X','Gießen','a@b.de')");
|
||||
|
||||
// Geschlecht als Latin-1-Bytes einfügen (BLOB), um den smart_decode-Fallback zu testen.
|
||||
var maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
|
||||
var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich");
|
||||
void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb, int becken = 0)
|
||||
{
|
||||
using var c = conn.CreateCommand();
|
||||
c.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_BECKEN) VALUES ($id,$n,$s,$b,$h,$z,3,'',$bk)";
|
||||
c.Parameters.AddWithValue("$id", id);
|
||||
c.Parameters.AddWithValue("$n", name);
|
||||
c.Parameters.AddWithValue("$s", sex);
|
||||
c.Parameters.AddWithValue("$b", birth);
|
||||
c.Parameters.AddWithValue("$h", herk);
|
||||
c.Parameters.AddWithValue("$z", zb);
|
||||
c.Parameters.AddWithValue("$bk", becken);
|
||||
c.ExecuteNonQuery();
|
||||
}
|
||||
InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009", becken: 1);
|
||||
InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009");
|
||||
|
||||
// Genotyp (Bracket-Notation) für Blacky.
|
||||
Exec("INSERT INTO color_tb (id,_FARBE,_FCODE) VALUES ('1','Schwarz','aa Cc[chm] DD EE GG Pp spsp')");
|
||||
|
||||
// Externe Ahnen (Eltern von Blacky & Kuke via baum_tb).
|
||||
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (1,'Oma','weiblich',0,1,'','')");
|
||||
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (2,'Opa','männlich',0,1,'','')");
|
||||
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('1','u1','u2')"); // Blacky Eltern = Oma×Opa
|
||||
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('2','u1','u2')"); // Kuke Eltern = Oma×Opa
|
||||
|
||||
// Wurf: Mutter Kuke (2) × Vater Blacky (1)
|
||||
Exec("INSERT INTO wurf_tb (id,_MID,_PID,_AM,_BEZ,_BEM) VALUES (1,'2','1',2455246.0,'Wurf A','Erster Wurf')");
|
||||
Exec("INSERT INTO wurftier_tb (id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_ABAM,_ABN,_PRICE,_ZB) VALUES ('1j1','1','Alvin','männlich',2455246.0,'','','',NULL,'','21 A')");
|
||||
|
||||
// Gewicht + Krankheit für Blacky.
|
||||
Exec("INSERT INTO waage_tb (_TID,_GRAMM,_DATE,_BEM) VALUES ('1',55.0,2455083.0,'')");
|
||||
Exec("INSERT INTO krank_tb (_TID,_DATE,_BEM,_MED) VALUES ('1',2455077.0,'Prellung','')");
|
||||
|
||||
// Feature-Tabellen-Daten.
|
||||
// Abnehmer-Kontakt (für Reservierung/Rücknahme/Warteliste).
|
||||
Exec("INSERT INTO abn_tb (id,_BEZ,_VNAME,_NNAME) VALUES (5,'Lisa M.','Lisa','Müller')");
|
||||
// Becken 1 (Blacky wohnt dort, _BECKEN=1), Becken 2 ohne Bewohner.
|
||||
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (1,'Käfig A','80x35x40',3,2455595.0,60,2455655.0)");
|
||||
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (2,'Terrarium','55x48x110',32,2455638.0,90,2455728.0)");
|
||||
// Erwerb: Blacky (stamm 1) am JDN für 9.50.
|
||||
Exec("INSERT INTO herktier_tb (id,_TID,_DATE,_PRICE,_BEM) VALUES (61,'1',2455076.0,9.5,'gekauft')");
|
||||
// Reservierung: Blacky (stamm 1) reserviert für abn 5.
|
||||
Exec("INSERT INTO abgeben_tb (tid,_RES,_BEM,_TERMIN,_ABN,_BEZ) VALUES ('1',1,'',2455730.0,5,0)");
|
||||
// Abgabe-Abschluss: Kuke (stamm 2) abgegeben an abn 5 für 15.
|
||||
Exec("INSERT INTO abstat_tb (tid,_IDABN,_AM,_PRICE,_BEM) VALUES ('2',5,2455716.0,15.0,'Zog aus.')");
|
||||
// Rücknahme: Blacky kam zurück von abn 5.
|
||||
Exec("INSERT INTO getback_tb (id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM) VALUES (2,'1',2455992.0,0.0,2455798.0,15.0,5,'kam zurück')");
|
||||
// Warteliste: Wunsch nach männlichem Schimmel von abn 5.
|
||||
Exec("INSERT INTO nachfrage_tb (id,_COLOR,_SEX,_DATE,_ABN,_CODE,_VOM,_STATUS) VALUES (1,'Schimmel','männlich',0.0,5,'egal',2456172.0,'')");
|
||||
// Ausstellung: ein Ergebnis für Blacky (in echten Backups oft leer).
|
||||
Exec("INSERT INTO ausz_tb (id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM) VALUES (1,'1','Schau 2011',2455700.0,'Halle A','1. Platz','BOB','Frau X','schön')");
|
||||
|
||||
conn.Close();
|
||||
SqliteConnection.ClearAllPools();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal file
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (Reservierungs-/Abgabe-Status): the pre-handover pipeline
|
||||
/// verfügbar → reserviert → abgegeben.
|
||||
/// - Full CRUD round-trip via /reservations.
|
||||
/// - GET ?gerbilId= filters to one animal.
|
||||
/// - Invalid status falls back to "verfuegbar".
|
||||
/// - CRITICAL: rows survive the import re-ingest wipe (loose, FK-free GerbilId/ReservedForContactId).
|
||||
/// </summary>
|
||||
public class SaleReservationEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public SaleReservationEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Reservation_crud_lifecycle_create_reserve_handover_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// Create -> defaults to verfuegbar.
|
||||
var create = await client.PostAsJsonAsync("/reservations", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Pippa",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Equal("verfuegbar", created.GetProperty("status").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal("Pippa", created.GetProperty("gerbilName").GetString());
|
||||
|
||||
// List contains it.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/reservations")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// Filter by gerbilId returns exactly this row.
|
||||
var byGerbil = JsonDocument.Parse(await client.GetStringAsync($"/reservations?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.All(byGerbil.EnumerateArray(), r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
|
||||
Assert.Contains(byGerbil.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// PUT -> reserve for a contact with an appointment + price.
|
||||
var reserve = await client.PutAsJsonAsync($"/reservations/{id}", new
|
||||
{
|
||||
status = "reserviert",
|
||||
reservedForContactId = contactId,
|
||||
contactName = "Familie Huber",
|
||||
appointmentDate = "2026-07-01T10:00:00Z",
|
||||
price = 25.50m,
|
||||
note = "Käfig wird mitgebracht.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, reserve.StatusCode);
|
||||
var reserved = JsonDocument.Parse(await reserve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("reserviert", reserved.GetProperty("status").GetString());
|
||||
Assert.Equal(contactId.ToString(), reserved.GetProperty("reservedForContactId").GetString());
|
||||
Assert.Equal("Familie Huber", reserved.GetProperty("contactName").GetString());
|
||||
Assert.Equal(25.50m, reserved.GetProperty("price").GetDecimal());
|
||||
|
||||
// PUT -> hand over.
|
||||
var handover = await client.PutAsJsonAsync($"/reservations/{id}", new
|
||||
{
|
||||
status = "abgegeben",
|
||||
reservedForContactId = contactId,
|
||||
handedOverDate = "2026-07-01T10:30:00Z",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, handover.StatusCode);
|
||||
var handed = JsonDocument.Parse(await handover.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("abgegeben", handed.GetProperty("status").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, handed.GetProperty("handedOverDate").ValueKind);
|
||||
|
||||
// Delete -> 204, then 404.
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/reservations/{id}", new { status = "verfuegbar" })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_with_unknown_status_falls_back_to_verfuegbar()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.NewGuid(), status = "bananen" });
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var dto = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("verfuegbar", dto.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_without_gerbilId_is_rejected()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.Empty, status = "reserviert" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reservation_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "reservation-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("reservation-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A reservation referencing the gerbil + contact that the wipe will delete.
|
||||
var resId = Guid.NewGuid();
|
||||
db.SaleReservations.Add(new SaleReservation
|
||||
{
|
||||
Id = resId,
|
||||
GerbilId = fatherId,
|
||||
GerbilName = "Papa",
|
||||
Status = "reserviert",
|
||||
ReservedForContactId = contactId,
|
||||
ContactName = "Test Breeder",
|
||||
AppointmentDate = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||
Price = 30m,
|
||||
Note = "Abholung am Wochenende.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/contacts were wiped & re-created, but the reservation is untouched.
|
||||
var survivor = await db.SaleReservations.SingleAsync(r => r.Id == resId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved
|
||||
Assert.Equal(contactId, survivor.ReservedForContactId);
|
||||
Assert.Equal("Papa", survivor.GerbilName);
|
||||
Assert.Equal("Test Breeder", survivor.ContactName);
|
||||
Assert.Equal("reserviert", survivor.Status);
|
||||
Assert.Equal(30m, survivor.Price);
|
||||
Assert.Equal(1, await db.SaleReservations.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Active");
|
||||
await Create("KombiAktiv", status: "Breeding");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
|
||||
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WAITLIST (RennmausPro nachfrage_tb): the prospective-buyer waiting list.
|
||||
/// - Full CRUD round-trip: POST -> GET (list + by id) -> PUT (status change) -> DELETE.
|
||||
/// - Validation: an entry with neither contact nor name, and an unknown status, are 400.
|
||||
/// - CRITICAL: waiting-list rows survive the import re-ingest wipe (loose, FK-free ContactId).
|
||||
/// </summary>
|
||||
public class WaitingListEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public WaitingListEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_round_trip()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// CREATE
|
||||
var contactId = Guid.NewGuid();
|
||||
var create = await client.PostAsJsonAsync("/waiting-list", new
|
||||
{
|
||||
contactId,
|
||||
contactName = "Familie Sonntag",
|
||||
wishColor = "Schwarz",
|
||||
wishGender = "female",
|
||||
requestedAt = "2026-06-01T00:00:00Z",
|
||||
status = "offen",
|
||||
note = "möchte zwei Weibchen",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||
Assert.Equal("Schwarz", created.GetProperty("wishColor").GetString());
|
||||
Assert.Equal(contactId.ToString(), created.GetProperty("contactId").GetString());
|
||||
|
||||
// GET by id
|
||||
var byId = JsonDocument.Parse(await client.GetStringAsync($"/waiting-list/{id}")).RootElement;
|
||||
Assert.Equal("Familie Sonntag", byId.GetProperty("contactName").GetString());
|
||||
|
||||
// LIST contains it
|
||||
var list = JsonDocument.Parse(await client.GetStringAsync("/waiting-list")).RootElement;
|
||||
Assert.Contains(list.EnumerateArray(), e => e.GetProperty("id").GetString() == id);
|
||||
|
||||
// UPDATE: set fulfilled
|
||||
var update = await client.PutAsJsonAsync($"/waiting-list/{id}", new
|
||||
{
|
||||
contactId,
|
||||
contactName = "Familie Sonntag",
|
||||
wishColor = "Schwarz",
|
||||
wishGender = "female",
|
||||
requestedAt = "2026-06-01T00:00:00Z",
|
||||
status = "erfuellt",
|
||||
note = "erledigt",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
var updated = JsonDocument.Parse(await update.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("erfuellt", updated.GetProperty("status").GetString());
|
||||
|
||||
// DELETE
|
||||
var del = await client.DeleteAsync($"/waiting-list/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = await client.GetAsync($"/waiting-list/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_empty_contact_and_name()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { status = "offen", wishColor = "Gold" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_unknown_status()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Test", status = "irgendwas" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_status_defaults_to_offen()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Ohne Status" });
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitingList_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors FeedbackEndpointTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "waitlist-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("waitlist-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A waiting-list entry referencing the contact that the wipe will delete.
|
||||
var entryId = Guid.NewGuid();
|
||||
db.WaitingListEntries.Add(new WaitingListEntry
|
||||
{
|
||||
Id = entryId,
|
||||
ContactId = contactId,
|
||||
ContactName = "Test Breeder",
|
||||
WishColor = "Schwarz",
|
||||
WishGender = "female",
|
||||
RequestedAt = DateTime.UtcNow,
|
||||
Status = "offen",
|
||||
Note = "wartet",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Contacts were wiped & re-created, but the waiting-list entry is untouched.
|
||||
var survivor = await db.WaitingListEntries.SingleAsync(e => e.Id == entryId);
|
||||
Assert.Equal(contactId, survivor.ContactId); // loose id preserved even though the contact row was deleted/recreated
|
||||
Assert.Equal("Test Breeder", survivor.ContactName);
|
||||
Assert.Equal("offen", survivor.Status);
|
||||
Assert.Equal(1, await db.WaitingListEntries.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "2b7d1860-b8d4-44d1-8548-be847b925c74",
|
||||
"name": "GerbilManager",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "19567620"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Gerbils",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get All Gerbil",
|
||||
"protocolProfileBehavior": {
|
||||
"strictSSL": false
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base}}/Gerbils",
|
||||
"host": [
|
||||
"{{base}}"
|
||||
],
|
||||
"path": [
|
||||
"Gerbils"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Post random Gerbil",
|
||||
"protocolProfileBehavior": {
|
||||
"disabledSystemHeaders": {
|
||||
"content-type": true
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"name\": \"{{$randomFirstName}}\",\r\n \"gender\": {{randomGender}}\r\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base}}/Gerbils",
|
||||
"host": [
|
||||
"{{base}}"
|
||||
],
|
||||
"path": [
|
||||
"Gerbils"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.globals.set('randomGender', Math.floor(Math.random() * 2)+1);"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "https://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "portDebug",
|
||||
"value": "7191",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "portProductive",
|
||||
"value": "80",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "rootD",
|
||||
"value": "http://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "rootR",
|
||||
"value": "http://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "base",
|
||||
"value": "{{base_url}}:{{portDebug}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
@@ -23,6 +24,12 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||
|
||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||
@@ -49,6 +56,13 @@ public class ApplicationContext : DbContext
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||
// support custom collation names and would fail EnsureCreated on the test host.
|
||||
const string DeIcu = "de-x-icu";
|
||||
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
|
||||
modelBuilder.Entity<Gerbil>(e =>
|
||||
{
|
||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||
@@ -58,6 +72,14 @@ public class ApplicationContext : DbContext
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
@@ -81,6 +103,12 @@ public class ApplicationContext : DbContext
|
||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.ColorVariety).WithMany()
|
||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||
e.HasIndex(g => g.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Litter>(e =>
|
||||
@@ -89,6 +117,12 @@ public class ApplicationContext : DbContext
|
||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(l => l.Mother).WithMany()
|
||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// DB-5: ExternalRef = source litter id from extract.py.
|
||||
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
@@ -106,6 +140,10 @@ public class ApplicationContext : DbContext
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||
e.HasOne<Enclosure>().WithMany()
|
||||
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
@@ -170,6 +208,73 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
|
||||
// Guid columns (no navigation properties → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
|
||||
// feedback rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<Feedback>(e =>
|
||||
{
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId
|
||||
// are plain nullable Guid columns (no navigation properties → EF creates NO foreign
|
||||
// key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or
|
||||
// breaks — acquisition rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<AcquisitionRecord>(e =>
|
||||
{
|
||||
e.Property(a => a.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(a => a.GerbilId);
|
||||
});
|
||||
|
||||
// ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback).
|
||||
// GerbilId/ReservedForContactId are plain Guid columns (no navigation properties →
|
||||
// EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never
|
||||
// cascades into — or breaks — reservation rows. They survive re-ingest by design.
|
||||
modelBuilder.Entity<SaleReservation>(e =>
|
||||
{
|
||||
e.Property(r => r.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
});
|
||||
|
||||
// WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain
|
||||
// nullable Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Contacts never cascades into — or breaks —
|
||||
// waiting-list rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<WaitingListEntry>(e =>
|
||||
{
|
||||
e.HasIndex(w => w.CreatedAt);
|
||||
});
|
||||
|
||||
// RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/
|
||||
// FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt
|
||||
// KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts
|
||||
// diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest.
|
||||
modelBuilder.Entity<ReturnRecord>(e =>
|
||||
{
|
||||
e.Property(r => r.ReturnPrice).HasPrecision(10, 2);
|
||||
e.Property(r => r.OriginalPrice).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
e.HasIndex(r => r.CreatedAt);
|
||||
});
|
||||
|
||||
// EXHIBITION: deliberately relationship-free (same pattern as Feedback). GerbilId is a
|
||||
// plain nullable Guid column (no navigation property → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils never cascades into or breaks exhibition rows.
|
||||
modelBuilder.Entity<ExhibitionResult>(e =>
|
||||
{
|
||||
e.HasIndex(x => x.GerbilId);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
modelBuilder.Entity<ColorVariety>()
|
||||
.Property(v => v.Name).UseCollation(DeIcu);
|
||||
modelBuilder.Entity<Contact>()
|
||||
.Property(c => c.Name).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
@@ -246,79 +351,97 @@ public class ApplicationContext : DbContext
|
||||
/// </summary>
|
||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||
{
|
||||
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
|
||||
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
|
||||
// SortOrder for new entries is appended; Kevin's frontend catalog handles
|
||||
// the interleaved display order via its own sortOrder values.
|
||||
(string Name, string Genotype)[] catalog =
|
||||
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
|
||||
// ID = array_index + 1 (stable, never changes — live FKs safe).
|
||||
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
|
||||
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
|
||||
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
|
||||
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
|
||||
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||
{
|
||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
||||
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
|
||||
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
|
||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
|
||||
("Blau", "aa CC dd EE GG PP spsp rere"),
|
||||
("Gold", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin", "aa CC DD EE GG pp spsp rere"),
|
||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
|
||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
|
||||
("dd Gold", "AA CC dd EE GG pp spsp rere"),
|
||||
("dd Platin", "aa CC dd EE GG pp spsp rere"),
|
||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
|
||||
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
|
||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
|
||||
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
|
||||
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
|
||||
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
|
||||
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
|
||||
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
|
||||
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||
// --- Zobel / colourpoint dark (ID 4) ---
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
|
||||
// --- Schimmel family (IDs 5) ---
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
|
||||
// --- Standard colours (IDs 6-15) ---
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
|
||||
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
|
||||
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
|
||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
|
||||
("Blau", "aa CC dd EE GG PP spsp rere", 10),
|
||||
("Gold", "AA CC DD EE GG pp spsp rere", 11),
|
||||
("Platin", "aa CC DD EE GG pp spsp rere", 12),
|
||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
|
||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
|
||||
// --- Dilute (dd) standard (IDs 16-17) ---
|
||||
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
|
||||
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
|
||||
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
|
||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
|
||||
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
|
||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
|
||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||
// --- Hell variants (IDs 38-48) ---
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
|
||||
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
|
||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
|
||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
|
||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
|
||||
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
|
||||
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
|
||||
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
|
||||
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
|
||||
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
|
||||
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
|
||||
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
|
||||
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
|
||||
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
|
||||
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
|
||||
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
|
||||
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
@@ -326,11 +449,11 @@ public class ApplicationContext : DbContext
|
||||
{
|
||||
rows[i] = new ColorVariety
|
||||
{
|
||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
||||
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||
Name = catalog[i].Name,
|
||||
CanonicalGenotype = catalog[i].Genotype,
|
||||
SortOrder = i,
|
||||
SortOrder = catalog[i].SortOrder,
|
||||
};
|
||||
}
|
||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||
|
||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||
/// In production this volume is shared with the publicsite-nginx container.
|
||||
/// </summary>
|
||||
public sealed class PublicSiteOptions
|
||||
{
|
||||
public const string SectionName = "PublicSite";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
|
||||
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
|
||||
public sealed record ContractAnimal(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null);
|
||||
string? Farbschlag = null,
|
||||
byte[]? Photo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||
|
||||
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Globalization;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
|
||||
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
|
||||
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
|
||||
/// 4–8 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
|
||||
/// </summary>
|
||||
public static class ContractPdfGenerator
|
||||
{
|
||||
static ContractPdfGenerator()
|
||||
{
|
||||
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
private static string Price(decimal p) => p.ToString("N2", German) + " €";
|
||||
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
|
||||
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
|
||||
|
||||
private const string Accent = "#A85F2E";
|
||||
private const string Ink = "#3D2E23";
|
||||
private const string Line = "#D9CCB4";
|
||||
|
||||
public static byte[] Generate(ContractData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
return Document.Create(doc =>
|
||||
{
|
||||
doc.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(1.8f, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
|
||||
|
||||
page.Header().PaddingBottom(8).Column(h =>
|
||||
{
|
||||
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
|
||||
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(6).Column(col =>
|
||||
{
|
||||
col.Spacing(12);
|
||||
Parties(col, data);
|
||||
Animals(col, data);
|
||||
Subject(col, data);
|
||||
Clauses(col);
|
||||
Signatures(col, data);
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
|
||||
t.Span("Seite ");
|
||||
t.CurrentPageNumber();
|
||||
t.Span(" / ");
|
||||
t.TotalPages();
|
||||
});
|
||||
});
|
||||
}).GeneratePdf();
|
||||
}
|
||||
|
||||
private static void Heading(ColumnDescriptor col, string text) =>
|
||||
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
|
||||
|
||||
private static void Kv(ColumnDescriptor col, string label, string value) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
|
||||
private static void Bullet(ColumnDescriptor col, string text) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(14).Text("•").FontColor(Accent);
|
||||
r.RelativeItem().Text(text);
|
||||
});
|
||||
|
||||
private static void Parties(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var s = data.Seller;
|
||||
var b = data.Buyer;
|
||||
Heading(col, "1. Vertragspartner");
|
||||
col.Item().Text("Verkäufer / Züchter").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Zuchtname:", Or(s.ZuchtName));
|
||||
Kv(c, "Vor- und Nachname:", Or(s.Name));
|
||||
Kv(c, "Adresse:", Or(s.Address));
|
||||
Kv(c, "Telefon:", Or(s.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(s.Email, ""));
|
||||
Kv(c, "Homepage:", Or(s.Homepage, ""));
|
||||
});
|
||||
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Vor- und Nachname:", Or(b.Name));
|
||||
Kv(c, "Adresse:", Or(b.Address));
|
||||
Kv(c, "Telefon:", Or(b.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(b.Email, ""));
|
||||
});
|
||||
}
|
||||
|
||||
private static void Animals(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "2. Angaben zu den Tieren");
|
||||
col.Item().Column(list =>
|
||||
{
|
||||
list.Spacing(10);
|
||||
foreach (var a in data.Animals)
|
||||
{
|
||||
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
|
||||
// wie im Mustervertrag (das große Rechteck links).
|
||||
list.Item().Border(1).BorderColor(Line).Row(row =>
|
||||
{
|
||||
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
|
||||
.Padding(4).Element(box =>
|
||||
{
|
||||
if (a.Photo is { Length: > 0 } bytes)
|
||||
box.AlignMiddle().Image(bytes).FitArea();
|
||||
else
|
||||
box.AlignMiddle().AlignCenter().Text("Kein Foto")
|
||||
.FontSize(8).FontColor("#B8AC97");
|
||||
});
|
||||
|
||||
row.RelativeItem(3f).Column(kv =>
|
||||
{
|
||||
AnimalRow(kv, "Name:", Or(a.Name, ""));
|
||||
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
|
||||
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
|
||||
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
|
||||
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
|
||||
{
|
||||
var cell = col.Item();
|
||||
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
|
||||
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
|
||||
{
|
||||
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
}
|
||||
|
||||
private static void Subject(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "3. Gegenstand des Vertrages");
|
||||
col.Item().Text(
|
||||
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
|
||||
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
|
||||
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
|
||||
$"von dem Züchter übergeben.");
|
||||
}
|
||||
|
||||
private static void Clauses(ColumnDescriptor col)
|
||||
{
|
||||
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
|
||||
col.Item().Text(
|
||||
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
|
||||
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
|
||||
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
|
||||
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
|
||||
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
|
||||
|
||||
Heading(col, "5. Pflichten des Käufers");
|
||||
col.Item().Text(
|
||||
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
|
||||
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
|
||||
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
|
||||
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
|
||||
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
|
||||
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
|
||||
});
|
||||
|
||||
Heading(col, "6. Rechte des Käufers");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
|
||||
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
|
||||
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
|
||||
});
|
||||
|
||||
Heading(col, "7. Haftung / Zuwiderhandlung");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
|
||||
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
|
||||
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
|
||||
});
|
||||
|
||||
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
|
||||
col.Item().Text(
|
||||
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
|
||||
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
|
||||
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
|
||||
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
|
||||
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
|
||||
}
|
||||
|
||||
private static void Signatures(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
|
||||
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
|
||||
col.Item().PaddingTop(28).Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
|
||||
});
|
||||
r.ConstantItem(40);
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>ERWERB: payload for POST/PUT /acquisitions (acquisition data per animal).</summary>
|
||||
public record AcquisitionInput(
|
||||
Guid? GerbilId,
|
||||
Guid? SourceContactId,
|
||||
DateOnly? Date,
|
||||
decimal? Price,
|
||||
string? Note);
|
||||
|
||||
/// <summary>ERWERB: response DTO for a stored acquisition record.</summary>
|
||||
public record AcquisitionDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
Guid? SourceContactId,
|
||||
DateOnly? Date,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -29,21 +30,29 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated,
|
||||
string? Provenance);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
string? Provenance);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||
|
||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||
public record EnclosureDto(
|
||||
Guid Id, string Name, string? Notes,
|
||||
string? Size, int? Capacity,
|
||||
DateOnly? LastCleanedDate, int? CleaningCycleDays, DateOnly? NextCleaningDate);
|
||||
|
||||
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
||||
|
||||
@@ -59,8 +68,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record GerbilInput(
|
||||
string Name,
|
||||
Gender Gender,
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
GerbilStatus? Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
@@ -72,6 +81,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -79,20 +89,25 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
public record EnclosureInput(
|
||||
string Name, string? Notes,
|
||||
string? Size, int? Capacity,
|
||||
DateOnly? LastCleanedDate, int? CleaningCycleDays);
|
||||
|
||||
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||
|
||||
|
||||
@@ -12,14 +12,20 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
string Url,
|
||||
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
|
||||
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
|
||||
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
|
||||
bool HasFile);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate);
|
||||
DateOnly? ContractDate,
|
||||
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
|
||||
Dictionary<Guid, Guid>? AnimalPhotos = null);
|
||||
|
||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||
public record BreederProfileDto(
|
||||
@@ -29,5 +35,6 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
string City,
|
||||
string NameSuffix);
|
||||
}
|
||||
|
||||
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>EXHIBITION: payload for POST /exhibitions.</summary>
|
||||
public record ExhibitionResultInput(
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note);
|
||||
|
||||
/// <summary>EXHIBITION: payload for PUT /exhibitions/{id} (all fields optional/partial).</summary>
|
||||
public record ExhibitionResultUpdate(
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string? EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note);
|
||||
|
||||
/// <summary>EXHIBITION: response DTO for a stored exhibition result.</summary>
|
||||
public record ExhibitionResultDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
60
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
60
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
|
||||
public record FeedbackInput(
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
|
||||
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
|
||||
/// </summary>
|
||||
public record FeedbackThreadEntry(
|
||||
string Role,
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp,
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Status,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt,
|
||||
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
|
||||
string? FixNote,
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
/// attach a clarifying question (Rückfrage), submit the breeder's answer, set the
|
||||
/// breeder-friendly fix note (changelog), or update the internal agent context.
|
||||
/// </summary>
|
||||
public record FeedbackUpdate(
|
||||
string? Message,
|
||||
string? Status,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext);
|
||||
}
|
||||
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
|
||||
/// Freitext. <c>Type</c> ist "tier" | "kontakt" | "wurf" | "gehege"; <c>Code</c> ist
|
||||
/// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
|
||||
/// </summary>
|
||||
public record RefRequest(string Type, string Code);
|
||||
|
||||
/// <summary>REFS: Body von POST /refs/resolve.</summary>
|
||||
public record ResolveRefsInput(List<RefRequest> Refs);
|
||||
|
||||
/// <summary>
|
||||
/// REFS: Auflösungsergebnis. <c>Id</c>/<c>Name</c> sind null, wenn der Code unbekannt
|
||||
/// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
|
||||
/// <c>Type</c>/<c>Code</c> werden gespiegelt, damit der Client zuordnen kann.
|
||||
/// </summary>
|
||||
public record RefResolution(string Type, string Code, Guid? Id, string? Name);
|
||||
}
|
||||
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>RÜCKNAHME: Payload für POST/PUT /returns.</summary>
|
||||
public record ReturnRecordInput(
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note);
|
||||
|
||||
/// <summary>RÜCKNAHME: Antwort-DTO für einen gespeicherten Rücknahme-Datensatz.</summary>
|
||||
public record ReturnRecordDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
66
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
66
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>Zählungen für die Analyse-Übersicht (RennmausPro-III-Import).</summary>
|
||||
public record Rpro3Counts(
|
||||
int OwnAnimals, // eigene Tiere (stamm)
|
||||
int ExternalRaw, // externe Ahnen (fremd, roh, vor Dedup)
|
||||
int ExternalAfterDedup, // externe Ahnen nach Dedup
|
||||
int Litters, // Würfe
|
||||
int Contacts, // Kontakte (Herkunft + Abnehmer)
|
||||
int Genotypes, // hinterlegte Genotypen (eigen + extern)
|
||||
int DuplicatesMerged, // weggefallene interne Dubletten
|
||||
int MergeClusters, // Anzahl zusammengelegter Cluster
|
||||
int Enclosures, // Gehege/Becken
|
||||
int Acquisitions, // Erwerbe (herktier_tb)
|
||||
int Reservations, // Reservierungen/Abgaben (abgeben_tb + abstat_tb)
|
||||
int Returns, // Rücknahmen (getback_tb)
|
||||
int WaitingList, // Wartelisten-Einträge (nachfrage_tb)
|
||||
int Exhibitions); // Ausstellungsergebnisse (ausz_tb)
|
||||
|
||||
/// <summary>Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin).</summary>
|
||||
public record Rpro3AmbiguousVariant(
|
||||
int Count,
|
||||
string Dob,
|
||||
string Farbe,
|
||||
string Origin,
|
||||
bool IsOwn);
|
||||
|
||||
public record Rpro3AmbiguousName(
|
||||
string Name,
|
||||
IReadOnlyList<Rpro3AmbiguousVariant> Variants,
|
||||
int BareCount);
|
||||
|
||||
/// <summary>Ein großer sicherer Merge-Cluster (zur Veranschaulichung der Dedup-Wirkung).</summary>
|
||||
public record Rpro3MergeSample(
|
||||
string Name,
|
||||
int RecordCount,
|
||||
string Dob,
|
||||
string Farbe,
|
||||
string Origin);
|
||||
|
||||
/// <summary>Ergebnis von POST /import/rpro3/analyze — schreibt NICHTS in die DB.</summary>
|
||||
public record Rpro3AnalyzeResult(
|
||||
Rpro3Counts Counts,
|
||||
int NewVsCurrentNew, // wie viele Tiere im aktuellen Stand fehlen würden (neu)
|
||||
int NewVsCurrentExisting, // wie viele schon vorhanden sind (Match)
|
||||
IReadOnlyList<Rpro3MergeSample> TopMerges,
|
||||
IReadOnlyList<Rpro3AmbiguousName> AmbiguousNames,
|
||||
bool PhotosProvided,
|
||||
int PhotoFilesAvailable);
|
||||
|
||||
/// <summary>Ergebnis von POST /import/rpro3/execute.</summary>
|
||||
public record Rpro3ExecuteResult(
|
||||
int GerbilsImported,
|
||||
int LittersImported,
|
||||
int ContactsImported,
|
||||
int HealthRecordsImported,
|
||||
int WeightRecordsImported,
|
||||
int PhotosImported,
|
||||
int EnclosuresImported,
|
||||
int AcquisitionsImported,
|
||||
int ReservationsImported,
|
||||
int ReturnsImported,
|
||||
int WaitingListImported,
|
||||
int ExhibitionsImported,
|
||||
string Message);
|
||||
}
|
||||
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>ABGABE-STATUS: response DTO for a stored reservation/sale status.</summary>
|
||||
public record SaleReservationDto(
|
||||
Guid Id,
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>ABGABE-STATUS: payload for POST /reservations.</summary>
|
||||
public record SaleReservationInput(
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS: payload for PUT /reservations/{id}. All fields optional — only the
|
||||
/// provided ones are changed. A null/blank Status is ignored.
|
||||
/// </summary>
|
||||
public record SaleReservationUpdate(
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
}
|
||||
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal file
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>WAITLIST: payload for POST/PUT /waiting-list.</summary>
|
||||
public record WaitingListInput(
|
||||
Guid? ContactId,
|
||||
string? ContactName,
|
||||
string? WishColor,
|
||||
string? WishGender,
|
||||
DateTime? RequestedAt,
|
||||
string? Status,
|
||||
string? Note);
|
||||
|
||||
/// <summary>WAITLIST: response DTO for a stored waiting-list entry.</summary>
|
||||
public record WaitingListDto(
|
||||
Guid Id,
|
||||
Guid? ContactId,
|
||||
string? ContactName,
|
||||
string? WishColor,
|
||||
string? WishGender,
|
||||
DateTime? RequestedAt,
|
||||
string Status,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal file
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// ERWERB: acquisition data per animal (purchase date/price/note).
|
||||
/// GET /acquisitions?gerbilId=… -> records for one animal (or all), newest date first.
|
||||
/// GET /acquisitions/{id} -> a single record.
|
||||
/// POST /acquisitions -> create, returns 201.
|
||||
/// PUT /acquisitions/{id} -> update, returns 204.
|
||||
/// DELETE /acquisitions/{id} -> delete, returns 204.
|
||||
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
|
||||
/// the import re-ingest wipe (mirrors Feedback).
|
||||
/// </summary>
|
||||
public static class AcquisitionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAcquisitionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/acquisitions").WithTags("Acquisitions");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
var rows = await db.AcquisitionRecords.AsNoTracking()
|
||||
.Where(a => gerbilId == null || a.GerbilId == gerbilId)
|
||||
.ToListAsync();
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
|
||||
// and DateOnly ordering stays consistent across providers this way.
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(a => a.Date ?? DateOnly.MinValue)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<AcquisitionDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var a = await db.AcquisitionRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return a is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(a));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (AcquisitionInput input, ApplicationContext db) =>
|
||||
{
|
||||
var a = new AcquisitionRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
SourceContactId = input.SourceContactId,
|
||||
Date = input.Date,
|
||||
Price = input.Price,
|
||||
Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.AcquisitionRecords.Add(a);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/acquisitions/{a.Id}", ToDto(a));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, AcquisitionInput input, ApplicationContext db) =>
|
||||
{
|
||||
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (a is null) return TypedResults.NotFound();
|
||||
a.GerbilId = input.GerbilId;
|
||||
a.SourceContactId = input.SourceContactId;
|
||||
a.Date = input.Date;
|
||||
a.Price = input.Price;
|
||||
a.Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (a is null) return TypedResults.NotFound();
|
||||
db.AcquisitionRecords.Remove(a);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static AcquisitionDto ToDto(AcquisitionRecord a) =>
|
||||
new(a.Id, a.GerbilId, a.SourceContactId, a.Date, a.Price, a.Note, a.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||
{
|
||||
if (!opts.Value.IsConfigured)
|
||||
return Results.Problem(
|
||||
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||
statusCode: 503,
|
||||
title: "PublicSite nicht konfiguriert");
|
||||
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||
return Results.Ok(new { filesPublished = files.Count });
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||
/// </summary>
|
||||
internal static async Task PublishToDirectoryAsync(
|
||||
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||
{
|
||||
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||
var liveDir = Path.Combine(rootPath, "live");
|
||||
var oldDir = Path.Combine(rootPath, "_old");
|
||||
|
||||
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
foreach (var (relativePath, content) in files)
|
||||
{
|
||||
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Atomic swap: _staging_new → live
|
||||
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||
Directory.Move(stagingDir, liveDir);
|
||||
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||
catch { /* non-fatal — old dir gone on next publish */ }
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -22,24 +23,31 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
private const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db,
|
||||
IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var root = ContractRoot(config, env);
|
||||
return TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
.ToPagedResultAsync(query, c => ToDto(c, root)));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env)));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
@@ -49,6 +57,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
var photoChoice = input.AnimalPhotos ?? [];
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
@@ -99,16 +108,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal
|
||||
{
|
||||
GerbilId = id,
|
||||
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
||||
}).ToList(),
|
||||
};
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
GerbilStatusService.Apply(g, today);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -142,6 +156,49 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
||||
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
||||
|
||||
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
||||
var gerbils = await db.Gerbils.AsNoTracking()
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
||||
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
||||
var photoByGerbil = c.Animals
|
||||
.Where(a => a.PhotoId is not null)
|
||||
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
||||
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
||||
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(
|
||||
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
||||
photoBytes.GetValueOrDefault(g.Id)))
|
||||
.ToList(),
|
||||
Price: c.Price,
|
||||
HandoverDate: c.HandoverDate,
|
||||
ContractDate: c.ContractDate);
|
||||
|
||||
var pdf = ContractPdfGenerator.Generate(data);
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
||||
return TypedResults.File(pdf, PdfContentType, download);
|
||||
});
|
||||
|
||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
@@ -164,6 +221,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||
|
||||
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
||||
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||
|
||||
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
||||
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
||||
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
||||
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
||||
{
|
||||
var result = new Dictionary<Guid, byte[]>();
|
||||
if (photoByGerbil.Count == 0) return result;
|
||||
|
||||
var photoIds = photoByGerbil.Values.ToList();
|
||||
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => photoIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var (gerbilId, photoId) in photoByGerbil)
|
||||
{
|
||||
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
||||
if (photo is null) continue;
|
||||
var path = Path.Combine(photoRoot, photo.FileName);
|
||||
if (File.Exists(path))
|
||||
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
@@ -193,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
$"/contracts/{c.Id}/file",
|
||||
// Imported contracts have no staged .docx -> HasFile=false (Word
|
||||
// download 404s gracefully). When no root is given (e.g. POST, where
|
||||
// the file was just written) we assume the file exists.
|
||||
HasFile: contractRoot is null
|
||||
|| (!string.IsNullOrEmpty(c.FileName)
|
||||
&& File.Exists(Path.Combine(contractRoot, c.FileName))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||
Apply(e, input);
|
||||
db.Enclosures.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
||||
@@ -35,10 +36,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.Name = input.Name; e.Notes = input.Notes;
|
||||
Apply(e, input);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// Reinigung dokumentieren: setzt LastCleanedDate auf heute (NextCleaningDate folgt aus dem Zyklus).
|
||||
group.MapPost("/{id:guid}/mark-cleaned", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.LastCleanedDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(e));
|
||||
});
|
||||
|
||||
// 409 if the enclosure still houses gerbils.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -54,6 +66,16 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
|
||||
private static void Apply(Enclosure e, EnclosureInput input)
|
||||
{
|
||||
e.Size = input.Size;
|
||||
e.Capacity = input.Capacity;
|
||||
e.LastCleanedDate = input.LastCleanedDate;
|
||||
e.CleaningCycleDays = input.CleaningCycleDays;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) =>
|
||||
new(e.Id, e.Name, e.Notes, e.Size, e.Capacity,
|
||||
e.LastCleanedDate, e.CleaningCycleDays, e.NextCleaningDate);
|
||||
}
|
||||
}
|
||||
|
||||
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// EXHIBITION: show/exhibition results & awards per animal (RennmausPro `ausz_tb`).
|
||||
/// GET /exhibitions[?gerbilId=…] -> list (optionally filtered to one animal), newest first.
|
||||
/// POST /exhibitions -> create a result, returns 201.
|
||||
/// PUT /exhibitions/{id} -> edit a result (partial), 404 on missing id.
|
||||
/// DELETE /exhibitions/{id} -> remove a result, 404 on missing id.
|
||||
/// Decoupled from gerbils (loose nullable GerbilId, no FK) so rows survive the import re-ingest wipe.
|
||||
/// </summary>
|
||||
public static class ExhibitionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapExhibitionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/exhibitions").WithTags("Exhibitions");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
// Filter in the query; order in memory (SQLite test host cannot ORDER BY a
|
||||
// DateTimeOffset column — mirrors FeedbackEndpoints).
|
||||
var query = db.ExhibitionResults.AsNoTracking();
|
||||
if (gerbilId is { } gid)
|
||||
query = query.Where(x => x.GerbilId == gid);
|
||||
var rows = await query.ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(x => x.Date ?? DateTime.MinValue)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.ExhibitionResults.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<ExhibitionResultDto>, BadRequest<string>>> (
|
||||
ExhibitionResultInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.EventName))
|
||||
return TypedResults.BadRequest("EventName darf nicht leer sein.");
|
||||
|
||||
var entity = new ExhibitionResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
EntityName = Trim(input.EntityName),
|
||||
EventName = input.EventName.Trim(),
|
||||
Date = input.Date,
|
||||
Placement = Trim(input.Placement),
|
||||
Award = Trim(input.Award),
|
||||
Note = Trim(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ExhibitionResults.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/exhibitions/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, ExhibitionResultUpdate input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (input.EventName is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.EventName))
|
||||
return TypedResults.BadRequest("EventName darf nicht leer sein.");
|
||||
entity.EventName = input.EventName.Trim();
|
||||
}
|
||||
if (input.GerbilId is not null) entity.GerbilId = input.GerbilId;
|
||||
if (input.EntityName is not null) entity.EntityName = Trim(input.EntityName);
|
||||
if (input.Date is not null) entity.Date = input.Date;
|
||||
if (input.Placement is not null) entity.Placement = Trim(input.Placement);
|
||||
if (input.Award is not null) entity.Award = Trim(input.Award);
|
||||
if (input.Note is not null) entity.Note = Trim(input.Note);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.ExhibitionResults.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Empty/whitespace optional strings normalise to null.
|
||||
private static string? Trim(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static ExhibitionResultDto ToDto(ExhibitionResult r) =>
|
||||
new(r.Id, r.GerbilId, r.EntityName, r.EventName, r.Date,
|
||||
r.Placement, r.Award, r.Note, r.CreatedAt);
|
||||
}
|
||||
}
|
||||
225
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
225
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets").
|
||||
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||
/// GET /feedback -> list reports, newest first (for the ticket list).
|
||||
/// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage),
|
||||
/// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt.
|
||||
/// DELETE /feedback/{id} -> remove a report. 404 on missing id.
|
||||
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||
/// rows survive the import re-ingest wipe.
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
|
||||
FeedbackInput input, ApplicationContext db, HttpContext http) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
return TypedResults.BadRequest("Message darf nicht leer sein.");
|
||||
|
||||
var entity = new Feedback
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Message = input.Message.Trim(),
|
||||
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
||||
GerbilId = input.GerbilId,
|
||||
LitterId = input.LitterId,
|
||||
ContactId = input.ContactId,
|
||||
EntityName = input.EntityName,
|
||||
Url = input.Url,
|
||||
ClientTimestamp = input.ClientTimestamp,
|
||||
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Open",
|
||||
ResolvedAt = null,
|
||||
};
|
||||
db.Feedback.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (input.Message is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
return TypedResults.BadRequest("Message darf nicht leer sein.");
|
||||
entity.Message = input.Message.Trim();
|
||||
}
|
||||
|
||||
// Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to
|
||||
// NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace
|
||||
// question clears it. If a PREVIOUS Q&A exchange already exists, archive it into the
|
||||
// thread (oldest first) before overwriting — so earlier rounds aren't lost — and clear
|
||||
// the current Answer so the new question starts a fresh open exchange.
|
||||
if (input.Question is not null)
|
||||
{
|
||||
var q = input.Question.Trim();
|
||||
if (q.Length > 0)
|
||||
{
|
||||
ArchiveCurrentExchange(entity);
|
||||
entity.Question = q;
|
||||
if (!entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entity.Status = "NeedsInfo";
|
||||
entity.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Question = null;
|
||||
}
|
||||
}
|
||||
|
||||
// The breeder's answer. A non-empty answer stamps AnsweredAt and moves to Answered.
|
||||
if (input.Answer is not null)
|
||||
{
|
||||
var a = input.Answer.Trim();
|
||||
if (a.Length == 0)
|
||||
{
|
||||
entity.Answer = null;
|
||||
entity.AnsweredAt = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Answer = a;
|
||||
entity.AnsweredAt = DateTimeOffset.UtcNow;
|
||||
entity.Status = "Answered";
|
||||
entity.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.Status is not null)
|
||||
{
|
||||
// Normalize to a known lifecycle state. Resolving stamps ResolvedAt; any other
|
||||
// state clears it. Open/NeedsInfo/Answered/Resolved are accepted (case-insensitive),
|
||||
// anything else falls back to Open.
|
||||
var status = input.Status.Trim();
|
||||
var resolved = status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
var normalized = status switch
|
||||
{
|
||||
_ when status.Equals("Resolved", StringComparison.OrdinalIgnoreCase) => "Resolved",
|
||||
_ when status.Equals("NeedsInfo", StringComparison.OrdinalIgnoreCase) => "NeedsInfo",
|
||||
_ when status.Equals("Answered", StringComparison.OrdinalIgnoreCase) => "Answered",
|
||||
_ => "Open",
|
||||
};
|
||||
entity.Status = normalized;
|
||||
entity.ResolvedAt = resolved
|
||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||
: null;
|
||||
}
|
||||
|
||||
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
|
||||
// A blank/whitespace value clears it.
|
||||
if (input.FixNote is not null)
|
||||
{
|
||||
var note = input.FixNote.Trim();
|
||||
entity.FixNote = note.Length == 0 ? null : note;
|
||||
}
|
||||
|
||||
// AGENT-CONTEXT (internal working memory): updated without changing the status.
|
||||
// A blank/whitespace value clears it.
|
||||
if (input.AgentContext is not null)
|
||||
{
|
||||
var c = input.AgentContext.Trim();
|
||||
entity.AgentContext = c.Length == 0 ? null : c;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.Feedback.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
||||
/// before it gets overwritten by a new round, then clear the current Answer. Only the
|
||||
/// non-empty parts are archived (a question with no answer yet is still preserved).
|
||||
/// </summary>
|
||||
private static void ArchiveCurrentExchange(Feedback entity)
|
||||
{
|
||||
var history = DeserializeThread(entity.Thread).ToList();
|
||||
var archivedAny = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entity.Question))
|
||||
{
|
||||
history.Add(new FeedbackThreadEntry("maintainer", entity.Question!, entity.CreatedAt));
|
||||
archivedAny = true;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(entity.Answer))
|
||||
{
|
||||
history.Add(new FeedbackThreadEntry("breeder", entity.Answer!, entity.AnsweredAt));
|
||||
archivedAny = true;
|
||||
}
|
||||
|
||||
if (archivedAny)
|
||||
entity.Thread = JsonSerializer.Serialize(history, ThreadJson);
|
||||
|
||||
// The new question starts a fresh open exchange — clear the prior answer.
|
||||
entity.Answer = null;
|
||||
entity.AnsweredAt = null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FeedbackThreadEntry> DeserializeThread(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<FeedbackThreadEntry>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<FeedbackThreadEntry>>(json, ThreadJson)
|
||||
?? new List<FeedbackThreadEntry>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<FeedbackThreadEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||
DeserializeThread(f.Thread));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
{
|
||||
var q = query.ToQuery();
|
||||
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
||||
var ids = paging.Data.Select(g => g.Id).ToList();
|
||||
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
||||
await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
||||
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
||||
var items = paging.Data
|
||||
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
||||
.ToList();
|
||||
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
||||
});
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => p.FileName)
|
||||
.FirstOrDefaultAsync();
|
||||
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
||||
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
@@ -39,19 +61,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id}
|
||||
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
g.Name = input.Name;
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
@@ -77,34 +98,46 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||
{
|
||||
g.Gender = i.Gender;
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
g.LitterId = i.LitterId;
|
||||
g.OriginContactId = i.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate;
|
||||
g.Genotype = i.Genotype;
|
||||
g.Notes = i.Notes;
|
||||
g.ImportSource = i.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
||||
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
||||
g.Provenance);
|
||||
}
|
||||
}
|
||||
|
||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ImportDocxEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||
|
||||
// POST /import/docx/dry-run — analyse without writing
|
||||
group.MapPost("/dry-run",
|
||||
async Task<Ok<ImportDocxReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||
|
||||
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||
group.MapPost("/execute",
|
||||
async Task<Ok<ImportDocxReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Import.Rpro3;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
@@ -8,6 +11,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
||||
/// against Julian's DB under god-supervision after he approves the dry-run.
|
||||
///
|
||||
/// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"):
|
||||
/// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren;
|
||||
/// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen.
|
||||
/// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs).
|
||||
/// </summary>
|
||||
public static class ImportEndpoints
|
||||
{
|
||||
@@ -29,7 +37,116 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
IReadOnlySet<string>? photoFiles = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoFiles = ListZipImageNames(imgSeekable);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.AnalyzeAsync(data, images is not null, photoFiles);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
group.MapPost("/rpro3/execute", async Task<Results<Ok<Rpro3ExecuteResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
Dictionary<string, string>? photoPaths = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoPaths = ExtractZipImages(imgSeekable, workDir);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren.
|
||||
private static async Task<MemoryStream> ToSeekable(Stream s)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
await s.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
||||
|
||||
private static IReadOnlySet<string> ListZipImageNames(Stream zip)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
set.Add(e.Name);
|
||||
return set;
|
||||
}
|
||||
|
||||
// Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad.
|
||||
private static Dictionary<string, string> ExtractZipImages(Stream zip, string workDir)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imgDir = Path.Combine(workDir, "bilder");
|
||||
Directory.CreateDirectory(imgDir);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
continue;
|
||||
var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name));
|
||||
try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; }
|
||||
catch { /* defektes Archiv-Entry überspringen */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void TryDeleteDir(string dir)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
Apply(l, input);
|
||||
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
l.Name = input.Name;
|
||||
l.Date = input.Date;
|
||||
Apply(l, input);
|
||||
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static void Apply(Litter l, LitterInput i)
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
{
|
||||
if (deaths is null) return null;
|
||||
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
|
||||
if (totalBorn is not null && deaths > totalBorn)
|
||||
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using GerbilManagerWebAPI.Names;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||
///
|
||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class NamesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/names/suggest", async (
|
||||
string? letter,
|
||||
string? gender,
|
||||
string? usages,
|
||||
int count,
|
||||
NameSuggestionService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("Names");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||
app.MapGet("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||
.Where(p => p.EnclosureId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new EnclosurePhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnclosureId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.EnclosurePhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.EnclosurePhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapGet("/photos/files/{fileName}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
|
||||
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
}
|
||||
}
|
||||
|
||||
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal file
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
|
||||
/// Tokens im Text: <c>tier:<code></c>, <c>kontakt:<code></c>,
|
||||
/// <c>wurf:<code></c>, <c>gehege:<code></c> — <c><code></c> ist ein
|
||||
/// GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Frontend rendert daraus
|
||||
/// Name + interner Link.
|
||||
///
|
||||
/// POST /refs/resolve -> [{ type, code, id|null, name|null }]
|
||||
///
|
||||
/// DB-AGNOSTISCH (Tests: SQLite in-memory, Prod: Postgres): pro Typ eine schlanke
|
||||
/// {Id,Name}-Projektion laden und im Speicher per Präfix matchen — KEIN ::text-Cast
|
||||
/// o.ä. Mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht raten).
|
||||
/// </summary>
|
||||
public static class RefsEndpoints
|
||||
{
|
||||
/// <summary>Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match.</summary>
|
||||
private readonly record struct NamedRow(Guid Id, string Name);
|
||||
|
||||
public static IEndpointRouteBuilder MapRefsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/refs").WithTags("Refs");
|
||||
|
||||
group.MapPost("/resolve", async Task<Ok<List<RefResolution>>> (
|
||||
ResolveRefsInput input, ApplicationContext db) =>
|
||||
{
|
||||
var refs = input.Refs ?? new List<RefRequest>();
|
||||
|
||||
// Welche Entitätstypen werden überhaupt angefragt? Nur diese Tabellen laden.
|
||||
var wantedTypes = refs
|
||||
.Select(r => NormalizeType(r.Type))
|
||||
.Where(t => t is not null)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
// Pro Typ einmalig eine leichte {Id,Name}-Projektion laden (DB-agnostisch:
|
||||
// reine Spaltenauswahl, kein ::text-Cast — läuft auf SQLite wie auf Postgres).
|
||||
var byType = new Dictionary<string, List<NamedRow>>();
|
||||
if (wantedTypes.Contains("tier"))
|
||||
byType["tier"] = await db.Gerbils.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("kontakt"))
|
||||
byType["kontakt"] = await db.Contacts.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("wurf"))
|
||||
byType["wurf"] = await db.Litters.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("gehege"))
|
||||
byType["gehege"] = await db.Enclosures.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
|
||||
var results = new List<RefResolution>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
var type = NormalizeType(r.Type);
|
||||
var prefix = NormalizeCode(r.Code);
|
||||
|
||||
if (type is null || prefix.Length < 6 || !byType.TryGetValue(type, out var rows))
|
||||
{
|
||||
results.Add(new RefResolution(r.Type, r.Code, null, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Präfix-Match auf der bindestrich-losen, klein geschriebenen GUID ("N"-Format).
|
||||
var matches = rows
|
||||
.Where(x => x.Id.ToString("N").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
// Genau ein Treffer => auflösen; 0 (unbekannt) oder >1 (mehrdeutig) => null.
|
||||
if (matches.Count == 1)
|
||||
results.Add(new RefResolution(r.Type, r.Code, matches[0].Id, matches[0].Name));
|
||||
else
|
||||
results.Add(new RefResolution(r.Type, r.Code, null, null));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(results);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null.</summary>
|
||||
private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"tier" => "tier",
|
||||
"kontakt" => "kontakt",
|
||||
"wurf" => "wurf",
|
||||
"gehege" => "gehege",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung).</summary>
|
||||
private static string NormalizeCode(string? code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return string.Empty;
|
||||
var chars = code.Trim().ToLowerInvariant()
|
||||
.Where(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (RPRO3 getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// GET /returns[?gerbilId=] -> Liste, neueste zuerst (optional nach Tier gefiltert).
|
||||
/// POST /returns -> Rücknahme erfassen, 201.
|
||||
/// PUT /returns/{id} -> Rücknahme aktualisieren, 200.
|
||||
/// DELETE /returns/{id} -> Rücknahme löschen, 204.
|
||||
/// Entkoppelt von Gerbils/Contacts (lose nullable Guid-Spalten, kein FK), daher
|
||||
/// überleben Zeilen den Import-Re-Ingest-Wipe — wie Feedback.
|
||||
/// </summary>
|
||||
public static class ReturnRecordEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReturnRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/returns").WithTags("Returns");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
var query = db.ReturnRecords.AsNoTracking();
|
||||
if (gerbilId is { } gid)
|
||||
query = query.Where(r => r.GerbilId == gid);
|
||||
// In-Memory sortieren: SQLite (Test-Host) kann nicht nach DateTimeOffset ORDER BY-en.
|
||||
var rows = await query.ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(r => r.ReturnDate ?? DateTime.MinValue)
|
||||
.ThenByDescending(r => r.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<ReturnRecordDto>, BadRequest<string>>> (
|
||||
ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
var entity = new ReturnRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
GerbilName = Trimmed(input.GerbilName),
|
||||
ReturnDate = input.ReturnDate,
|
||||
ReturnPrice = input.ReturnPrice,
|
||||
OriginalPrice = input.OriginalPrice,
|
||||
OriginalSaleDate = input.OriginalSaleDate,
|
||||
FromContactId = input.FromContactId,
|
||||
FromContactName = Trimmed(input.FromContactName),
|
||||
Note = Trimmed(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ReturnRecords.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/returns/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<ReturnRecordDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
entity.GerbilId = input.GerbilId;
|
||||
entity.GerbilName = Trimmed(input.GerbilName);
|
||||
entity.ReturnDate = input.ReturnDate;
|
||||
entity.ReturnPrice = input.ReturnPrice;
|
||||
entity.OriginalPrice = input.OriginalPrice;
|
||||
entity.OriginalSaleDate = input.OriginalSaleDate;
|
||||
entity.FromContactId = input.FromContactId;
|
||||
entity.FromContactName = Trimmed(input.FromContactName);
|
||||
entity.Note = Trimmed(input.Note);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
db.ReturnRecords.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? Trimmed(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static ReturnRecordDto ToDto(ReturnRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.GerbilName, r.ReturnDate, r.ReturnPrice, r.OriginalPrice,
|
||||
r.OriginalSaleDate, r.FromContactId, r.FromContactName, r.Note, r.CreatedAt);
|
||||
}
|
||||
}
|
||||
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal file
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
|
||||
/// pipeline — verfügbar → reserviert → abgegeben.
|
||||
/// GET /reservations -> list all, newest-updated first.
|
||||
/// GET /reservations?gerbilId= -> the (single) reservation status for one animal, if any.
|
||||
/// POST /reservations -> create/establish a status, returns 201.
|
||||
/// PUT /reservations/{id} -> change status / reservation details. 404 on missing id.
|
||||
/// DELETE /reservations/{id} -> remove. 404 on missing id.
|
||||
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
|
||||
/// the import re-ingest wipe — like Feedback.
|
||||
/// </summary>
|
||||
public static class SaleReservationEndpoints
|
||||
{
|
||||
private static readonly string[] AllowedStatuses = { "verfuegbar", "reserviert", "abgegeben" };
|
||||
|
||||
/// <summary>Normalize a status string to one of the three canonical values; default "verfuegbar".</summary>
|
||||
private static string NormalizeStatus(string? raw)
|
||||
{
|
||||
var s = raw?.Trim().ToLowerInvariant();
|
||||
return AllowedStatuses.Contains(s) ? s! : "verfuegbar";
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapSaleReservationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/reservations").WithTags("Reservations");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.SaleReservations.AsNoTracking().ToListAsync();
|
||||
var filtered = gerbilId is { } gid
|
||||
? rows.Where(r => r.GerbilId == gid)
|
||||
: rows;
|
||||
return TypedResults.Ok(filtered
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<SaleReservationDto>, BadRequest<string>>> (
|
||||
SaleReservationInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (input.GerbilId == Guid.Empty)
|
||||
return TypedResults.BadRequest("GerbilId ist erforderlich.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entity = new SaleReservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
GerbilName = Trim(input.GerbilName),
|
||||
Status = NormalizeStatus(input.Status),
|
||||
ReservedForContactId = input.ReservedForContactId,
|
||||
ContactName = Trim(input.ContactName),
|
||||
AppointmentDate = input.AppointmentDate,
|
||||
Price = input.Price,
|
||||
Note = Trim(input.Note),
|
||||
HandedOverDate = input.HandedOverDate,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.SaleReservations.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/reservations/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<SaleReservationDto>, NotFound>> (
|
||||
Guid id, SaleReservationUpdate input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (input.Status is not null)
|
||||
entity.Status = NormalizeStatus(input.Status);
|
||||
if (input.GerbilName is not null)
|
||||
entity.GerbilName = Trim(input.GerbilName);
|
||||
if (input.ContactName is not null)
|
||||
entity.ContactName = Trim(input.ContactName);
|
||||
if (input.Note is not null)
|
||||
entity.Note = Trim(input.Note);
|
||||
|
||||
// Value-type/nullable fields are always applied from the payload (a null clears them).
|
||||
entity.ReservedForContactId = input.ReservedForContactId;
|
||||
entity.AppointmentDate = input.AppointmentDate;
|
||||
entity.Price = input.Price;
|
||||
entity.HandedOverDate = input.HandedOverDate;
|
||||
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.SaleReservations.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? Trim(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static SaleReservationDto ToDto(SaleReservation r) =>
|
||||
new(r.Id, r.GerbilId, r.GerbilName, r.Status, r.ReservedForContactId, r.ContactName,
|
||||
r.AppointmentDate, r.Price, r.Note, r.HandedOverDate, r.CreatedAt, r.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
s.NameSuffix = input.NameSuffix ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal file
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WAITLIST (RennmausPro nachfrage_tb): a standing list of interested parties waiting
|
||||
/// for a future animal matching their wish criteria (colour, gender).
|
||||
/// GET /waiting-list -> list entries, newest request first
|
||||
/// POST /waiting-list -> create an entry (returns 201)
|
||||
/// PUT /waiting-list/{id} -> update / change status
|
||||
/// DELETE /waiting-list/{id} -> remove an entry
|
||||
/// Decoupled from contacts (loose nullable ContactId, no FK), so rows survive the
|
||||
/// import re-ingest wipe — exactly like Feedback.
|
||||
/// </summary>
|
||||
public static class WaitingListEndpoints
|
||||
{
|
||||
/// <summary>Allowed workflow statuses (frontend contract).</summary>
|
||||
private static readonly string[] AllowedStatuses = { "offen", "erfuellt", "storniert" };
|
||||
private const string DefaultStatus = "offen";
|
||||
|
||||
public static IEndpointRouteBuilder MapWaitingListEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/waiting-list").WithTags("WaitingList");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
|
||||
// and RequestedAt is nullable — sort entries with a date first, newest first.
|
||||
var rows = await db.WaitingListEntries.AsNoTracking().ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(e => e.RequestedAt ?? DateTime.MinValue)
|
||||
.ThenByDescending(e => e.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.WaitingListEntries.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
|
||||
return entity is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<WaitingListDto>, BadRequest<string>>> (
|
||||
WaitingListInput input, ApplicationContext db) =>
|
||||
{
|
||||
var status = NormalizeStatus(input.Status);
|
||||
if (status is null)
|
||||
return TypedResults.BadRequest("Ungültiger Status.");
|
||||
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
|
||||
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
|
||||
|
||||
var entity = new WaitingListEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContactId = input.ContactId,
|
||||
ContactName = Trim(input.ContactName),
|
||||
WishColor = Trim(input.WishColor),
|
||||
WishGender = Trim(input.WishGender),
|
||||
RequestedAt = input.RequestedAt,
|
||||
Status = status,
|
||||
Note = Trim(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.WaitingListEntries.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/waiting-list/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, WaitingListInput input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
|
||||
var status = NormalizeStatus(input.Status);
|
||||
if (status is null)
|
||||
return TypedResults.BadRequest("Ungültiger Status.");
|
||||
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
|
||||
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
|
||||
|
||||
entity.ContactId = input.ContactId;
|
||||
entity.ContactName = Trim(input.ContactName);
|
||||
entity.WishColor = Trim(input.WishColor);
|
||||
entity.WishGender = Trim(input.WishGender);
|
||||
entity.RequestedAt = input.RequestedAt;
|
||||
entity.Status = status;
|
||||
entity.Note = Trim(input.Note);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
db.WaitingListEntries.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Empty/whitespace status defaults to "offen"; unknown values are rejected (null).</summary>
|
||||
private static string? NormalizeStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status)) return DefaultStatus;
|
||||
var trimmed = status.Trim();
|
||||
return AllowedStatuses.Contains(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
private static string? Trim(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static WaitingListDto ToDto(WaitingListEntry e) =>
|
||||
new(e.Id, e.ContactId, e.ContactName, e.WishColor, e.WishGender, e.RequestedAt, e.Status, e.Note, e.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Active] = "Aktiv",
|
||||
[GerbilStatus.Breeding] = "Zucht",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
[GerbilStatus.ForSale] = "Abzugeben",
|
||||
[GerbilStatus.Pet] = "Liebhaber",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
|
||||
///
|
||||
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||
/// Match order per docx row:
|
||||
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||
///
|
||||
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
|
||||
/// -N ordinal suffix disambiguates same-name siblings in a litter).
|
||||
///
|
||||
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||
///
|
||||
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||
/// to re-run on transient-failure retry.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportDocxService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _sourceDir;
|
||||
|
||||
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||
: this(db,
|
||||
config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||
{ }
|
||||
|
||||
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||
{
|
||||
_db = db;
|
||||
_sourceDir = sourceDir;
|
||||
}
|
||||
|
||||
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
|
||||
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||
|
||||
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
|
||||
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
|
||||
|
||||
int docxLitCount = docxLitters.Count;
|
||||
int docxAnimCount = docxAnimals.Count;
|
||||
int pdfLitCount = pdfLitters.Count;
|
||||
int pdfAnimCount = pdfAnimals.Count;
|
||||
|
||||
docxLitters.AddRange(pdfLitters);
|
||||
docxAnimals.AddRange(pdfAnimals);
|
||||
|
||||
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||
{
|
||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
|
||||
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
|
||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||
}
|
||||
|
||||
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
|
||||
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
|
||||
// Date-only lookup with uniqueness guard avoids false links (only link when
|
||||
// exactly one DB litter falls within ±5 days of the docx litter DOB).
|
||||
var littersInDb = await _db.Litters
|
||||
.Select(l => new { l.Id, l.Date })
|
||||
.ToListAsync();
|
||||
var littersByDayNumber = littersInDb
|
||||
.Where(l => l.Date.HasValue)
|
||||
.GroupBy(l => l.Date!.Value.DayNumber)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||
var gerbilsInDb = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.ExternalRef })
|
||||
.ToListAsync();
|
||||
var gerbilByKey = gerbilsInDb
|
||||
.Where(g => g.DateOfBirth is not null)
|
||||
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
|
||||
var docxExternalRefs = gerbilsInDb
|
||||
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
|
||||
.ToDictionary(g => g.ExternalRef!,
|
||||
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
|
||||
|
||||
// Contact lookup: normalized name → existing Contact.Id
|
||||
var contactsInDb = await _db.Contacts
|
||||
.Select(c => new { c.Id, c.Name })
|
||||
.ToListAsync();
|
||||
var contactByNorm = contactsInDb
|
||||
.GroupBy(c => NormalizeName(c.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// Snapshot of DB contacts before any writes.
|
||||
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||
// but would remain in the in-memory dict without this reset).
|
||||
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||
|
||||
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||
var colorVarietyByName = (await _db.ColorVarieties
|
||||
.Select(cv => new { cv.Id, cv.Name })
|
||||
.ToListAsync())
|
||||
.GroupBy(cv => NormalizeName(cv.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||
|
||||
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||
var externalRefOrdinals = new Dictionary<string, int>();
|
||||
|
||||
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||
var batchRefs = new HashSet<string>();
|
||||
|
||||
// Inner loop — shared by dry-run and execute paths.
|
||||
// All local variables above are captured by reference (C# closure), so the strategy
|
||||
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||
async Task RunLoopAsync()
|
||||
{
|
||||
foreach (var da in docxAnimals)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||
|
||||
// Collision-free ExternalRef: ordinal suffix for same-name siblings
|
||||
var baseRef = DocxExternalRefBase(da);
|
||||
externalRefOrdinals.TryGetValue(baseRef, out var ord);
|
||||
ord++;
|
||||
externalRefOrdinals[baseRef] = ord;
|
||||
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
|
||||
|
||||
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
|
||||
Guid? litterId = null;
|
||||
if (!string.IsNullOrWhiteSpace(da.LitterDob))
|
||||
{
|
||||
var litterDob = ParseDate(da.LitterDob);
|
||||
if (litterDob is not null)
|
||||
{
|
||||
var candidates = new List<Guid>();
|
||||
for (int delta = -5; delta <= 5; delta++)
|
||||
{
|
||||
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
|
||||
candidates.AddRange(cl.Select(l => l.Id));
|
||||
}
|
||||
if (candidates.Count == 1)
|
||||
litterId = candidates[0];
|
||||
// If 0 or >1 candidates: no link (avoid false links)
|
||||
}
|
||||
}
|
||||
|
||||
// Animal DOB = litter birth date (docx has no per-animal DOB)
|
||||
var animalDob = litterId is not null
|
||||
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||
: ParseDate(da.LitterDob);
|
||||
|
||||
if (animalDob is null) { skipped++; continue; }
|
||||
|
||||
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||
var deathDate = ParseDate(da.DeathDate);
|
||||
|
||||
// Resolve receiver contact (lookup-or-create; shared by all paths)
|
||||
Guid? receiverId = null;
|
||||
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||
{
|
||||
var normOwner = NormalizeName(da.Owner);
|
||||
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
|
||||
{
|
||||
receiverId = existingContactId;
|
||||
ownerLinked++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ownerCreated++;
|
||||
if (execute)
|
||||
{
|
||||
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||
_db.Contacts.Add(newContact);
|
||||
await _db.SaveChangesAsync(); // flush within the outer tx
|
||||
receiverId = newContact.Id;
|
||||
contactByNorm[normOwner] = receiverId.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
|
||||
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
|
||||
{
|
||||
bool willLink = litterId is not null && prevSnap.LitterId is null;
|
||||
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
|
||||
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
|
||||
|
||||
if (willLink) litterLinked++;
|
||||
if (willHome) goHomeFilled++;
|
||||
if (willDeath) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||
if (row is null) continue;
|
||||
if (willLink) row.LitterId = litterId;
|
||||
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||
if (willHome) row.GoHomeDate = goHomeDate;
|
||||
if (willDeath)
|
||||
{
|
||||
row.DateOfDeath = deathDate;
|
||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||
row.CauseOfDeath = da.DeathCause.Trim();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
|
||||
var nameKey = NameDobKey(da.Name, animalDob.Value);
|
||||
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
|
||||
{
|
||||
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||
?? gerbilCands.First();
|
||||
|
||||
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
|
||||
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||
|
||||
if (willLink) litterLinked++;
|
||||
if (willHome) goHomeFilled++;
|
||||
if (willDeath) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||
if (row is null) continue;
|
||||
if (willLink) row.LitterId = litterId;
|
||||
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||
if (willHome) row.GoHomeDate = goHomeDate;
|
||||
if (willDeath)
|
||||
{
|
||||
row.DateOfDeath = deathDate;
|
||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||
row.CauseOfDeath = da.DeathCause.Trim();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
|
||||
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
|
||||
|
||||
animalsCreated++;
|
||||
if (litterId is not null) litterLinked++;
|
||||
if (goHomeDate is not null) goHomeFilled++;
|
||||
if (deathDate is not null) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
|
||||
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = da.Name.Trim(),
|
||||
DateOfBirth = animalDob,
|
||||
Gender = ParseGender(da.Gender),
|
||||
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
LitterId = litterId,
|
||||
ReceiverContactId = receiverId,
|
||||
GoHomeDate = goHomeDate,
|
||||
DateOfDeath = deathDate,
|
||||
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||
ColorVarietyId = cvId == default ? null : cvId,
|
||||
OriginBreeder = "Zucht der kleinen Chaoten",
|
||||
IsResident = false,
|
||||
ImportSource = "docx",
|
||||
ExternalRef = externalRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
// Dry-run: just count, no writes, no transaction needed.
|
||||
await RunLoopAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||
// The lambda resets all mutable state at the top so retries start clean.
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Reset mutable state — idempotent on strategy retry
|
||||
_db.ChangeTracker.Clear();
|
||||
externalRefOrdinals.Clear();
|
||||
batchRefs.Clear();
|
||||
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
await RunLoopAsync();
|
||||
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
|
||||
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||
|
||||
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
|
||||
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
{
|
||||
var path = Path.Combine(_sourceDir, file);
|
||||
if (!File.Exists(path)) return default;
|
||||
using var fs = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||
if (!m.Success) return null;
|
||||
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||
int y = int.Parse(m.Groups[3].Value);
|
||||
if (y < 100) y += 2000;
|
||||
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||
}
|
||||
|
||||
private static string NameDobKey(string name, DateOnly dob)
|
||||
{
|
||||
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||
return $"{n}|{dob:yyyy-MM-dd}";
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name)
|
||||
{
|
||||
var n = (name ?? "").ToLowerInvariant();
|
||||
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
|
||||
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
|
||||
/// </summary>
|
||||
internal static string DocxExternalRefBase(DocxAnimal da)
|
||||
{
|
||||
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
|
||||
var name = System.Text.RegularExpressions.Regex.Replace(
|
||||
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||
return $"docx-{ws}-{name}-{da.LitterDob}";
|
||||
}
|
||||
|
||||
private static Gender ParseGender(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
|
||||
var lower = s.ToLowerInvariant();
|
||||
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
|
||||
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
|
||||
return Gender.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Source shapes (from extract_docx.py output) ----
|
||||
|
||||
public sealed class DocxLitter
|
||||
{
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string MotherName { get; set; } = "";
|
||||
public string FatherName { get; set; } = "";
|
||||
public string WsCode { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class DocxAnimal
|
||||
{
|
||||
public string WsCode { get; set; } = "";
|
||||
public string LitterDob { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Farbschlag { get; set; } = "";
|
||||
public string Gender { get; set; } = "";
|
||||
public string Owner { get; set; } = "";
|
||||
public string AbgabeDate { get; set; } = "";
|
||||
public string AbgabeWeight { get; set; } = "";
|
||||
public string DeathDate { get; set; } = "";
|
||||
public string DeathCause { get; set; } = "";
|
||||
public string PartnerName { get; set; } = "";
|
||||
public string PartnerDob { get; set; } = "";
|
||||
}
|
||||
|
||||
// ---- Report ----
|
||||
|
||||
public sealed record ImportDocxReport(
|
||||
bool Executed,
|
||||
int Created,
|
||||
int LitterLinked,
|
||||
int OwnerLinked,
|
||||
int GoHomeFilled,
|
||||
int DeathFilled,
|
||||
int ContactsCreated,
|
||||
int Skipped,
|
||||
IReadOnlyList<string> Notes);
|
||||
}
|
||||
@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
@@ -98,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
int ConflictsResolvedByDecision = 0,
|
||||
int FarbschlagDerivedFromGenotype = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -71,17 +72,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
else if (conf == "niedrig") dateOnly++;
|
||||
}
|
||||
|
||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
||||
// existing rows (idempotency).
|
||||
var existingGerbilExtRefs = await _db.Gerbils
|
||||
.Where(g => g.ExternalRef != null)
|
||||
.Select(g => g.ExternalRef!).ToListAsync();
|
||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||
|
||||
var existingLitterKeys = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterKeys
|
||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterData
|
||||
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
|
||||
var existingLitterExtRefSet = existingLitterData
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
|
||||
// colour-variety name -> id (case-insensitive)
|
||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||
@@ -94,10 +99,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||
|
||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||
// so skip them early — they can never be created or linked to animals.
|
||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
@@ -107,7 +112,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||
{ littersExisting++; continue; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
@@ -122,6 +130,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8)
|
||||
@@ -137,31 +146,71 @@ namespace GerbilManagerWebAPI.Import
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||
|
||||
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||
var varietiesWithGeno = await _db.ColorVarieties
|
||||
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
int fbDerivedFromGenotype = 0;
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
||||
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||
Guid gid;
|
||||
if (exists)
|
||||
{
|
||||
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||
{
|
||||
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||
else
|
||||
{
|
||||
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else gid = Guid.NewGuid();
|
||||
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
wurfLitterId = lid;
|
||||
|
||||
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||
{
|
||||
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||
// would match any catalog entry via wildcards and produce false positives.
|
||||
var composed = ComposeGenotype(a.Genotype);
|
||||
if (!composed.Contains("??"))
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||
}
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
@@ -169,7 +218,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
@@ -240,10 +290,15 @@ namespace GerbilManagerWebAPI.Import
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
||||
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
|
||||
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
|
||||
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
|
||||
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
|
||||
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
|
||||
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
|
||||
// which would cause EF's topo-sort to throw "circular dependency detected".
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
@@ -252,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
@@ -267,13 +322,17 @@ namespace GerbilManagerWebAPI.Import
|
||||
derivedLitters--;
|
||||
continue;
|
||||
}
|
||||
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
|
||||
// in this same batch. Setting them now causes EF circular dependency
|
||||
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
|
||||
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
FatherId = sl.Father,
|
||||
MotherId = sl.Mother,
|
||||
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||
Date = sl.Date,
|
||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
@@ -332,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
@@ -361,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_db.Gerbils.Add(importedGerbil);
|
||||
}
|
||||
|
||||
// photos
|
||||
@@ -388,6 +449,20 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
|
||||
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
|
||||
if (execute && synthLitterPendingParents.Count > 0)
|
||||
{
|
||||
foreach (var (litId, (f, m)) in synthLitterPendingParents)
|
||||
{
|
||||
var row = await _db.Litters.FindAsync(litId);
|
||||
if (row is null) continue;
|
||||
if (f is not null) row.FatherId = f;
|
||||
if (m is not null) row.MotherId = m;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||
if (execute)
|
||||
{
|
||||
@@ -460,6 +535,78 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||
|
||||
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||
{
|
||||
var noColor = await _db.Gerbils
|
||||
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||
.Select(g => new { g.Id, g.Genotype })
|
||||
.ToListAsync();
|
||||
foreach (var g in noColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||
Guid? derivedVid = null;
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||
{ derivedVid = v.Id; break; }
|
||||
if (derivedVid is null) continue;
|
||||
fbDerivedFromGenotype++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||
}
|
||||
}
|
||||
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||
int herkunftBackfilled = 0;
|
||||
{
|
||||
var needsHerkunft = await _db.Gerbils
|
||||
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||
.Select(g => new { g.Id, g.LitterId })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var g in needsHerkunft)
|
||||
{
|
||||
herkunftBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||
string? derived = null;
|
||||
if (g.LitterId is not null)
|
||||
{
|
||||
var parentIds = await _db.Litters
|
||||
.Where(l => l.Id == g.LitterId)
|
||||
.Select(l => new { l.FatherId, l.MotherId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (parentIds?.FatherId is Guid fid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
}
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.OriginBreeder is null)
|
||||
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||
}
|
||||
}
|
||||
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (littersWithoutDate > 0)
|
||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
@@ -468,10 +615,16 @@ namespace GerbilManagerWebAPI.Import
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
if (herkunftBackfilled > 0)
|
||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||
if (farbschlagWouldRebackfill > 0)
|
||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (fbDerivedFromGenotype > 0)
|
||||
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
@@ -480,11 +633,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
@@ -517,6 +671,23 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||
|
||||
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||
{
|
||||
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (a.Length < 8 || c.Length < 8) return false;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if (a[i] == "??" || c[i] == "??") continue;
|
||||
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
@@ -574,7 +745,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||
Guid? CurrentColorVarietyId = null);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
|
||||
326
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
326
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public sealed class IngestResolvedService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_sourceDir = config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||
_photoRoot = config["Photos:RootPath"]
|
||||
?? Path.Combine(contentRoot, "photo-storage");
|
||||
}
|
||||
|
||||
public async Task<string> RunAsync()
|
||||
{
|
||||
if (!File.Exists(_resolvedJsonPath))
|
||||
{
|
||||
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
|
||||
}
|
||||
|
||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return "Error: Failed to deserialize resolved_import.json.";
|
||||
}
|
||||
|
||||
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
// Break circular references first
|
||||
foreach (var l in _db.Litters)
|
||||
{
|
||||
l.FatherId = null;
|
||||
l.MotherId = null;
|
||||
}
|
||||
foreach (var g in _db.Gerbils)
|
||||
{
|
||||
g.LitterId = null;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Break circular references first using ExecuteUpdateAsync
|
||||
await _db.Litters.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(l => l.FatherId, (Guid?)null)
|
||||
.SetProperty(l => l.MotherId, (Guid?)null));
|
||||
|
||||
await _db.Gerbils.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(g => g.LitterId, (Guid?)null));
|
||||
|
||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
var addedContactIds = new HashSet<Guid>();
|
||||
int contactsAdded = 0;
|
||||
int contactsUpdated = 0;
|
||||
foreach (var c in data.Contacts)
|
||||
{
|
||||
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||
{
|
||||
existingContact.Name = c.Name;
|
||||
existingContact.Email = c.Email;
|
||||
existingContact.Phone = c.Phone;
|
||||
existingContact.Address = c.Address;
|
||||
existingContact.Notes = c.Notes;
|
||||
existingContact.IsBreeder = c.IsBreeder;
|
||||
existingContact.IsReceiver = c.IsReceiver;
|
||||
existingContact.NameSuffix = c.NameSuffix;
|
||||
existingContact.Provenance = c.Provenance;
|
||||
contactsUpdated++;
|
||||
}
|
||||
else if (addedContactIds.Add(c.Id))
|
||||
{
|
||||
_db.Contacts.Add(c);
|
||||
contactsAdded++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
||||
var littersToInsert = new List<Litter>();
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
littersToInsert.Add(new Litter
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Date = l.Date,
|
||||
TotalBorn = l.TotalBorn,
|
||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
||||
FatherId = null, // Set in Pass 2
|
||||
MotherId = null, // Set in Pass 2
|
||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
||||
Notes = l.Notes,
|
||||
PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef,
|
||||
LitterLetter = l.LitterLetter,
|
||||
Provenance = l.Provenance
|
||||
});
|
||||
}
|
||||
_db.Litters.AddRange(littersToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
||||
var gerbilsToInsert = new List<Gerbil>();
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Gender = g.Gender,
|
||||
Status = g.Status,
|
||||
LitterId = null, // Set in Pass 2
|
||||
OriginContactId = g.OriginContactId,
|
||||
ReceiverContactId = g.ReceiverContactId,
|
||||
EnclosureId = g.EnclosureId,
|
||||
ColorVarietyId = g.ColorVarietyId,
|
||||
DateOfBirth = g.DateOfBirth,
|
||||
DateOfDeath = g.DateOfDeath,
|
||||
CauseOfDeath = g.CauseOfDeath,
|
||||
GoHomeDate = g.GoHomeDate,
|
||||
Genotype = g.Genotype,
|
||||
Notes = g.Notes,
|
||||
ImportSource = g.ImportSource,
|
||||
ExternalRef = g.ExternalRef,
|
||||
RawImportData = g.RawImportData,
|
||||
Provenance = g.Provenance,
|
||||
OriginBreeder = g.OriginBreeder,
|
||||
NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits,
|
||||
CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf,
|
||||
IsResident = g.IsResident
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
gerbilsToInsert.Add(importedGerbil);
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: Set the actual foreign key relationships
|
||||
// Update Litters with their parent animal IDs
|
||||
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
|
||||
{
|
||||
dbLitter.FatherId = l.FatherId;
|
||||
dbLitter.MotherId = l.MotherId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Gerbils with their actual LitterId
|
||||
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||
{
|
||||
dbGerbil.LitterId = g.LitterId;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var p in data.GerbilPhotos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(p.SourcePath))
|
||||
{
|
||||
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var src = Path.Combine(_sourceDir, relPath);
|
||||
if (File.Exists(src))
|
||||
{
|
||||
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||
try
|
||||
{
|
||||
File.Copy(src, dest, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: Source photo file not found at {src}");
|
||||
}
|
||||
}
|
||||
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = p.Id,
|
||||
GerbilId = p.GerbilId,
|
||||
FileName = p.FileName,
|
||||
Caption = p.Caption,
|
||||
SortOrder = p.SortOrder,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
|
||||
// The table was wiped above, so these are recreated on every ingest
|
||||
// (they survive only by being part of the payload). FK-safe: contacts
|
||||
// and gerbils are already inserted. We skip rows whose buyer contact
|
||||
// is unknown and skip individual animal links to unknown gerbils
|
||||
// (defensive — the resolver should never emit those).
|
||||
// No .docx is copied: the source files live on the network share and
|
||||
// are not staged into Contracts:RootPath, so the Word download 404s
|
||||
// gracefully (the endpoint already returns NotFound) while the PDF is
|
||||
// regenerated from the data. The DTO exposes HasFile=false for these
|
||||
// so the frontend hides the Word button.
|
||||
int contractsAdded = 0;
|
||||
int contractAnimalsSkipped = 0;
|
||||
if (data.SaleContracts.Count > 0)
|
||||
{
|
||||
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||
var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||
var seenContractIds = new HashSet<Guid>();
|
||||
foreach (var sc in data.SaleContracts)
|
||||
{
|
||||
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
|
||||
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
|
||||
|
||||
var animals = new List<SaleContractAnimal>();
|
||||
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
|
||||
{
|
||||
if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; }
|
||||
animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid });
|
||||
}
|
||||
|
||||
_db.SaleContracts.Add(new SaleContract
|
||||
{
|
||||
Id = sc.Id,
|
||||
ContactId = sc.ContactId,
|
||||
Price = sc.Price,
|
||||
HandoverDate = sc.HandoverDate,
|
||||
ContractDate = sc.ContractDate,
|
||||
FileName = sc.FileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = animals,
|
||||
});
|
||||
contractsAdded++;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
|
||||
}
|
||||
}
|
||||
|
||||
public class ResolvedImportData
|
||||
{
|
||||
public List<Contact> Contacts { get; set; } = new();
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { get; set; } = new();
|
||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||
public List<ResolvedSaleContract> SaleContracts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Eine importierte Abgabevertrag-Zeile aus resolved_import.json.
|
||||
/// <c>Animals</c> sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim
|
||||
/// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der
|
||||
/// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt.</summary>
|
||||
public class ResolvedSaleContract
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ContactId { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public DateOnly HandoverDate { get; set; }
|
||||
public DateOnly ContractDate { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public List<Guid> Animals { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResolvedPhoto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[JsonPropertyName("_source_path")]
|
||||
public string? SourcePath { get; set; }
|
||||
}
|
||||
}
|
||||
197
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
197
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Port von tools/import/compare_rpro3.py `dedup()`. RennmausPro erkennt beim Import vorhandene
|
||||
/// Tiere NICHT → dasselbe Tier (v. a. die 7427 externen) kommt mehrfach vor.
|
||||
///
|
||||
/// Union-Find INNERHALB gleicher (normalisierter) Namen:
|
||||
/// merge(a,b) wenn DOB/Farbe/Herkunft kompatibel (gleich ODER eine Seite unbekannt)
|
||||
/// UND mindestens ein bekanntes Feld positiv übereinstimmt. Konflikt in einem bekannten
|
||||
/// Feld → NICHT mergen (unsicher → mehrdeutig).
|
||||
/// Platzhalter-Namen ("unbekannt","-","","?" …) werden nie dedupt.
|
||||
/// </summary>
|
||||
public static class Rpro3Dedup
|
||||
{
|
||||
private static readonly HashSet<string> PlaceholderNames = new(StringComparer.Ordinal)
|
||||
{ "", "-", "n", "unbekannt", "unbekannt?", "?", "nn", "n.n.", "na", "namenlos", "." };
|
||||
|
||||
private static readonly HashSet<string> UnknownValues = new(StringComparer.Ordinal)
|
||||
{ "", "unbekannt", "unbek.", "unbek", "unbekannte zucht", "?", "n.n.",
|
||||
"unbekannter farbschlag", "keine angabe", "k.a.", "na", "unbekannte farbe" };
|
||||
|
||||
public sealed class DedupResult
|
||||
{
|
||||
/// <summary>Cluster (mehr als ein Datensatz) → die zusammengelegten Tiere. Key = Wurzel-Rid.</summary>
|
||||
public Dictionary<string, List<Rpro3Animal>> MergeClusters { get; } = new();
|
||||
/// <summary>Repräsentant-Rid je Cluster (auch Singletons) → Cluster-Wurzel.</summary>
|
||||
public Dictionary<string, string> RidToRoot { get; } = new();
|
||||
/// <summary>Mehrdeutige Namen: Name → Varianten (manuelle Entscheidung nötig).</summary>
|
||||
public List<AmbiguousName> Ambiguous { get; } = new();
|
||||
/// <summary>Platzhalter-Datensätze (nicht dedupbar) gruppiert nach Name.</summary>
|
||||
public List<Rpro3Animal> Placeholders { get; } = new();
|
||||
public int CollapsedRecords { get; set; } // Summe der Datensätze in Merge-Clustern
|
||||
public int DuplicatesRemoved { get; set; } // CollapsedRecords - Anzahl Cluster
|
||||
}
|
||||
|
||||
public sealed class AmbiguousName
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public List<Variant> Variants { get; } = new();
|
||||
public int BareCount { get; set; } // merkmalslose Varianten (nur Name)
|
||||
}
|
||||
|
||||
public sealed class Variant
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public List<string> Dob { get; } = new();
|
||||
public List<string> Farbe { get; } = new();
|
||||
public List<string> Origin { get; } = new();
|
||||
public bool IsOwn { get; set; }
|
||||
}
|
||||
|
||||
public static string NormName(string? s)
|
||||
{
|
||||
if (s is null) return "";
|
||||
var n = s.Normalize(NormalizationForm.FormKC).Trim().ToLowerInvariant();
|
||||
return string.Join(' ', n.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
public static string NormValue(string? s)
|
||||
{
|
||||
var v = NormName(s);
|
||||
return UnknownValues.Contains(v) ? "" : v;
|
||||
}
|
||||
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
|
||||
{
|
||||
var result = new DedupResult();
|
||||
|
||||
foreach (var a in animals)
|
||||
{
|
||||
a.NameKey = NormName(a.Name);
|
||||
a.FarbeKey = NormValue(a.Farbe);
|
||||
a.OriginKey = NormValue(a.Origin);
|
||||
}
|
||||
|
||||
var byName = new Dictionary<string, List<Rpro3Animal>>();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
if (PlaceholderNames.Contains(a.NameKey)) { result.Placeholders.Add(a); continue; }
|
||||
if (!byName.TryGetValue(a.NameKey, out var list)) byName[a.NameKey] = list = new();
|
||||
list.Add(a);
|
||||
}
|
||||
|
||||
var parent = new Dictionary<string, string>();
|
||||
string Find(string x)
|
||||
{
|
||||
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
||||
return x;
|
||||
}
|
||||
void Union(string x, string y)
|
||||
{
|
||||
parent.TryAdd(x, x); parent.TryAdd(y, y);
|
||||
parent[Find(x)] = Find(y);
|
||||
}
|
||||
|
||||
static int Positive(Rpro3Animal a, Rpro3Animal b)
|
||||
{
|
||||
int agree = 0;
|
||||
if (a.Dob is not null && b.Dob is not null && a.Dob == b.Dob) agree++;
|
||||
if (a.FarbeKey.Length > 0 && a.FarbeKey == b.FarbeKey) agree++;
|
||||
if (a.OriginKey.Length > 0 && a.OriginKey == b.OriginKey) agree++;
|
||||
return agree;
|
||||
}
|
||||
static int Conflict(Rpro3Animal a, Rpro3Animal b)
|
||||
{
|
||||
int c = 0;
|
||||
if (a.Dob is not null && b.Dob is not null && a.Dob != b.Dob) c++;
|
||||
if (a.FarbeKey.Length > 0 && b.FarbeKey.Length > 0 && a.FarbeKey != b.FarbeKey) c++;
|
||||
if (a.OriginKey.Length > 0 && b.OriginKey.Length > 0 && a.OriginKey != b.OriginKey) c++;
|
||||
return c;
|
||||
}
|
||||
static bool Compat(string? x, string? y) =>
|
||||
string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y) || x == y;
|
||||
static bool CompatDob(DateOnly? x, DateOnly? y) => x is null || y is null || x == y;
|
||||
|
||||
foreach (var (_, group) in byName)
|
||||
{
|
||||
foreach (var a in group) parent.TryAdd(a.Rid, a.Rid);
|
||||
for (int i = 0; i < group.Count; i++)
|
||||
for (int j = i + 1; j < group.Count; j++)
|
||||
{
|
||||
var a = group[i]; var b = group[j];
|
||||
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
|
||||
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
|
||||
Union(a.Rid, b.Rid);
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster sammeln
|
||||
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
if (PlaceholderNames.Contains(a.NameKey)) continue;
|
||||
var root = Find(a.Rid);
|
||||
result.RidToRoot[a.Rid] = root;
|
||||
if (!clusters.TryGetValue(root, out var list)) clusters[root] = list = new();
|
||||
list.Add(a);
|
||||
}
|
||||
foreach (var (k, v) in clusters)
|
||||
if (v.Count > 1)
|
||||
{
|
||||
result.MergeClusters[k] = v;
|
||||
result.CollapsedRecords += v.Count;
|
||||
}
|
||||
result.DuplicatesRemoved = result.CollapsedRecords - result.MergeClusters.Count;
|
||||
|
||||
// Mehrdeutige Namen: Name löst sich nach sicherem Merge in >1 Cluster auf
|
||||
var nameToRoots = new Dictionary<string, HashSet<string>>();
|
||||
var disp = new Dictionary<string, string>();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
if (PlaceholderNames.Contains(a.NameKey)) continue;
|
||||
if (!nameToRoots.TryGetValue(a.NameKey, out var set)) nameToRoots[a.NameKey] = set = new();
|
||||
set.Add(Find(a.Rid));
|
||||
disp.TryAdd(a.NameKey, a.Name ?? a.NameKey);
|
||||
}
|
||||
|
||||
foreach (var (nm, roots) in nameToRoots)
|
||||
{
|
||||
if (roots.Count <= 1) continue;
|
||||
var amb = new AmbiguousName { Name = disp.GetValueOrDefault(nm, nm) };
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var recs = clusters[root];
|
||||
var dob = recs.Where(r => r.Dob is not null).Select(r => r.Dob!.Value.ToString("yyyy-MM-dd")).Distinct().OrderBy(x => x).ToList();
|
||||
var farbe = recs.Where(r => !string.IsNullOrEmpty(r.Farbe)).Select(r => r.Farbe!).Distinct().OrderBy(x => x).ToList();
|
||||
var origin = recs.Where(r => NormValue(r.Origin).Length > 0).Select(r => r.Origin!).Distinct().OrderBy(x => x).ToList();
|
||||
bool bare = dob.Count == 0 && farbe.Count == 0 && origin.Count == 0;
|
||||
if (bare) { amb.BareCount += recs.Count; continue; }
|
||||
var v = new Variant { Count = recs.Count, IsOwn = recs.Any(r => r.Src == Rpro3Src.Stamm) };
|
||||
v.Dob.AddRange(dob); v.Farbe.AddRange(farbe); v.Origin.AddRange(origin);
|
||||
amb.Variants.Add(v);
|
||||
}
|
||||
amb.Variants.Sort((x, y) => y.Count.CompareTo(x.Count));
|
||||
// Nur Namen mit mind. einer informativen Variante interessieren die Züchterin.
|
||||
if (amb.Variants.Count >= 1) result.Ambiguous.Add(amb);
|
||||
}
|
||||
result.Ambiguous.Sort((x, y) => y.Variants.Count.CompareTo(x.Variants.Count));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Repräsentant eines Clusters: bevorzugt ein stamm-Tier, sonst das mit den meisten
|
||||
/// bekannten Feldern (DOB/Farbe/Origin), Tiebreak per Rid (stabil).</summary>
|
||||
public static Rpro3Animal Representative(List<Rpro3Animal> cluster)
|
||||
{
|
||||
return cluster
|
||||
.OrderByDescending(a => a.Src == Rpro3Src.Stamm)
|
||||
.ThenByDescending(a => (a.Dob is not null ? 1 : 0) + (a.FarbeKey.Length > 0 ? 1 : 0) + (a.OriginKey.Length > 0 ? 1 : 0))
|
||||
.ThenBy(a => a.Rid, StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs
Normal file
39
GerbilManagerWebAPI/Import/Rpro3/Rpro3Guid.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministische GUIDs aus stabilen RPRO3-Schlüsseln (analog zum Python `generate_guid`-Muster).
|
||||
/// Gleicher Schlüssel → gleiche GUID über Re-Imports hinweg → idempotent.
|
||||
/// MD5-basiert (Namespace-Präfix vermeidet Kollisionen mit anderen Entitätstypen).
|
||||
/// </summary>
|
||||
public static class Rpro3Guid
|
||||
{
|
||||
public const string ImportSource = "RennmausPro III";
|
||||
|
||||
public static Guid For(string @namespace, string key)
|
||||
{
|
||||
var bytes = MD5.HashData(Encoding.UTF8.GetBytes($"rpro3:{@namespace}:{key}"));
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
public static Guid Gerbil(string clusterRootRid) => For("gerbil", clusterRootRid);
|
||||
public static Guid Litter(int wurfId) => For("litter", wurfId.ToString());
|
||||
public static Guid Contact(string ns, int id) => For("contact", $"{ns}:{id}");
|
||||
public static Guid Health(string tid, string disc, int seq) => For("health", $"{tid}:{seq}:{disc}");
|
||||
public static Guid Weight(string tid, int seq) => For("weight", $"{tid}:{seq}");
|
||||
|
||||
// Feature-Tabellen — deterministische IDs für Idempotenz (Re-Import ersetzt dieselben Zeilen).
|
||||
public static Guid Enclosure(int beckenId) => For("enclosure", beckenId.ToString());
|
||||
public static Guid Acquisition(int herktierId) => For("acquisition", herktierId.ToString());
|
||||
public static Guid Reservation(string tid) => For("reservation", tid);
|
||||
public static Guid Return(int getbackId) => For("return", getbackId.ToString());
|
||||
public static Guid WaitingList(int nachfrageId) => For("waitinglist", nachfrageId.ToString());
|
||||
public static Guid Exhibition(int auszId) => For("exhibition", auszId.ToString());
|
||||
|
||||
/// <summary>Stabiler ExternalRef-Wert (Gerbil/Litter Idempotenzschlüssel, UNIQUE-Spalte).</summary>
|
||||
public static string GerbilRef(string rootRid) => $"rpro3:{rootRid}";
|
||||
public static string LitterRef(int wurfId) => $"rpro3-wurf:{wurfId}";
|
||||
}
|
||||
}
|
||||
861
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
861
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
@@ -0,0 +1,861 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// „First class" RennmausPro-III-Backup-Importer. Parst die `.backup` (SQLite), dedupliziert
|
||||
/// RPRO3-interne Dubletten (Port von compare_rpro3.py) und überführt die Daten in unser Modell.
|
||||
///
|
||||
/// Zwei Operationen:
|
||||
/// AnalyzeAsync — schreibt NICHTS; liefert Zählungen, Top-Merges, mehrdeutige Namen, neu/vorhanden.
|
||||
/// ExecuteAsync — idempotenter Import (deterministische GUIDs + ExternalRef). Re-Run aktualisiert
|
||||
/// dieselben Zeilen; vorherige RPRO3-Importe (ImportSource="RennmausPro III")
|
||||
/// werden vor dem Neuladen entfernt. Spreadsheet-Importe bleiben unangetastet.
|
||||
/// </summary>
|
||||
public sealed class Rpro3ImportService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
||||
}
|
||||
|
||||
// ───────────────────────── ANALYZE ─────────────────────────
|
||||
|
||||
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
||||
var existing = await _db.Gerbils.AsNoTracking()
|
||||
.Select(g => new { g.NameSearch, g.DateOfBirth, g.ExternalRef })
|
||||
.ToListAsync();
|
||||
var existingRefs = existing.Where(e => e.ExternalRef is not null)
|
||||
.Select(e => e.ExternalRef!).ToHashSet(StringComparer.Ordinal);
|
||||
var existingByName = existing
|
||||
.GroupBy(e => e.NameSearch ?? "")
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.DateOfBirth).ToList());
|
||||
|
||||
int newCount = 0, existingCount = 0;
|
||||
foreach (var gp in plan.Gerbils.Values)
|
||||
{
|
||||
if (existingRefs.Contains(gp.ExternalRef)) { existingCount++; continue; }
|
||||
var key = GerbilSearch.Normalize(gp.Name);
|
||||
if (existingByName.TryGetValue(key, out var dobs) && DobMatch(gp.Dob, dobs)) existingCount++;
|
||||
else newCount++;
|
||||
}
|
||||
|
||||
var counts = new Rpro3Counts(
|
||||
OwnAnimals: data.Animals.Count(a => a.Src == Rpro3Src.Stamm),
|
||||
ExternalRaw: data.Animals.Count(a => a.Src == Rpro3Src.Fremd),
|
||||
ExternalAfterDedup: plan.Gerbils.Values.Count(g => !g.IsResident),
|
||||
Litters: data.Litters.Count,
|
||||
Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
|
||||
Genotypes: data.ColorStammCount + data.ColorExtCount,
|
||||
DuplicatesMerged: dedup.DuplicatesRemoved,
|
||||
MergeClusters: dedup.MergeClusters.Count,
|
||||
Enclosures: plan.Enclosures.Count,
|
||||
Acquisitions: plan.Acquisitions.Count,
|
||||
Reservations: plan.Reservations.Count,
|
||||
Returns: plan.Returns.Count,
|
||||
WaitingList: plan.WaitingList.Count,
|
||||
Exhibitions: plan.Exhibitions.Count);
|
||||
|
||||
var topMerges = dedup.MergeClusters.Values
|
||||
.OrderByDescending(v => v.Count)
|
||||
.Take(25)
|
||||
.Select(v =>
|
||||
{
|
||||
var rep = Rpro3Dedup.Representative(v);
|
||||
return new Rpro3MergeSample(
|
||||
rep.Name ?? "—",
|
||||
v.Count,
|
||||
string.Join(", ", v.Where(x => x.Dob is not null).Select(x => x.Dob!.Value.ToString("yyyy-MM-dd")).Distinct()),
|
||||
Truncate(string.Join(", ", v.Where(x => !string.IsNullOrEmpty(x.Farbe)).Select(x => x.Farbe!).Distinct()), 60),
|
||||
Truncate(string.Join(", ", v.Where(x => Rpro3Dedup.NormValue(x.Origin).Length > 0).Select(x => x.Origin!).Distinct()), 60));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var ambiguous = dedup.Ambiguous
|
||||
.Take(80)
|
||||
.Select(a => new Rpro3AmbiguousName(
|
||||
a.Name,
|
||||
a.Variants.Select(v => new Rpro3AmbiguousVariant(
|
||||
v.Count,
|
||||
v.Dob.Count > 0 ? string.Join(", ", v.Dob) : "—",
|
||||
v.Farbe.Count > 0 ? Truncate(string.Join(", ", v.Farbe), 40) : "—",
|
||||
v.Origin.Count > 0 ? Truncate(string.Join(", ", v.Origin), 40) : "—",
|
||||
v.IsOwn)).ToList(),
|
||||
a.BareCount))
|
||||
.ToList();
|
||||
|
||||
return new Rpro3AnalyzeResult(
|
||||
counts, newCount, existingCount, topMerges, ambiguous,
|
||||
photosProvided, availablePhotoFiles?.Count ?? 0);
|
||||
}
|
||||
|
||||
private static bool DobMatch(DateOnly? rpDob, List<DateOnly?> existing)
|
||||
{
|
||||
// Match, wenn ein Bestandstier denselben (oder unbekannten) Geburtstag hat (±14 Tage).
|
||||
if (rpDob is null) return true;
|
||||
foreach (var d in existing)
|
||||
{
|
||||
if (d is null) return true;
|
||||
if (Math.Abs((rpDob.Value.ToDateTime(TimeOnly.MinValue) - d.Value.ToDateTime(TimeOnly.MinValue)).TotalDays) <= 14)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ───────────────────────── EXECUTE ─────────────────────────
|
||||
|
||||
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
||||
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
|
||||
// ExecuteAsync auf derselben DbContext-Instanz würden sonst alte Tracking-Einträge
|
||||
// mit den neu eingefügten (gleiche deterministische IDs) kollidieren.
|
||||
_db.ChangeTracker.Clear();
|
||||
|
||||
// 1. Vorherigen RPRO3-Import idempotent entfernen (deterministische IDs → Re-Run ok).
|
||||
// Health/Weight zuerst (FK auf Gerbil), dann FK-Links lösen, dann Gerbils/Litters.
|
||||
var priorGerbilIds = await _db.Gerbils
|
||||
.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
|
||||
.Select(g => g.Id).ToListAsync();
|
||||
var priorLitterRefs = plan.Litters.Values.Select(l => l.ExternalRef).ToList();
|
||||
|
||||
if (priorGerbilIds.Count > 0)
|
||||
{
|
||||
await _db.HealthRecords.Where(h => priorGerbilIds.Contains(h.GerbilId)).ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.Where(w => priorGerbilIds.Contains(w.GerbilId)).ExecuteDeleteAsync();
|
||||
await _db.GerbilPhotos.Where(p => priorGerbilIds.Contains(p.GerbilId)).ExecuteDeleteAsync();
|
||||
}
|
||||
// FK-Schleifen lösen: Litter.Father/Mother + Gerbil.Litter, die auf RPRO3-Tiere zeigen.
|
||||
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FatherId, (Guid?)null).SetProperty(l => l.MotherId, (Guid?)null));
|
||||
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(g => g.LitterId, (Guid?)null));
|
||||
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync();
|
||||
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync();
|
||||
|
||||
// 1b. Feature-Tabellen idempotent leeren. Diese Entities haben KEINE ImportSource-Spalte,
|
||||
// daher löschen wir gezielt die deterministischen RPRO3-IDs, die dieser Import erzeugt
|
||||
// (Re-Import desselben Backups → keine Dubletten). Manuell angelegte Einträge mit
|
||||
// anderen IDs bleiben unangetastet. Enclosures werden NICHT aus Gerbils gelöscht,
|
||||
// nur die EnclosureId-Verknüpfung gelöschter Tiere fällt mit den Tieren weg.
|
||||
var acqIds = plan.Acquisitions.Select(a => a.Id).ToList();
|
||||
var resIds = plan.Reservations.Select(r => r.Id).ToList();
|
||||
var retIds = plan.Returns.Select(r => r.Id).ToList();
|
||||
var wlIds = plan.WaitingList.Select(w => w.Id).ToList();
|
||||
var exhIds = plan.Exhibitions.Select(e => e.Id).ToList();
|
||||
var encIds = plan.Enclosures.Keys.ToList();
|
||||
if (acqIds.Count > 0) await _db.AcquisitionRecords.Where(x => acqIds.Contains(x.Id)).ExecuteDeleteAsync();
|
||||
if (resIds.Count > 0) await _db.SaleReservations.Where(x => resIds.Contains(x.Id)).ExecuteDeleteAsync();
|
||||
if (retIds.Count > 0) await _db.ReturnRecords.Where(x => retIds.Contains(x.Id)).ExecuteDeleteAsync();
|
||||
if (wlIds.Count > 0) await _db.WaitingListEntries.Where(x => wlIds.Contains(x.Id)).ExecuteDeleteAsync();
|
||||
if (exhIds.Count > 0) await _db.ExhibitionResults.Where(x => exhIds.Contains(x.Id)).ExecuteDeleteAsync();
|
||||
|
||||
// 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände).
|
||||
int contactsImported = 0;
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
foreach (var c in plan.Contacts.Values)
|
||||
{
|
||||
if (existingContacts.TryGetValue(c.Id, out var ex))
|
||||
{
|
||||
ex.Name = c.Name; ex.Email = c.Email; ex.Phone = c.Phone; ex.Address = c.Address;
|
||||
ex.Notes = c.Notes; ex.IsBreeder = c.IsBreeder; ex.IsReceiver = c.IsReceiver;
|
||||
ex.NameSuffix = c.NameSuffix; ex.Provenance = c.Provenance;
|
||||
}
|
||||
else { _db.Contacts.Add(c); }
|
||||
contactsImported++;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 2b. Enclosures upserten (deterministische IDs). MUSS vor den Gerbils laufen, da
|
||||
// Gerbil.EnclosureId ein echter FK ist. Re-Import aktualisiert dieselben Becken.
|
||||
var existingEnclosures = await _db.Enclosures
|
||||
.Where(e => plan.Enclosures.Keys.Contains(e.Id)).ToDictionaryAsync(e => e.Id);
|
||||
foreach (var en in plan.Enclosures.Values)
|
||||
{
|
||||
if (existingEnclosures.TryGetValue(en.Id, out var ex))
|
||||
{
|
||||
ex.Name = en.Name; ex.Size = en.Size; ex.Capacity = en.Capacity;
|
||||
ex.LastCleanedDate = en.LastCleanedDate; ex.CleaningCycleDays = en.CleaningCycleDays;
|
||||
}
|
||||
else { _db.Enclosures.Add(en); }
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 3. Litters (Pass 1: ohne Eltern-FKs).
|
||||
foreach (var l in plan.Litters.Values)
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = l.Id, Name = l.Name, Date = l.Date, ExternalRef = l.ExternalRef,
|
||||
Notes = l.Notes, Provenance = l.Provenance, FatherId = null, MotherId = null,
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 4. Gerbils (Pass 1: ohne LitterId).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
foreach (var g in plan.Gerbils.Values)
|
||||
{
|
||||
var gerbil = new Gerbil
|
||||
{
|
||||
Id = g.Id, Name = g.Name, Gender = g.Gender,
|
||||
DateOfBirth = g.Dob, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
|
||||
Genotype = g.Genotype, ColorVarietyId = g.ColorVarietyId, OriginBreeder = g.OriginBreeder,
|
||||
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
|
||||
GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
|
||||
Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
|
||||
Provenance = g.Provenance, LitterId = null,
|
||||
EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null,
|
||||
};
|
||||
GerbilStatusService.Apply(gerbil, today);
|
||||
_db.Gerbils.Add(gerbil);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: FK-Beziehungen setzen (Litter-Eltern + Gerbil-Geburtswurf).
|
||||
var littersInDb = await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in plan.Litters.Values)
|
||||
if (littersInDb.TryGetValue(l.Id, out var dbl))
|
||||
{
|
||||
dbl.MotherId = l.MotherId; dbl.FatherId = l.FatherId;
|
||||
}
|
||||
var gerbilsInDb = await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in plan.Gerbils.Values)
|
||||
if (g.LitterId is not null && gerbilsInDb.TryGetValue(g.Id, out var dbg))
|
||||
dbg.LitterId = g.LitterId;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Gesundheits- und Gewichtseinträge.
|
||||
foreach (var h in plan.Health) _db.HealthRecords.Add(h);
|
||||
foreach (var w in plan.Weights) _db.WeightRecords.Add(w);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 7. Fotos (optional, aus bilder.zip).
|
||||
int photosImported = 0;
|
||||
if (photosProvided && photoSourcePaths is not null)
|
||||
{
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var (gerbilId, fileNames) in plan.PhotoFiles)
|
||||
{
|
||||
int sort = 0;
|
||||
foreach (var fn in fileNames)
|
||||
{
|
||||
if (!photoSourcePaths.TryGetValue(fn.ToLowerInvariant(), out var src) || !File.Exists(src)) continue;
|
||||
var ext = Path.GetExtension(fn);
|
||||
var storedName = $"{Guid.NewGuid():N}{ext}";
|
||||
try { File.Copy(src, Path.Combine(_photoRoot, storedName), overwrite: true); }
|
||||
catch { continue; }
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbilId, FileName = storedName,
|
||||
SortOrder = sort++, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
photosImported++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 8. Feature-Tabellen einfügen (Enclosures schon in 2b erledigt).
|
||||
foreach (var a in plan.Acquisitions) _db.AcquisitionRecords.Add(a);
|
||||
foreach (var r in plan.Reservations) _db.SaleReservations.Add(r);
|
||||
foreach (var r in plan.Returns) _db.ReturnRecords.Add(r);
|
||||
foreach (var w in plan.WaitingList) _db.WaitingListEntries.Add(w);
|
||||
foreach (var e in plan.Exhibitions) _db.ExhibitionResults.Add(e);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new Rpro3ExecuteResult(
|
||||
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
|
||||
plan.Health.Count, plan.Weights.Count, photosImported,
|
||||
plan.Enclosures.Count, plan.Acquisitions.Count, plan.Reservations.Count,
|
||||
plan.Returns.Count, plan.WaitingList.Count, plan.Exhibitions.Count,
|
||||
$"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " +
|
||||
$"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " +
|
||||
$"{photosImported} Fotos, {plan.Enclosures.Count} Gehege, {plan.Acquisitions.Count} Erwerbe, " +
|
||||
$"{plan.Reservations.Count} Reservierungen, {plan.Returns.Count} Rücknahmen, " +
|
||||
$"{plan.WaitingList.Count} Wartelisten-Einträge, {plan.Exhibitions.Count} Ausstellungen.");
|
||||
}
|
||||
|
||||
// ───────────────────────── PLAN-AUFBAU ─────────────────────────
|
||||
|
||||
private sealed class GerbilPlan
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string ExternalRef { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public Gender Gender { get; set; }
|
||||
public DateOnly? Dob { get; set; }
|
||||
public DateOnly? DateOfDeath { get; set; }
|
||||
public string? CauseOfDeath { get; set; }
|
||||
public string? Genotype { get; set; }
|
||||
public Guid? ColorVarietyId { get; set; }
|
||||
public string? OriginBreeder { get; set; }
|
||||
public Guid? OriginContactId { get; set; }
|
||||
public Guid? ReceiverContactId { get; set; }
|
||||
public DateOnly? GoHomeDate { get; set; }
|
||||
public bool IsResident { get; set; }
|
||||
public bool IsCastrated { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? Provenance { get; set; }
|
||||
public Guid? LitterId { get; set; }
|
||||
public string? MotherRid { get; set; }
|
||||
public string? FatherRid { get; set; }
|
||||
}
|
||||
|
||||
private sealed class LitterPlan
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string ExternalRef { get; init; }
|
||||
public string Name { get; set; } = "Wurf";
|
||||
public DateOnly? Date { get; set; }
|
||||
public Guid? MotherId { get; set; }
|
||||
public Guid? FatherId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? Provenance { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ImportPlan
|
||||
{
|
||||
public Dictionary<Guid, GerbilPlan> Gerbils { get; } = new();
|
||||
public Dictionary<Guid, LitterPlan> Litters { get; } = new();
|
||||
public Dictionary<Guid, Contact> Contacts { get; } = new();
|
||||
public List<HealthRecord> Health { get; } = new();
|
||||
public List<WeightRecord> Weights { get; } = new();
|
||||
public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();
|
||||
|
||||
// Feature-Tabellen.
|
||||
public Dictionary<Guid, Enclosure> Enclosures { get; } = new();
|
||||
public Dictionary<Guid, Guid> GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId
|
||||
public List<AcquisitionRecord> Acquisitions { get; } = new();
|
||||
public List<SaleReservation> Reservations { get; } = new();
|
||||
public List<ReturnRecord> Returns { get; } = new();
|
||||
public List<WaitingListEntry> WaitingList { get; } = new();
|
||||
public List<ExhibitionResult> Exhibitions { get; } = new();
|
||||
}
|
||||
|
||||
private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup)
|
||||
{
|
||||
_lastDedup = dedup;
|
||||
var plan = new ImportPlan();
|
||||
|
||||
// 0) Farbschlag-Katalog (Name → Id) für ColorVariety-Verknüpfung.
|
||||
var colorByName = _db.ColorVarieties.AsNoTracking()
|
||||
.ToDictionary(c => Rpro3Dedup.NormName(c.Name), c => c.Id);
|
||||
|
||||
// 1) Kontakte (herk = Züchter/Herkunft, abn = Abnehmer).
|
||||
Guid? HerkContactId(int? herkId)
|
||||
{
|
||||
if (herkId is null || herkId == 1) return null; // 1 = eigene Zucht → kein Kontakt
|
||||
if (!data.HerkContacts.TryGetValue(herkId.Value.ToString(), out var c)) return null;
|
||||
return EnsureContact(plan, c, isBreeder: true, isReceiver: false);
|
||||
}
|
||||
Guid? AbnContactId(int? abnId)
|
||||
{
|
||||
if (abnId is null) return null;
|
||||
if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return null;
|
||||
return EnsureContact(plan, c, isBreeder: false, isReceiver: true);
|
||||
}
|
||||
|
||||
// 2) Rid → Cluster-Root → Gerbil-GUID. Platzhalter-Tiere bekommen ihren eigenen Root.
|
||||
string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
|
||||
Guid GerbilIdOfRid(string rid) => Rpro3Guid.Gerbil(RootOf(rid));
|
||||
|
||||
// 3) Pup-Index: SID(stamm-id) → pup, und stamm-id → Welpen-Daten (Abgabe/Preis/Tod).
|
||||
var pupBySid = new Dictionary<string, Rpro3Pup>(StringComparer.Ordinal);
|
||||
foreach (var p in data.Pups.Values)
|
||||
if (!string.IsNullOrWhiteSpace(p.Sid) && p.Sid != "0")
|
||||
pupBySid[p.Sid] = p;
|
||||
|
||||
// 4) Ein Gerbil je Cluster (Repräsentant führt; stamm-Tiere machen den Cluster resident).
|
||||
// Singletons (RidToRoot == self) ebenfalls einbeziehen.
|
||||
var clusterMembers = new Dictionary<string, List<Rpro3Animal>>(StringComparer.Ordinal);
|
||||
foreach (var a in data.Animals)
|
||||
{
|
||||
var root = RootOf(a.Rid);
|
||||
if (!clusterMembers.TryGetValue(root, out var list)) clusterMembers[root] = list = new();
|
||||
list.Add(a);
|
||||
}
|
||||
|
||||
foreach (var (root, members) in clusterMembers)
|
||||
{
|
||||
var rep = Rpro3Dedup.Representative(members);
|
||||
bool resident = members.Any(m => m.Src == Rpro3Src.Stamm);
|
||||
var id = Rpro3Guid.Gerbil(root);
|
||||
if (plan.Gerbils.ContainsKey(id)) continue;
|
||||
|
||||
// Bestes bekanntes Feld aus allen Cluster-Mitgliedern wählen.
|
||||
var dob = members.Select(m => m.Dob).FirstOrDefault(d => d is not null) ?? rep.Dob;
|
||||
var farbe = members.Select(m => m.Farbe).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Farbe;
|
||||
var fcode = members.Select(m => m.Fcode).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Fcode;
|
||||
var death = members.FirstOrDefault(m => m.DateOfDeath is not null);
|
||||
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 gp = new GerbilPlan
|
||||
{
|
||||
Id = id,
|
||||
ExternalRef = Rpro3Guid.GerbilRef(root),
|
||||
Name = string.IsNullOrWhiteSpace(rep.Name) ? "Unbenannt" : rep.Name!,
|
||||
Gender = members.Select(m => m.Gender).FirstOrDefault(g => g != Gender.unknown),
|
||||
Dob = dob,
|
||||
DateOfDeath = death?.DateOfDeath,
|
||||
CauseOfDeath = death?.CauseOfDeath,
|
||||
Genotype = CleanGenotype(fcode),
|
||||
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
||||
OriginContactId = HerkContactId(herkId),
|
||||
IsResident = resident,
|
||||
IsCastrated = members.Any(m => m.IsCastrated),
|
||||
MotherRid = rep.MidRaw,
|
||||
FatherRid = rep.PidRaw,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(farbe) && colorByName.TryGetValue(Rpro3Dedup.NormName(farbe), out var cvId))
|
||||
gp.ColorVarietyId = cvId;
|
||||
|
||||
// Welpen-Zusatzdaten (Abgabe/Preis) für residente Tiere, die ein wurftier sind.
|
||||
foreach (var m in members.Where(m => m.Src == Rpro3Src.Stamm))
|
||||
if (pupBySid.TryGetValue(m.Rid, out var pup))
|
||||
{
|
||||
gp.GoHomeDate = pup.AbgabeDate;
|
||||
gp.ReceiverContactId = AbnContactId(pup.AbnId);
|
||||
if (pup.DateOfDeath is not null && gp.DateOfDeath is null)
|
||||
{
|
||||
gp.DateOfDeath = pup.DateOfDeath; gp.CauseOfDeath = pup.CauseOfDeath;
|
||||
}
|
||||
}
|
||||
|
||||
gp.Notes = BuildNotes(members);
|
||||
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||
plan.Gerbils[id] = gp;
|
||||
}
|
||||
|
||||
// 5) Würfe aus wurf_tb (autoritativ). Eltern → Cluster-GUID.
|
||||
foreach (var (wid, w) in data.Litters)
|
||||
{
|
||||
var id = Rpro3Guid.Litter(wid);
|
||||
var lp = new LitterPlan
|
||||
{
|
||||
Id = id,
|
||||
ExternalRef = Rpro3Guid.LitterRef(wid),
|
||||
Name = string.IsNullOrWhiteSpace(w.Name) ? $"Wurf {wid}" : w.Name!,
|
||||
Date = w.Date,
|
||||
Notes = w.Notes,
|
||||
MotherId = ResolveParentGuid(w.MotherRid, plan),
|
||||
FatherId = ResolveParentGuid(w.FatherRid, plan),
|
||||
Provenance = JsonProvenance("RennmausPro III", new() { ["wurfId"] = wid, ["quelle"] = "wurf_tb" }),
|
||||
};
|
||||
plan.Litters[id] = lp;
|
||||
}
|
||||
|
||||
// 6) Welpen → Geburtswurf verknüpfen (pup._SID → resultierendes Gerbil).
|
||||
foreach (var p in data.Pups.Values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p.Sid) || p.Sid == "0") continue;
|
||||
var gid = GerbilIdOfRid(p.Sid);
|
||||
if (!plan.Gerbils.TryGetValue(gid, out var gp)) continue;
|
||||
var litterId = Rpro3Guid.Litter(p.WurfId);
|
||||
if (plan.Litters.ContainsKey(litterId)) gp.LitterId = litterId;
|
||||
}
|
||||
|
||||
// 7) Synthetische „Pedigree-Würfe" für Tiere mit baum_tb-Eltern, aber ohne wurf_tb-Geburtswurf.
|
||||
// So traversiert der Stammbaum (Litter → Father/Mother) auch externe Ahnen-Linien.
|
||||
foreach (var gp in plan.Gerbils.Values)
|
||||
{
|
||||
if (gp.LitterId is not null) continue;
|
||||
var motherId = ResolveParentGuid(gp.MotherRid, plan);
|
||||
var fatherId = ResolveParentGuid(gp.FatherRid, plan);
|
||||
if (motherId is null && fatherId is null) continue;
|
||||
var key = $"ped:{motherId}:{fatherId}";
|
||||
var litterId = Rpro3Guid.For("litter", key);
|
||||
if (!plan.Litters.TryGetValue(litterId, out var lp))
|
||||
{
|
||||
lp = new LitterPlan
|
||||
{
|
||||
Id = litterId,
|
||||
ExternalRef = $"rpro3-ped:{motherId}:{fatherId}",
|
||||
Name = "Abstammung (RennmausPro)",
|
||||
MotherId = motherId,
|
||||
FatherId = fatherId,
|
||||
Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = "baum_tb", ["synthetisch"] = true }),
|
||||
};
|
||||
plan.Litters[litterId] = lp;
|
||||
}
|
||||
gp.LitterId = litterId;
|
||||
}
|
||||
|
||||
// 8) Gesundheit/Gewicht/Tagebuch → stamm-Tiere (tid = stamm-id).
|
||||
BuildHealthAndWeights(data, plan, GerbilIdOfRid);
|
||||
|
||||
// 9) Fotos (photo_tb id = stamm-id).
|
||||
foreach (var (tid, set) in data.Photos)
|
||||
{
|
||||
var gid = GerbilIdOfRid(tid);
|
||||
if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames;
|
||||
}
|
||||
|
||||
// 10) Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung).
|
||||
BuildFeatureTables(data, plan, dedup, pupBySid);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die 6 Feature-Entities aus den RPRO3-Feature-Tabellen. Alle Referenzen werden über
|
||||
/// die bereits aufgebauten Gerbil-/Contact-GUIDs aufgelöst; tids können stamm ("N") oder
|
||||
/// wurftier ("XjY") sein — Welpen-tids werden über _SID auf das resultierende Tier abgebildet.
|
||||
/// </summary>
|
||||
private void BuildFeatureTables(
|
||||
Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary<string, Rpro3Pup> pupBySid)
|
||||
{
|
||||
string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
|
||||
|
||||
// tid ("N" oder "XjY") → (GerbilId?, erfasster Name?). Welpen ohne kept-_SID liefern null-Id.
|
||||
(Guid? id, string? name) ResolveGerbil(string? tid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tid)) return (null, null);
|
||||
string rid = tid;
|
||||
if (tid.Contains('j'))
|
||||
{
|
||||
// Welpe: über _SID auf das behaltene stamm-Tier abbilden, falls vorhanden.
|
||||
if (data.Pups.TryGetValue(tid, out var pup) && !string.IsNullOrWhiteSpace(pup.Sid) && pup.Sid != "0")
|
||||
rid = pup.Sid!;
|
||||
else
|
||||
{
|
||||
var pupName = data.Pups.TryGetValue(tid, out var p) ? p.Name : null;
|
||||
return (null, pupName);
|
||||
}
|
||||
}
|
||||
var gid = Rpro3Guid.Gerbil(RootOf(rid));
|
||||
return plan.Gerbils.TryGetValue(gid, out var gp) ? (gid, gp.Name) : (null, data.ResolveName(rid));
|
||||
}
|
||||
|
||||
// abn-id → (ContactId?, erfasster Name?). Legt den Kontakt bei Bedarf an (als Abnehmer).
|
||||
(Guid? id, string? name) ResolveAbn(int? abnId)
|
||||
{
|
||||
if (abnId is null) return (null, null);
|
||||
if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return (null, null);
|
||||
var id = EnsureContact(plan, c, isBreeder: false, isReceiver: true);
|
||||
return (id, c.DisplayName);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
static DateTime? AsDt(DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
|
||||
// ── Becken → Enclosure (+ stamm._BECKEN → Gerbil-Zuordnung) ──
|
||||
var beckenById = new Dictionary<int, Guid>();
|
||||
foreach (var b in data.Becken)
|
||||
{
|
||||
var id = Rpro3Guid.Enclosure(b.Id);
|
||||
beckenById[b.Id] = id;
|
||||
plan.Enclosures[id] = new Enclosure
|
||||
{
|
||||
Id = id,
|
||||
Name = string.IsNullOrWhiteSpace(b.Name) ? $"Becken {b.Id}" : b.Name!,
|
||||
Size = string.IsNullOrWhiteSpace(b.Size) ? null : b.Size,
|
||||
Capacity = b.Capacity is > 0 ? b.Capacity : null,
|
||||
LastCleanedDate = b.LastCleaned,
|
||||
CleaningCycleDays = b.CycleDays is > 0 ? b.CycleDays : null,
|
||||
};
|
||||
}
|
||||
foreach (var (rid, beckenId) in data.StammBecken)
|
||||
{
|
||||
if (!beckenById.TryGetValue(beckenId, out var encId)) continue;
|
||||
var gid = Rpro3Guid.Gerbil(RootOf(rid));
|
||||
if (plan.Gerbils.ContainsKey(gid)) plan.GerbilEnclosure[gid] = encId;
|
||||
}
|
||||
|
||||
// ── herktier_tb → AcquisitionRecord ──
|
||||
foreach (var a in data.Acquisitions)
|
||||
{
|
||||
var (gid, _) = ResolveGerbil(a.Tid);
|
||||
if (gid is null) continue; // kein Tier → kein Erwerb
|
||||
// Herkunfts-Kontakt steht bereits auf Gerbil.OriginContactId; hier Datum/Preis/Notiz.
|
||||
Guid? sourceContactId = plan.Gerbils.TryGetValue(gid.Value, out var gp) ? gp.OriginContactId : null;
|
||||
plan.Acquisitions.Add(new AcquisitionRecord
|
||||
{
|
||||
Id = Rpro3Guid.Acquisition(a.Id),
|
||||
GerbilId = gid,
|
||||
SourceContactId = sourceContactId,
|
||||
Date = a.Date,
|
||||
Price = a.Price is > 0 ? a.Price : null,
|
||||
Note = NullIfEmpty(a.Note),
|
||||
CreatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
// ── abgeben_tb + abstat_tb → SaleReservation (eine Zeile je Tier) ──
|
||||
// abgeben = Vormerkung/Reservierung (oft Welpen-tid), abstat = Abgabe-Abschluss (stamm-tid).
|
||||
// Beide werden über das Tier zusammengeführt; ist beides vorhanden, gewinnt „abgegeben".
|
||||
var reservationByGerbil = new Dictionary<Guid, SaleReservation>();
|
||||
SaleReservation Res(Guid gid, string? name, string tidKey)
|
||||
{
|
||||
if (reservationByGerbil.TryGetValue(gid, out var ex)) return ex;
|
||||
var res = new SaleReservation
|
||||
{
|
||||
Id = Rpro3Guid.Reservation(tidKey),
|
||||
GerbilId = gid,
|
||||
GerbilName = name,
|
||||
Status = "verfuegbar",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
reservationByGerbil[gid] = res;
|
||||
plan.Reservations.Add(res);
|
||||
return res;
|
||||
}
|
||||
foreach (var ab in data.Abgeben)
|
||||
{
|
||||
var (gid, name) = ResolveGerbil(ab.Tid);
|
||||
if (gid is null) continue;
|
||||
var res = Res(gid.Value, name, ab.Tid);
|
||||
var (cid, cname) = ResolveAbn(ab.AbnId);
|
||||
if (res.Status != "abgegeben") res.Status = ab.Reserved ? "reserviert" : res.Status;
|
||||
res.ReservedForContactId ??= cid;
|
||||
res.ContactName ??= cname;
|
||||
res.AppointmentDate ??= AsDt(ab.Appointment);
|
||||
res.Note ??= NullIfEmpty(ab.Note);
|
||||
}
|
||||
foreach (var st in data.Abstat)
|
||||
{
|
||||
var (gid, name) = ResolveGerbil(st.Tid);
|
||||
if (gid is null) continue;
|
||||
var res = Res(gid.Value, name, st.Tid);
|
||||
var (cid, cname) = ResolveAbn(st.AbnId);
|
||||
res.Status = "abgegeben";
|
||||
res.ReservedForContactId ??= cid;
|
||||
res.ContactName ??= cname;
|
||||
res.HandedOverDate ??= AsDt(st.HandedOver);
|
||||
if (st.Price is > 0) res.Price ??= st.Price;
|
||||
res.Note ??= NullIfEmpty(st.Note);
|
||||
res.UpdatedAt = now;
|
||||
}
|
||||
|
||||
// ── getback_tb → ReturnRecord ──
|
||||
foreach (var gb in data.Getback)
|
||||
{
|
||||
var (gid, name) = ResolveGerbil(gb.Tid);
|
||||
var (cid, cname) = ResolveAbn(gb.AbnId);
|
||||
plan.Returns.Add(new ReturnRecord
|
||||
{
|
||||
Id = Rpro3Guid.Return(gb.Id),
|
||||
GerbilId = gid,
|
||||
GerbilName = name,
|
||||
ReturnDate = AsDt(gb.ReturnDate),
|
||||
ReturnPrice = gb.ReturnPrice is > 0 ? gb.ReturnPrice : null,
|
||||
OriginalPrice = gb.OriginalPrice is > 0 ? gb.OriginalPrice : null,
|
||||
OriginalSaleDate = AsDt(gb.OriginalSaleDate),
|
||||
FromContactId = cid,
|
||||
FromContactName = cname,
|
||||
Note = NullIfEmpty(gb.Note),
|
||||
CreatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
// ── nachfrage_tb → WaitingListEntry ──
|
||||
foreach (var nf in data.Nachfrage)
|
||||
{
|
||||
var (cid, cname) = ResolveAbn(nf.AbnId);
|
||||
plan.WaitingList.Add(new WaitingListEntry
|
||||
{
|
||||
Id = Rpro3Guid.WaitingList(nf.Id),
|
||||
ContactId = cid,
|
||||
ContactName = cname,
|
||||
WishColor = NullIfEmpty(nf.WishColor),
|
||||
WishGender = nf.WishGender switch
|
||||
{
|
||||
Gender.male => "male",
|
||||
Gender.female => "female",
|
||||
_ => null,
|
||||
},
|
||||
RequestedAt = AsDt(nf.RequestedAt),
|
||||
Status = MapWaitlistStatus(nf.Status),
|
||||
Note = NullIfEmpty(nf.Note),
|
||||
CreatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
// ── ausz_tb → ExhibitionResult (in vielen Backups leer; trotzdem voll verdrahtet) ──
|
||||
foreach (var ex in data.Ausstellungen)
|
||||
{
|
||||
var (gid, name) = ResolveGerbil(ex.Tid);
|
||||
var noteParts = new[] { ex.Place, ex.Juror is { Length: > 0 } ? $"Juror: {ex.Juror}" : null, ex.Note }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
var note = string.Join(" · ", noteParts);
|
||||
plan.Exhibitions.Add(new ExhibitionResult
|
||||
{
|
||||
Id = Rpro3Guid.Exhibition(ex.Id),
|
||||
GerbilId = gid,
|
||||
EntityName = name,
|
||||
EventName = string.IsNullOrWhiteSpace(ex.EventName) ? "Ausstellung" : ex.EventName!,
|
||||
Date = AsDt(ex.Date),
|
||||
Placement = NullIfEmpty(ex.Placement),
|
||||
Award = NullIfEmpty(ex.Award),
|
||||
Note = string.IsNullOrWhiteSpace(note) ? null : note,
|
||||
CreatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
/// <summary>RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert).</summary>
|
||||
private static string MapWaitlistStatus(string? raw)
|
||||
{
|
||||
var s = (raw ?? "").Trim().ToLowerInvariant();
|
||||
return s switch
|
||||
{
|
||||
"" or "offen" or "0" => "offen",
|
||||
"erfuellt" or "erfüllt" or "1" => "erfuellt",
|
||||
"storniert" or "2" => "storniert",
|
||||
_ => "offen",
|
||||
};
|
||||
}
|
||||
|
||||
private Guid? ResolveParentGuid(string? rid, ImportPlan plan)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
|
||||
var gid = Rpro3Guid.Gerbil(_lastDedup!.RidToRoot.TryGetValue(rid, out var r) ? r : rid);
|
||||
return plan.Gerbils.ContainsKey(gid) ? gid : null;
|
||||
}
|
||||
|
||||
// Kontext-Hack: ResolveParentGuid braucht die RidToRoot-Map des aktuellen Plans.
|
||||
private Rpro3Dedup.DedupResult? _lastDedup;
|
||||
|
||||
private void BuildHealthAndWeights(Rpro3Data data, ImportPlan plan, Func<string, Guid> gerbilIdOfRid)
|
||||
{
|
||||
int seq = 0;
|
||||
foreach (var h in data.Health)
|
||||
{
|
||||
var gid = gerbilIdOfRid(h.Tid);
|
||||
if (!plan.Gerbils.ContainsKey(gid) || h.Date is null) continue;
|
||||
var desc = string.IsNullOrWhiteSpace(h.Description) ? "Krankheitseintrag" : h.Description!;
|
||||
if (!string.IsNullOrWhiteSpace(h.Medication)) desc += $" (Medikation: {h.Medication})";
|
||||
plan.Health.Add(new HealthRecord
|
||||
{
|
||||
Id = Rpro3Guid.Health(h.Tid, desc, seq++),
|
||||
GerbilId = gid, Date = h.Date.Value, Type = HealthRecordType.Treatment,
|
||||
Description = desc, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
// Tagebuch als "Other"-Gesundheitseinträge (kein eigenes Tagebuch-Modell vorhanden).
|
||||
foreach (var d in data.Diary)
|
||||
{
|
||||
var gid = gerbilIdOfRid(d.Tid);
|
||||
if (!plan.Gerbils.ContainsKey(gid) || d.Date is null || string.IsNullOrWhiteSpace(d.Description)) continue;
|
||||
var desc = (string.IsNullOrWhiteSpace(d.Title) ? "" : d.Title + ": ") + d.Description;
|
||||
plan.Health.Add(new HealthRecord
|
||||
{
|
||||
Id = Rpro3Guid.Health(d.Tid, "diary:" + desc, seq++),
|
||||
GerbilId = gid, Date = d.Date.Value, Type = HealthRecordType.Other,
|
||||
Description = "Tagebuch: " + desc, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
int wseq = 0;
|
||||
var seen = new HashSet<Guid>();
|
||||
foreach (var w in data.Weights)
|
||||
{
|
||||
var gid = gerbilIdOfRid(w.Tid);
|
||||
if (!plan.Gerbils.ContainsKey(gid) || w.Date is null || w.Grams <= 0) continue;
|
||||
var id = Rpro3Guid.Weight(w.Tid + ":" + w.Date.Value.DayNumber, wseq++);
|
||||
if (!seen.Add(id)) continue;
|
||||
plan.Weights.Add(new WeightRecord
|
||||
{
|
||||
Id = id, GerbilId = gid, Date = w.Date.Value, WeightGrams = w.Grams, Notes = w.Notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Guid EnsureContact(ImportPlan plan, Rpro3Contact c, bool isBreeder, bool isReceiver)
|
||||
{
|
||||
var id = Rpro3Guid.Contact(c.Namespace, c.Id);
|
||||
if (plan.Contacts.TryGetValue(id, out var ex))
|
||||
{
|
||||
ex.IsBreeder |= isBreeder; ex.IsReceiver |= isReceiver;
|
||||
return id;
|
||||
}
|
||||
var address = string.Join(", ",
|
||||
new[] { c.Str, $"{c.Plz} {c.Ort}".Trim() }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
var notes = string.Join("\n",
|
||||
new[] { c.Memo, c.Bem, c.Http }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
plan.Contacts[id] = new Contact
|
||||
{
|
||||
Id = id,
|
||||
Name = c.DisplayName,
|
||||
Email = string.IsNullOrWhiteSpace(c.Mail) ? null : c.Mail,
|
||||
Phone = string.IsNullOrWhiteSpace(c.Telp) ? c.Teld : c.Telp,
|
||||
Address = string.IsNullOrWhiteSpace(address) ? null : address,
|
||||
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
|
||||
IsBreeder = isBreeder,
|
||||
IsReceiver = isReceiver,
|
||||
NameSuffix = string.IsNullOrWhiteSpace(c.Clan) ? null : c.Clan,
|
||||
Provenance = JsonProvenance("RennmausPro III",
|
||||
new() { ["quelle"] = c.Namespace == "herk" ? "herk_tb" : "abn_tb", ["rpro3Id"] = c.Id }),
|
||||
};
|
||||
return id;
|
||||
}
|
||||
|
||||
// ───────────────────────── Helpers ─────────────────────────
|
||||
|
||||
/// <summary>RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt
|
||||
/// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten.</summary>
|
||||
internal static string? CleanGenotype(string? fcode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fcode)) return null;
|
||||
var sb = new System.Text.StringBuilder(fcode.Length);
|
||||
foreach (var ch in fcode)
|
||||
if (ch != '[' && ch != ']') sb.Append(ch);
|
||||
var cleaned = sb.ToString().Trim();
|
||||
return cleaned.Length == 0 ? null : cleaned;
|
||||
}
|
||||
|
||||
private static string? BuildNotes(List<Rpro3Animal> members)
|
||||
{
|
||||
var defects = members.Select(m => m.Defects).Where(d => !string.IsNullOrWhiteSpace(d)).Distinct().ToList();
|
||||
var zb = members.Select(m => m.Zb).FirstOrDefault(z => !string.IsNullOrWhiteSpace(z));
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(zb)) parts.Add($"Zuchtbuch-Nr.: {zb}");
|
||||
if (defects.Count > 0) parts.Add("Defekte: " + string.Join("; ", defects));
|
||||
return parts.Count > 0 ? string.Join("\n", parts) : null;
|
||||
}
|
||||
|
||||
private static string BuildProvenance(Rpro3Data data, List<Rpro3Animal> members, Rpro3Dedup.DedupResult dedup)
|
||||
{
|
||||
var rep = Rpro3Dedup.Representative(members);
|
||||
var obj = new Dictionary<string, object?>
|
||||
{
|
||||
["importSource"] = "RennmausPro III",
|
||||
["mergedRecordCount"] = members.Count,
|
||||
["rpro3Ids"] = members.Select(m => m.Rid).Take(8).ToList(),
|
||||
["mother"] = data.ResolveName(rep.MidRaw),
|
||||
["father"] = data.ResolveName(rep.PidRaw),
|
||||
};
|
||||
if (members.Count > 1)
|
||||
obj["note"] = $"{members.Count} RennmausPro-Datensätze zusammengelegt (Name + Geburtsdatum + Farbe + Herkunft).";
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static string JsonProvenance(string source, Dictionary<string, object?> extra)
|
||||
{
|
||||
extra["importSource"] = source;
|
||||
return JsonSerializer.Serialize(extra);
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||
}
|
||||
}
|
||||
246
GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs
Normal file
246
GerbilManagerWebAPI/Import/Rpro3/Rpro3Models.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Quelle eines RPRO3-Tier-Datensatzes. „stamm" = eigenes Tier, „fremd" = externer Ahn,
|
||||
/// „wurftier" = Welpe eines Wurfs (nur relevant, wenn nicht in ein stamm-Tier überführt).
|
||||
/// </summary>
|
||||
public enum Rpro3Src { Stamm, Fremd, Wurftier }
|
||||
|
||||
/// <summary>Einheitlicher Tier-Datensatz aus der RPRO3-SQLite (vor Dedup).
|
||||
/// `Rid` ist die RPRO3-ID im jeweiligen Namensraum: "N"=stamm, "uN"=fremd, "XjY"=wurftier.</summary>
|
||||
public sealed class Rpro3Animal
|
||||
{
|
||||
public required string Rid { get; init; }
|
||||
public Rpro3Src Src { get; init; }
|
||||
public string? Name { get; set; }
|
||||
public Gender Gender { get; set; }
|
||||
public DateOnly? Dob { get; set; }
|
||||
public string? Farbe { get; set; } // _FARBE Farbschlagname
|
||||
public string? Fcode { get; set; } // _FCODE 8-Locus-Genotyp
|
||||
public string? Origin { get; set; } // aufgelöster Herkunfts-Name (herk_tb)
|
||||
public int? OriginHerkId { get; set; } // herk_tb.id (1 = eigene Zucht)
|
||||
public string? Zb { get; set; }
|
||||
public string? MidRaw { get; set; } // Mutter-Ref im RPRO3-Namensraum
|
||||
public string? PidRaw { get; set; } // Vater-Ref im RPRO3-Namensraum
|
||||
public DateOnly? DateOfDeath { get; set; }
|
||||
public string? CauseOfDeath { get; set; }
|
||||
public string? Defects { get; set; } // _FEHLER
|
||||
public bool IsCastrated { get; set; }
|
||||
public int? StatusCode { get; set; } // stamm._STATUS (RPRO3)
|
||||
|
||||
// Dedup-Schlüssel (normalisiert), wird von Rpro3Dedup gesetzt.
|
||||
public string NameKey { get; set; } = "";
|
||||
public string FarbeKey { get; set; } = "";
|
||||
public string OriginKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class Rpro3Litter
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? Name { get; set; } // _BEZ "Wurf A"
|
||||
public DateOnly? Date { get; set; } // _AM
|
||||
public string? MotherRid { get; set; } // stamm-id als string
|
||||
public string? FatherRid { get; set; }
|
||||
public string? Notes { get; set; } // _BEM
|
||||
}
|
||||
|
||||
public sealed class Rpro3Pup
|
||||
{
|
||||
public required string Id { get; init; } // "XjY"
|
||||
public int WurfId { get; init; }
|
||||
public string? Name { get; set; }
|
||||
public Gender Gender { get; set; }
|
||||
public DateOnly? Dob { get; set; }
|
||||
public string? Sid { get; set; } // → stamm-id, falls als eigenes Tier behalten
|
||||
public DateOnly? DateOfDeath { get; set; }
|
||||
public string? CauseOfDeath { get; set; }
|
||||
public DateOnly? AbgabeDate { get; set; }
|
||||
public int? AbnId { get; set; } // → abn_tb
|
||||
public decimal? Price { get; set; }
|
||||
public string? Zb { get; set; }
|
||||
public string? Defects { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Rpro3Contact
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public required string Namespace { get; init; } // "herk" | "abn"
|
||||
public string? Bez { get; set; }
|
||||
public string? Anrede { get; set; }
|
||||
public string? Vname { get; set; }
|
||||
public string? Nname { get; set; }
|
||||
public string? Clan { get; set; }
|
||||
public string? Str { get; set; }
|
||||
public string? Ort { get; set; }
|
||||
public string? Plz { get; set; }
|
||||
public string? Telp { get; set; }
|
||||
public string? Teld { get; set; }
|
||||
public string? Mail { get; set; }
|
||||
public string? Http { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public string? Bem { get; set; }
|
||||
|
||||
/// <summary>Anzeigename: Bezeichnung/Cattery, sonst Clan, sonst Vor+Nachname.</summary>
|
||||
public string DisplayName =>
|
||||
FirstNonEmpty(Bez, Clan, $"{Vname} {Nname}".Trim()) ?? "Unbekannt";
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] vals) =>
|
||||
vals.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class Rpro3Weight
|
||||
{
|
||||
public required string Tid { get; init; } // stamm-id (waage) bzw. wurftier-id (jungwaage)
|
||||
public DateOnly? Date { get; set; }
|
||||
public int Grams { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Rpro3Health
|
||||
{
|
||||
public required string Tid { get; init; }
|
||||
public DateOnly? Date { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Medication { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Rpro3Diary
|
||||
{
|
||||
public required string Tid { get; init; }
|
||||
public DateOnly? Date { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Rpro3PhotoSet
|
||||
{
|
||||
public required string Tid { get; init; } // stamm-id
|
||||
public List<string> FileNames { get; } = new();
|
||||
}
|
||||
|
||||
// ── Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz) ──
|
||||
|
||||
/// <summary>becken_tb: Gehege/Becken (id, _BEZ Name, _SIZE Maße, _MENGE Kapazität,
|
||||
/// _CLEANED letzte Reinigung JDN, _CYCLUS Reinigungsintervall Tage).</summary>
|
||||
public sealed class Rpro3Becken
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? Name { get; set; }
|
||||
public string? Size { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public DateOnly? LastCleaned { get; set; }
|
||||
public int? CycleDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>herktier_tb: Erwerb eines Tieres (_TID stamm-id, _DATE Kaufdatum JDN,
|
||||
/// _PRICE Kaufpreis, _BEM Notiz).</summary>
|
||||
public sealed class Rpro3Acquisition
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? Tid { get; set; } // stamm-id
|
||||
public DateOnly? Date { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>abgeben_tb: Reservierung/Vormerkung (tid stamm- oder wurftier-id, _RES reserviert?,
|
||||
/// _TERMIN Abgabetermin JDN, _ABN Abnehmer-Kontakt, _BEM Notiz).</summary>
|
||||
public sealed class Rpro3Abgeben
|
||||
{
|
||||
public required string Tid { get; init; } // stamm-id ("N") oder wurftier-id ("XjY")
|
||||
public bool Reserved { get; set; }
|
||||
public DateOnly? Appointment { get; set; }
|
||||
public int? AbnId { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>abstat_tb: Abgabe-Abschluss (tid stamm-id, _IDABN Abnehmer-Kontakt,
|
||||
/// _AM Abgabedatum JDN, _PRICE Abgabepreis, _BEM Notiz).</summary>
|
||||
public sealed class Rpro3Abstat
|
||||
{
|
||||
public required string Tid { get; init; } // stamm-id
|
||||
public int? AbnId { get; set; }
|
||||
public DateOnly? HandedOver { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>getback_tb: Rücknahme (_TID stamm-/wurftier-id, _ZAM Rücknahmedatum JDN,
|
||||
/// _ZPREIS Rücknahmepreis, _AM ursprüngliches Abgabedatum JDN, _PREIS ursprünglicher Preis,
|
||||
/// _ABN Kontakt, von dem zurückkam, _BEM Notiz).</summary>
|
||||
public sealed class Rpro3Getback
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? Tid { get; set; } // stamm- oder wurftier-id
|
||||
public DateOnly? ReturnDate { get; set; }
|
||||
public decimal? ReturnPrice { get; set; }
|
||||
public DateOnly? OriginalSaleDate { get; set; }
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
public int? AbnId { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>nachfrage_tb: Warteliste/Nachfrage (_COLOR Wunschfarbe, _SEX Wunschgeschlecht,
|
||||
/// _ABN Interessenten-Kontakt, _VOM Anfragedatum JDN, _STATUS Status, _BEM/_CODE Notiz).</summary>
|
||||
public sealed class Rpro3Nachfrage
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? WishColor { get; set; }
|
||||
public Gender WishGender { get; set; }
|
||||
public int? AbnId { get; set; }
|
||||
public DateOnly? RequestedAt { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>ausz_tb: Ausstellungsergebnis (_TID stamm-/wurftier-id, _VERANSTALTUNG Veranstaltung,
|
||||
/// _DATE Datum JDN, _ORT Ort, _PLATZ Platzierung, _AUSZ Auszeichnung, _JUROR/_EXTRA/_BEM Notiz).</summary>
|
||||
public sealed class Rpro3Ausstellung
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public string? Tid { get; set; }
|
||||
public string? EventName { get; set; }
|
||||
public DateOnly? Date { get; set; }
|
||||
public string? Place { get; set; } // _ORT
|
||||
public string? Placement { get; set; } // _PLATZ
|
||||
public string? Award { get; set; } // _AUSZ
|
||||
public string? Juror { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Vollständig geparster RPRO3-Backup-Inhalt.</summary>
|
||||
public sealed class Rpro3Data
|
||||
{
|
||||
public List<Rpro3Animal> Animals { get; } = new(); // stamm + fremd
|
||||
public Dictionary<int, Rpro3Litter> Litters { get; } = new();
|
||||
public Dictionary<string, Rpro3Pup> Pups { get; } = new();
|
||||
public Dictionary<string, Rpro3Contact> HerkContacts { get; } = new();
|
||||
public Dictionary<string, Rpro3Contact> AbnContacts { get; } = new();
|
||||
public List<Rpro3Weight> Weights { get; } = new();
|
||||
public List<Rpro3Health> Health { get; } = new();
|
||||
public List<Rpro3Diary> Diary { get; } = new();
|
||||
public Dictionary<string, Rpro3PhotoSet> Photos { get; } = new();
|
||||
|
||||
// Feature-Tabellen.
|
||||
public List<Rpro3Becken> Becken { get; } = new();
|
||||
public List<Rpro3Acquisition> Acquisitions { get; } = new();
|
||||
public List<Rpro3Abgeben> Abgeben { get; } = new();
|
||||
public List<Rpro3Abstat> Abstat { get; } = new();
|
||||
public List<Rpro3Getback> Getback { get; } = new();
|
||||
public List<Rpro3Nachfrage> Nachfrage { get; } = new();
|
||||
public List<Rpro3Ausstellung> Ausstellungen { get; } = new();
|
||||
|
||||
/// <summary>stamm-id (string) → becken_tb.id (Gehege-Zuordnung aus stamm_tb._BECKEN).
|
||||
/// Nur positive, gültige Becken-IDs.</summary>
|
||||
public Dictionary<string, int> StammBecken { get; } = new();
|
||||
|
||||
// Roh-Zählungen (vor Dedup) für den Report.
|
||||
public int ColorStammCount { get; set; }
|
||||
public int ColorExtCount { get; set; }
|
||||
|
||||
/// <summary>Löst eine RPRO3-Ref ("N"/"uN"/"XjY") in einen Anzeigenamen auf (für Provenance/Report).</summary>
|
||||
public Func<string?, string?> ResolveName { get; set; } = _ => null;
|
||||
}
|
||||
}
|
||||
613
GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs
Normal file
613
GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs
Normal file
@@ -0,0 +1,613 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte
|
||||
/// `_rpro3.db` direkt und überführt sie in <see cref="Rpro3Data"/>.
|
||||
///
|
||||
/// KRITISCHE PARSING-DETAILS (Port von tools/import/compare_rpro3.py):
|
||||
/// 1. DATUM: _BIRTH/_AM/_DATE/_TODAM/_ABAM sind astronomische Julianische Tageszahlen (REAL).
|
||||
/// 0/None = unbekannt. date = fromordinal(round(jdn) - 1721425).
|
||||
/// 2. ENCODING (gemischt!): TEXT teils UTF-8 (neuer), teils CP1252 (älter). Wir lesen jede
|
||||
/// Spalte als BLOB (Bytes) und dekodieren erst strikt UTF-8, bei Fehler CP1252
|
||||
/// (siehe smart_decode). Microsoft.Data.Sqlite würde TEXT sonst als UTF-8 erzwingen → Mojibake.
|
||||
/// 3. ID-NAMENSRÄUME: "N"=stamm_tb, "uN"=fremd_tb, "XjY"=wurftier_tb.
|
||||
/// </summary>
|
||||
public sealed class Rpro3Reader
|
||||
{
|
||||
private static bool _cp1252Registered;
|
||||
|
||||
public Rpro3Reader()
|
||||
{
|
||||
// CP1252 ist auf .NET Core nicht ohne CodePagesEncodingProvider verfügbar.
|
||||
if (!_cp1252Registered)
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
_cp1252Registered = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und
|
||||
/// parst sie. Wirft <see cref="Rpro3FormatException"/>, wenn keine DB gefunden wurde.
|
||||
/// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn).</summary>
|
||||
public Rpro3Data ReadFromBackup(Stream backupStream, out string workDir)
|
||||
{
|
||||
workDir = Path.Combine(Path.GetTempPath(), "rpro3-import-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(backupStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var dbEntry = archive.Entries.FirstOrDefault(e =>
|
||||
e.Name.Equals("_rpro3.db", StringComparison.OrdinalIgnoreCase))
|
||||
?? archive.Entries.FirstOrDefault(e =>
|
||||
e.Name.EndsWith(".db", StringComparison.OrdinalIgnoreCase));
|
||||
if (dbEntry is null)
|
||||
throw new Rpro3FormatException(
|
||||
"In der hochgeladenen Datei wurde keine RennmausPro-Datenbank (_rpro3.db) gefunden.");
|
||||
|
||||
var dbPath = Path.Combine(workDir, "_rpro3.db");
|
||||
dbEntry.ExtractToFile(dbPath, overwrite: true);
|
||||
return ReadFromDbFile(dbPath);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
throw new Rpro3FormatException(
|
||||
"Die Datei ist kein gültiges RennmausPro-Backup (kein lesbares ZIP-Archiv).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad).</summary>
|
||||
public Rpro3Data ReadFromDbFile(string dbPath)
|
||||
{
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = dbPath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
}.ToString();
|
||||
|
||||
using var conn = new SqliteConnection(connectionString);
|
||||
conn.Open();
|
||||
return Parse(conn);
|
||||
}
|
||||
|
||||
// ---------- Decoding helpers (Port von smart_decode / jdn_to_date) ----------
|
||||
|
||||
/// <summary>RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252.</summary>
|
||||
internal static string? SmartDecode(byte[]? bytes)
|
||||
{
|
||||
if (bytes is null || bytes.Length == 0) return bytes is null ? null : "";
|
||||
try
|
||||
{
|
||||
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
|
||||
.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return Encoding.GetEncoding(1252).GetString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>JDN (REAL) → DateOnly. 0/None/ungültig = null.</summary>
|
||||
internal static DateOnly? JdnToDate(double? jdn)
|
||||
{
|
||||
if (jdn is null || jdn.Value <= 0) return null;
|
||||
// Python: date.fromordinal(round(jdn) - 1721425). .NET-Ordinaltage zählen ab 0001-01-01
|
||||
// mit DateOnly.FromDayNumber(n) wobei DayNumber 0 = 0001-01-01 (= Python-ordinal 1).
|
||||
// → dayNumber = round(jdn) - 1721425 - 1.
|
||||
long dayNumber = (long)Math.Round(jdn.Value) - 1721425 - 1;
|
||||
if (dayNumber < 0 || dayNumber > DateOnly.MaxValue.DayNumber) return null;
|
||||
return DateOnly.FromDayNumber((int)dayNumber);
|
||||
}
|
||||
|
||||
private static Gender ParseGender(string? sex) => (sex ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"männlich" or "maennlich" or "m" or "male" => Gender.male,
|
||||
"weiblich" or "w" or "f" or "female" => Gender.female,
|
||||
_ => Gender.unknown,
|
||||
};
|
||||
|
||||
// ---------- BLOB-aware accessors ----------
|
||||
|
||||
private static string? Str(SqliteDataReader r, int ord)
|
||||
{
|
||||
if (r.IsDBNull(ord)) return null;
|
||||
// Als Bytes lesen → smart decode (UTF-8/CP1252).
|
||||
using var stream = r.GetStream(ord);
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
return SmartDecode(ms.ToArray());
|
||||
}
|
||||
|
||||
private static double? Real(SqliteDataReader r, int ord)
|
||||
{
|
||||
if (r.IsDBNull(ord)) return null;
|
||||
try { return r.GetDouble(ord); }
|
||||
catch { return double.TryParse(Str(r, ord), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; }
|
||||
}
|
||||
|
||||
private static int? IntOrNull(SqliteDataReader r, int ord)
|
||||
{
|
||||
if (r.IsDBNull(ord)) return null;
|
||||
try { return (int)r.GetInt64(ord); }
|
||||
catch
|
||||
{
|
||||
var s = Str(r, ord);
|
||||
return int.TryParse(s, out var v) ? v : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal? DecimalOrNull(SqliteDataReader r, int ord)
|
||||
{
|
||||
if (r.IsDBNull(ord)) return null;
|
||||
try { return (decimal)r.GetDouble(ord); }
|
||||
catch
|
||||
{
|
||||
var s = Str(r, ord);
|
||||
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Map column name → ordinal once per query (RPRO3 column order is stable but we stay safe).
|
||||
private static Func<SqliteDataReader, string, int> OrdinalLookup(SqliteDataReader r)
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < r.FieldCount; i++) map[r.GetName(i)] = i;
|
||||
return (rr, name) => map.TryGetValue(name, out var o) ? o : -1;
|
||||
}
|
||||
|
||||
// ---------- Parse ----------
|
||||
|
||||
private Rpro3Data Parse(SqliteConnection conn)
|
||||
{
|
||||
var data = new Rpro3Data();
|
||||
|
||||
// herk_tb → Anzeigename + voller Kontakt
|
||||
var herkName = new Dictionary<int, string>();
|
||||
foreach (var (id, c) in ReadContacts(conn, "herk_tb", "herk", "_HERK_IGNORED"))
|
||||
{
|
||||
data.HerkContacts[c.Id.ToString()] = c;
|
||||
herkName[id] = c.DisplayName;
|
||||
}
|
||||
foreach (var (_, c) in ReadContacts(conn, "abn_tb", "abn", "_ABN_IGNORED"))
|
||||
data.AbnContacts[c.Id.ToString()] = c;
|
||||
|
||||
// baum_tb: master parent table (id → Mutter/Vater im Namensraum)
|
||||
var baum = new Dictionary<string, (string? mid, string? pid)>();
|
||||
Query(conn, "SELECT id, _MID, _PID FROM baum_tb", (r, ord) =>
|
||||
{
|
||||
var id = Str(r, ord(r, "id"));
|
||||
if (id is not null) baum[id] = (Str(r, ord(r, "_MID")), Str(r, ord(r, "_PID")));
|
||||
});
|
||||
|
||||
// Farben: color_tb (stamm), wurfcolor_tb (pup), fremdcolor_tb (extern)
|
||||
var colorStamm = ReadColors(conn, "color_tb");
|
||||
var colorPup = ReadColors(conn, "wurfcolor_tb");
|
||||
var colorExt = ReadColors(conn, "fremdcolor_tb");
|
||||
data.ColorStammCount = colorStamm.Count;
|
||||
data.ColorExtCount = colorExt.Count;
|
||||
|
||||
(string farbe, string fcode) ColorFor(string rid)
|
||||
{
|
||||
Dictionary<string, (string, string)> src =
|
||||
rid.StartsWith('u') ? colorExt : rid.Contains('j') ? colorPup : colorStamm;
|
||||
return src.TryGetValue(rid, out var v) ? v : ("", "");
|
||||
}
|
||||
|
||||
// Tod (stamm/fremd nutzen tod_tb via tid = stamm-id; fremd hat eigenes _TODAM)
|
||||
var tod = new Dictionary<string, (DateOnly? am, string? warum)>();
|
||||
Query(conn, "SELECT tid, _AM, _WARUM FROM tod_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "tid"));
|
||||
if (tid is not null) tod[tid] = (JdnToDate(Real(r, ord(r, "_AM"))), Str(r, ord(r, "_WARUM")));
|
||||
});
|
||||
|
||||
// stamm_tb: Namen für Eltern-Auflösung
|
||||
var stammName = new Dictionary<int, string?>();
|
||||
var fremdName = new Dictionary<int, string?>();
|
||||
var pupName = new Dictionary<string, string?>();
|
||||
|
||||
// stamm_tb (eigene Tiere)
|
||||
Query(conn,
|
||||
"SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE,_BECKEN FROM stamm_tb",
|
||||
(r, ord) =>
|
||||
{
|
||||
var sid = IntOrNull(r, ord(r, "id"));
|
||||
if (sid is null) return;
|
||||
var rid = sid.Value.ToString();
|
||||
var name = Str(r, ord(r, "_NAME"));
|
||||
stammName[sid.Value] = name;
|
||||
var beckenId = IntOrNull(r, ord(r, "_BECKEN"));
|
||||
if (beckenId is > 0) data.StammBecken[rid] = beckenId.Value;
|
||||
var (farbe, fcode) = ColorFor(rid);
|
||||
baum.TryGetValue(rid, out var par);
|
||||
var herkId = IntOrNull(r, ord(r, "_HERKUNFT"));
|
||||
tod.TryGetValue(rid, out var death);
|
||||
data.Animals.Add(new Rpro3Animal
|
||||
{
|
||||
Rid = rid,
|
||||
Src = Rpro3Src.Stamm,
|
||||
Name = name,
|
||||
Gender = ParseGender(Str(r, ord(r, "_SEX"))),
|
||||
Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
|
||||
Farbe = farbe,
|
||||
Fcode = fcode,
|
||||
Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "",
|
||||
OriginHerkId = herkId,
|
||||
Zb = Str(r, ord(r, "_ZB")),
|
||||
MidRaw = par.mid,
|
||||
PidRaw = par.pid,
|
||||
DateOfDeath = death.am,
|
||||
CauseOfDeath = death.warum,
|
||||
Defects = Str(r, ord(r, "_FEHLER")),
|
||||
IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null,
|
||||
StatusCode = IntOrNull(r, ord(r, "_STATUS")),
|
||||
});
|
||||
});
|
||||
|
||||
// fremd_tb (externe Ahnen)
|
||||
Query(conn,
|
||||
"SELECT id,_NAME,_SEX,_BIRTH,_ZB,_HERK,_MID,_PID,_TODAM,_KASTRAT_DATE FROM fremd_tb",
|
||||
(r, ord) =>
|
||||
{
|
||||
var fid = IntOrNull(r, ord(r, "id"));
|
||||
if (fid is null) return;
|
||||
var rid = "u" + fid.Value;
|
||||
var name = Str(r, ord(r, "_NAME"));
|
||||
fremdName[fid.Value] = name;
|
||||
var (farbe, fcode) = ColorFor(rid);
|
||||
var herkId = IntOrNull(r, ord(r, "_HERK"));
|
||||
data.Animals.Add(new Rpro3Animal
|
||||
{
|
||||
Rid = rid,
|
||||
Src = Rpro3Src.Fremd,
|
||||
Name = name,
|
||||
Gender = ParseGender(Str(r, ord(r, "_SEX"))),
|
||||
Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
|
||||
Farbe = farbe,
|
||||
Fcode = fcode,
|
||||
Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "",
|
||||
OriginHerkId = herkId,
|
||||
Zb = Str(r, ord(r, "_ZB")),
|
||||
MidRaw = Str(r, ord(r, "_MID")),
|
||||
PidRaw = Str(r, ord(r, "_PID")),
|
||||
DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))),
|
||||
IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null,
|
||||
});
|
||||
});
|
||||
|
||||
// wurf_tb (Würfe)
|
||||
Query(conn, "SELECT id,_MID,_PID,_AM,_BEZ,_BEM FROM wurf_tb", (r, ord) =>
|
||||
{
|
||||
var wid = IntOrNull(r, ord(r, "id"));
|
||||
if (wid is null) return;
|
||||
data.Litters[wid.Value] = new Rpro3Litter
|
||||
{
|
||||
Id = wid.Value,
|
||||
Name = Str(r, ord(r, "_BEZ")),
|
||||
Date = JdnToDate(Real(r, ord(r, "_AM"))),
|
||||
MotherRid = Str(r, ord(r, "_MID")),
|
||||
FatherRid = Str(r, ord(r, "_PID")),
|
||||
Notes = Str(r, ord(r, "_BEM")),
|
||||
};
|
||||
});
|
||||
|
||||
// wurftier_tb (Welpen)
|
||||
Query(conn,
|
||||
"SELECT id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_WARUM,_ABAM,_ABN,_PRICE,_ZB,_FEHLER FROM wurftier_tb",
|
||||
(r, ord) =>
|
||||
{
|
||||
var id = Str(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
var name = Str(r, ord(r, "_NAME"));
|
||||
pupName[id] = name;
|
||||
data.Pups[id] = new Rpro3Pup
|
||||
{
|
||||
Id = id,
|
||||
WurfId = IntOrNull(r, ord(r, "_WID")) ?? 0,
|
||||
Name = name,
|
||||
Gender = ParseGender(Str(r, ord(r, "_SEX"))),
|
||||
Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
|
||||
Sid = Str(r, ord(r, "_SID")),
|
||||
DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))),
|
||||
CauseOfDeath = Str(r, ord(r, "_WARUM")),
|
||||
AbgabeDate = JdnToDate(Real(r, ord(r, "_ABAM"))),
|
||||
AbnId = IntOrNull(r, ord(r, "_ABN")),
|
||||
Price = DecimalOrNull(r, ord(r, "_PRICE")),
|
||||
Zb = Str(r, ord(r, "_ZB")),
|
||||
Defects = Str(r, ord(r, "_FEHLER")),
|
||||
};
|
||||
});
|
||||
|
||||
// Eltern-Namen auflösen (für Report / Provenance)
|
||||
string? NameFor(string? rid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL") return null;
|
||||
if (rid.StartsWith('u'))
|
||||
return int.TryParse(rid[1..], out var fid) && fremdName.TryGetValue(fid, out var n) ? n : null;
|
||||
if (rid.Contains('j'))
|
||||
return pupName.TryGetValue(rid, out var n) ? n : null;
|
||||
return int.TryParse(rid, out var sid) && stammName.TryGetValue(sid, out var sn) ? sn : null;
|
||||
}
|
||||
data.ResolveName = NameFor;
|
||||
|
||||
// Gewichte: waage_tb (_TID = stamm-id) + jungwaage_tb (_TID = wurftier-id "XjY" oder via _WID)
|
||||
Query(conn, "SELECT _TID,_GRAMM,_DATE,_BEM FROM waage_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "_TID"));
|
||||
if (tid is null) return;
|
||||
data.Weights.Add(new Rpro3Weight
|
||||
{
|
||||
Tid = tid,
|
||||
Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0),
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Notes = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
Query(conn, "SELECT _TID,_WID,_GRAMM,_DATE,_BEM FROM jungwaage_tb", (r, ord) =>
|
||||
{
|
||||
// _TID ist hier i. d. R. die laufende Pup-Nr im Wurf; _WID der Wurf → "WIDjTID".
|
||||
var tidRaw = Str(r, ord(r, "_TID"));
|
||||
var widRaw = IntOrNull(r, ord(r, "_WID"));
|
||||
string? tid = tidRaw is not null && tidRaw.Contains('j') ? tidRaw
|
||||
: widRaw is not null && tidRaw is not null ? $"{widRaw}j{tidRaw}" : null;
|
||||
if (tid is null) return;
|
||||
data.Weights.Add(new Rpro3Weight
|
||||
{
|
||||
Tid = tid,
|
||||
Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0),
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Notes = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
|
||||
// Krankheiten: krank_tb (_TID = stamm-id)
|
||||
Query(conn, "SELECT _TID,_DATE,_BEM,_MED FROM krank_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "_TID"));
|
||||
if (tid is null) return;
|
||||
data.Health.Add(new Rpro3Health
|
||||
{
|
||||
Tid = tid,
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Description = Str(r, ord(r, "_BEM")),
|
||||
Medication = Str(r, ord(r, "_MED")),
|
||||
});
|
||||
});
|
||||
|
||||
// Tagebuch: diary_tb (_TID = stamm-id)
|
||||
Query(conn, "SELECT _TID,_BEZ,_DATE,_DESC FROM diary_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "_TID"));
|
||||
if (tid is null) return;
|
||||
data.Diary.Add(new Rpro3Diary
|
||||
{
|
||||
Tid = tid,
|
||||
Title = Str(r, ord(r, "_BEZ")),
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Description = Str(r, ord(r, "_DESC")),
|
||||
});
|
||||
});
|
||||
|
||||
// Fotos: photo_tb (id = stamm-id, _P1/_P2/_P3 = Bilddateinamen)
|
||||
Query(conn, "SELECT id,_P1,_P2,_P3 FROM photo_tb", (r, ord) =>
|
||||
{
|
||||
var pid = IntOrNull(r, ord(r, "id"));
|
||||
if (pid is null) return;
|
||||
var set = new Rpro3PhotoSet { Tid = pid.Value.ToString() };
|
||||
foreach (var col in new[] { "_P1", "_P2", "_P3" })
|
||||
{
|
||||
var fn = Str(r, ord(r, col));
|
||||
if (!string.IsNullOrWhiteSpace(fn) && fn != "---")
|
||||
set.FileNames.Add(Path.GetFileName(fn.Replace('\\', '/')));
|
||||
}
|
||||
if (set.FileNames.Count > 0) data.Photos[set.Tid] = set;
|
||||
});
|
||||
|
||||
ParseFeatureTables(conn, data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Parst die Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz).
|
||||
/// Jede Tabelle ist optional — fehlt sie in einem älteren Backup, wird sie still übersprungen.</summary>
|
||||
private void ParseFeatureTables(SqliteConnection conn, Rpro3Data data)
|
||||
{
|
||||
// becken_tb: Gehege.
|
||||
QueryOptional(conn, "SELECT id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS FROM becken_tb", (r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
data.Becken.Add(new Rpro3Becken
|
||||
{
|
||||
Id = id.Value,
|
||||
Name = Str(r, ord(r, "_BEZ")),
|
||||
Size = Str(r, ord(r, "_SIZE")),
|
||||
Capacity = IntOrNull(r, ord(r, "_MENGE")),
|
||||
LastCleaned = JdnToDate(Real(r, ord(r, "_CLEANED"))),
|
||||
CycleDays = IntOrNull(r, ord(r, "_CYCLUS")),
|
||||
});
|
||||
});
|
||||
|
||||
// herktier_tb: Erwerb.
|
||||
QueryOptional(conn, "SELECT id,_TID,_DATE,_PRICE,_BEM FROM herktier_tb", (r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
data.Acquisitions.Add(new Rpro3Acquisition
|
||||
{
|
||||
Id = id.Value,
|
||||
Tid = Str(r, ord(r, "_TID")),
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Price = DecimalOrNull(r, ord(r, "_PRICE")),
|
||||
Note = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
|
||||
// abgeben_tb: Reservierung/Vormerkung.
|
||||
QueryOptional(conn, "SELECT tid,_RES,_TERMIN,_ABN,_BEM FROM abgeben_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "tid"));
|
||||
if (string.IsNullOrWhiteSpace(tid)) return;
|
||||
data.Abgeben.Add(new Rpro3Abgeben
|
||||
{
|
||||
Tid = tid,
|
||||
Reserved = (IntOrNull(r, ord(r, "_RES")) ?? 0) != 0,
|
||||
Appointment = JdnToDate(Real(r, ord(r, "_TERMIN"))),
|
||||
AbnId = IntOrNull(r, ord(r, "_ABN")),
|
||||
Note = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
|
||||
// abstat_tb: Abgabe-Abschluss.
|
||||
QueryOptional(conn, "SELECT tid,_IDABN,_AM,_PRICE,_BEM FROM abstat_tb", (r, ord) =>
|
||||
{
|
||||
var tid = Str(r, ord(r, "tid"));
|
||||
if (string.IsNullOrWhiteSpace(tid)) return;
|
||||
data.Abstat.Add(new Rpro3Abstat
|
||||
{
|
||||
Tid = tid,
|
||||
AbnId = IntOrNull(r, ord(r, "_IDABN")),
|
||||
HandedOver = JdnToDate(Real(r, ord(r, "_AM"))),
|
||||
Price = DecimalOrNull(r, ord(r, "_PRICE")),
|
||||
Note = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
|
||||
// getback_tb: Rücknahme.
|
||||
QueryOptional(conn, "SELECT id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM FROM getback_tb", (r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
data.Getback.Add(new Rpro3Getback
|
||||
{
|
||||
Id = id.Value,
|
||||
Tid = Str(r, ord(r, "_TID")),
|
||||
ReturnDate = JdnToDate(Real(r, ord(r, "_ZAM"))),
|
||||
ReturnPrice = DecimalOrNull(r, ord(r, "_ZPREIS")),
|
||||
OriginalSaleDate = JdnToDate(Real(r, ord(r, "_AM"))),
|
||||
OriginalPrice = DecimalOrNull(r, ord(r, "_PREIS")),
|
||||
AbnId = IntOrNull(r, ord(r, "_ABN")),
|
||||
Note = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
|
||||
// nachfrage_tb: Warteliste. _VOM ist das Anfragedatum (_DATE ist hier i. d. R. 0).
|
||||
// Hat keine eigene _BEM-Spalte; _CODE (z. B. "egal") dient als Zusatznotiz.
|
||||
QueryOptional(conn, "SELECT id,_COLOR,_SEX,_ABN,_VOM,_DATE,_STATUS,_CODE FROM nachfrage_tb", (r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
var code = Str(r, ord(r, "_CODE"));
|
||||
data.Nachfrage.Add(new Rpro3Nachfrage
|
||||
{
|
||||
Id = id.Value,
|
||||
WishColor = Str(r, ord(r, "_COLOR")),
|
||||
WishGender = ParseGender(Str(r, ord(r, "_SEX"))),
|
||||
AbnId = IntOrNull(r, ord(r, "_ABN")),
|
||||
RequestedAt = JdnToDate(Real(r, ord(r, "_VOM"))) ?? JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Status = Str(r, ord(r, "_STATUS")),
|
||||
Note = string.IsNullOrWhiteSpace(code) || code == "egal" ? null : code,
|
||||
});
|
||||
});
|
||||
|
||||
// ausz_tb: Ausstellungsergebnisse (in vielen Backups leer — robust gegen 0 Zeilen).
|
||||
QueryOptional(conn, "SELECT id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM FROM ausz_tb", (r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
data.Ausstellungen.Add(new Rpro3Ausstellung
|
||||
{
|
||||
Id = id.Value,
|
||||
Tid = Str(r, ord(r, "_TID")),
|
||||
EventName = Str(r, ord(r, "_VERANSTALTUNG")),
|
||||
Date = JdnToDate(Real(r, ord(r, "_DATE"))),
|
||||
Place = Str(r, ord(r, "_ORT")),
|
||||
Placement = Str(r, ord(r, "_PLATZ")),
|
||||
Award = Str(r, ord(r, "_AUSZ")),
|
||||
Juror = Str(r, ord(r, "_JUROR")),
|
||||
Note = Str(r, ord(r, "_BEM")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
|
||||
{
|
||||
var dict = new Dictionary<string, (string, string)>();
|
||||
Query(conn, $"SELECT id,_FARBE,_FCODE FROM {table}", (r, ord) =>
|
||||
{
|
||||
var id = Str(r, ord(r, "id"));
|
||||
if (id is not null)
|
||||
dict[id] = (Str(r, ord(r, "_FARBE")) ?? "", Str(r, ord(r, "_FCODE")) ?? "");
|
||||
});
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static IEnumerable<(int id, Rpro3Contact c)> ReadContacts(
|
||||
SqliteConnection conn, string table, string ns, string _)
|
||||
{
|
||||
var list = new List<(int, Rpro3Contact)>();
|
||||
Query(conn,
|
||||
$"SELECT id,_BEZ,_ANREDE,_VNAME,_NNAME,_CLAN,_STR,_ORT,_PLZ,_TELP,_TELD,_MAIL,_HTTP,_MEMO,_BEM FROM {table}",
|
||||
(r, ord) =>
|
||||
{
|
||||
var id = IntOrNull(r, ord(r, "id"));
|
||||
if (id is null) return;
|
||||
list.Add((id.Value, new Rpro3Contact
|
||||
{
|
||||
Id = id.Value,
|
||||
Namespace = ns,
|
||||
Bez = Str(r, ord(r, "_BEZ")),
|
||||
Anrede = Str(r, ord(r, "_ANREDE")),
|
||||
Vname = Str(r, ord(r, "_VNAME")),
|
||||
Nname = Str(r, ord(r, "_NNAME")),
|
||||
Clan = Str(r, ord(r, "_CLAN")),
|
||||
Str = Str(r, ord(r, "_STR")),
|
||||
Ort = Str(r, ord(r, "_ORT")),
|
||||
Plz = Str(r, ord(r, "_PLZ")),
|
||||
Telp = Str(r, ord(r, "_TELP")),
|
||||
Teld = Str(r, ord(r, "_TELD")),
|
||||
Mail = Str(r, ord(r, "_MAIL")),
|
||||
Http = Str(r, ord(r, "_HTTP")),
|
||||
Memo = Str(r, ord(r, "_MEMO")),
|
||||
Bem = Str(r, ord(r, "_BEM")),
|
||||
}));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void Query(
|
||||
SqliteConnection conn, string sql,
|
||||
Action<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
using var r = cmd.ExecuteReader();
|
||||
Func<SqliteDataReader, string, int>? ord = null;
|
||||
while (r.Read())
|
||||
{
|
||||
ord ??= OrdinalLookup(r);
|
||||
onRow(r, ord);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wie <see cref="Query"/>, ignoriert aber fehlende Tabellen/Spalten
|
||||
/// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt.</summary>
|
||||
private static void QueryOptional(
|
||||
SqliteConnection conn, string sql,
|
||||
Action<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
|
||||
{
|
||||
try { Query(conn, sql, onRow); }
|
||||
catch (SqliteException) { /* Tabelle/Spalte fehlt in diesem Backup → überspringen */ }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Rpro3FormatException : Exception
|
||||
{
|
||||
public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
}
|
||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UniqueExternalRef : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_ExternalRef",
|
||||
table: "Gerbils",
|
||||
column: "ExternalRef",
|
||||
unique: true,
|
||||
filter: "\"ExternalRef\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Gerbils_ExternalRef",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGermanCollation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalRef",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters",
|
||||
column: "ExternalRef",
|
||||
unique: true,
|
||||
filter: "\"ExternalRef\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterExternalRef : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterLetter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LitterLetter",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LitterLetter",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesAR5 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
column: "Name",
|
||||
value: "Dilute Gold");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
column: "Name",
|
||||
value: "Dilute Platin");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
column: "Name",
|
||||
value: "Dilute Agouti");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
column: "Name",
|
||||
value: "Dilute Silberagouti");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
column: "Name",
|
||||
value: "Dilute Kohlfuchs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
column: "Name",
|
||||
value: "Dilute Anthrazit");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
column: "SortOrder",
|
||||
value: 36);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "SortOrder",
|
||||
value: 37);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "SortOrder",
|
||||
value: 38);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "SortOrder",
|
||||
value: 39);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "SortOrder",
|
||||
value: 40);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
column: "SortOrder",
|
||||
value: 41);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
column: "SortOrder",
|
||||
value: 42);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "SortOrder",
|
||||
value: 43);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
column: "SortOrder",
|
||||
value: 44);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
column: "SortOrder",
|
||||
value: 45);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "SortOrder",
|
||||
value: 46);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
column: "SortOrder",
|
||||
value: 47);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "SortOrder",
|
||||
value: 48);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
column: "SortOrder",
|
||||
value: 49);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
column: "SortOrder",
|
||||
value: 50);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "SortOrder",
|
||||
value: 51);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "Name", "SortOrder" },
|
||||
values: new object[] { "Dilute Topas", 52 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "Name", "SortOrder" },
|
||||
values: new object[] { "Dilute Blaufuchs", 53 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "SortOrder",
|
||||
value: 54);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
column: "SortOrder",
|
||||
value: 55);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "SortOrder",
|
||||
value: 56);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
column: "SortOrder",
|
||||
value: 57);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
column: "SortOrder",
|
||||
value: 59);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
column: "SortOrder",
|
||||
value: 61);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
column: "SortOrder",
|
||||
value: 63);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
column: "SortOrder",
|
||||
value: 65);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
column: "SortOrder",
|
||||
value: 66);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
column: "SortOrder",
|
||||
value: 67);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
column: "SortOrder",
|
||||
value: 68);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
column: "SortOrder",
|
||||
value: 58);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
column: "SortOrder",
|
||||
value: 60);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
column: "SortOrder",
|
||||
value: 62);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
column: "SortOrder",
|
||||
value: 69);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ColorVarieties",
|
||||
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
column: "Name",
|
||||
value: "dd Gold");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
column: "Name",
|
||||
value: "dd Platin");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
column: "Name",
|
||||
value: "Agouti dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
column: "Name",
|
||||
value: "Silberagouti dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
column: "Name",
|
||||
value: "Kohlfuchs dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
column: "Name",
|
||||
value: "Anthrazit dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
column: "SortOrder",
|
||||
value: 32);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "SortOrder",
|
||||
value: 33);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "SortOrder",
|
||||
value: 34);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "SortOrder",
|
||||
value: 35);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "SortOrder",
|
||||
value: 36);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
column: "SortOrder",
|
||||
value: 37);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
column: "SortOrder",
|
||||
value: 38);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "SortOrder",
|
||||
value: 39);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
column: "SortOrder",
|
||||
value: 40);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
column: "SortOrder",
|
||||
value: 41);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "SortOrder",
|
||||
value: 42);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
column: "SortOrder",
|
||||
value: 43);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "SortOrder",
|
||||
value: 44);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
column: "SortOrder",
|
||||
value: 45);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
column: "SortOrder",
|
||||
value: 46);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "SortOrder",
|
||||
value: 47);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "Name", "SortOrder" },
|
||||
values: new object[] { "Topas dd", 48 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "Name", "SortOrder" },
|
||||
values: new object[] { "Blaufuchs dd", 49 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "SortOrder",
|
||||
value: 50);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
column: "SortOrder",
|
||||
value: 51);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "SortOrder",
|
||||
value: 52);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
column: "SortOrder",
|
||||
value: 53);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
column: "SortOrder",
|
||||
value: 54);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
column: "SortOrder",
|
||||
value: 55);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
column: "SortOrder",
|
||||
value: 56);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
column: "SortOrder",
|
||||
value: 57);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
column: "SortOrder",
|
||||
value: 58);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
column: "SortOrder",
|
||||
value: 59);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
column: "SortOrder",
|
||||
value: 60);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
column: "SortOrder",
|
||||
value: 61);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
column: "SortOrder",
|
||||
value: 62);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
column: "SortOrder",
|
||||
value: 63);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
column: "SortOrder",
|
||||
value: 65);
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameREW : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "REW");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "Pink Eyed White (PEW)");
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class StatusModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Rename string value 'Active' → 'Breeding'.
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
|
||||
|
||||
// 2) DateOfDeath set → Deceased (always takes precedence).
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
|
||||
|
||||
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||
WHERE ""Status"" != 'Deceased'
|
||||
AND (""ReceiverContactId"" IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||
|
||||
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
|
||||
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
|
||||
AND ""DateOfBirth"" IS NOT NULL
|
||||
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
|
||||
}
|
||||
}
|
||||
}
|
||||
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterMortality : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSpottingType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SpottingType",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpottingType",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddContactRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBreeder",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReceiver",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBreeder",
|
||||
table: "Contacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReceiver",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsCastrated : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCastrated",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCastrated",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBreedingNameSuffix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BreederSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
column: "NameSuffix",
|
||||
value: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnclosurePhotos : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EnclosurePhotos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Caption = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
|
||||
column: x => x.EnclosureId,
|
||||
principalTable: "Enclosures",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EnclosurePhotos_EnclosureId",
|
||||
table: "EnclosurePhotos",
|
||||
column: "EnclosureId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EnclosurePhotos");
|
||||
}
|
||||
}
|
||||
}
|
||||
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeLitterDateNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<DateOnly>(
|
||||
name: "Date",
|
||||
table: "Litters",
|
||||
type: "date",
|
||||
nullable: true,
|
||||
oldClrType: typeof(DateOnly),
|
||||
oldType: "date");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<DateOnly>(
|
||||
name: "Date",
|
||||
table: "Litters",
|
||||
type: "date",
|
||||
nullable: false,
|
||||
defaultValue: new DateOnly(1, 1, 1),
|
||||
oldClrType: typeof(DateOnly),
|
||||
oldType: "date",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user