Compare commits
82 Commits
2befe53df9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c3f87f01 | |||
| 748ce4733f | |||
| 5a6d1c2541 | |||
| bf074696b4 | |||
| fdad1633cd | |||
| 54e873c115 | |||
| 7a2feac04d | |||
| 0b99cfd2bd | |||
| 1cb9e52117 | |||
| 2e7911074f | |||
| 0a4bcabd98 | |||
| 1484d04917 | |||
| 854da1a000 | |||
| 60214a7c92 | |||
| 45673bb066 | |||
| 45b8533f18 | |||
| 84365bba7f | |||
| a333bce1b3 | |||
| bbd305861f | |||
| 8aaddea4ef | |||
| 54cc493230 | |||
| bb1b8f8c76 | |||
| 00a3919c9f | |||
| b0e92c363a | |||
| 7262b34ae9 | |||
| ebc70cc0b8 | |||
| f9e672c707 | |||
| fe79be2eee | |||
| 41ee4ee3ab | |||
| f7a1a7724b | |||
| 750619d3d7 | |||
| 48b138bbfd | |||
| c0a7b1a5ee | |||
| a0fa33da42 | |||
| 6b264a9b71 | |||
| 26977d73ff | |||
| 12f5de00a0 | |||
| 0540498e85 | |||
| a87d1ec865 | |||
| 8c1ee75b81 | |||
| 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 |
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
214
.claude/skills/ticket-triage/SKILL.md
Normal file
214
.claude/skills/ticket-triage/SKILL.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Ziel-System: lokal ODER Prod (WICHTIG — zuerst klären)
|
||||||
|
|
||||||
|
Die Tickets liegen dort, wo die Züchterin sie meldet — im Regelfall **Prod**
|
||||||
|
(TrueNAS Custom-App). Kläre zu Beginn, gegen welches System der Lauf geht, und
|
||||||
|
setze die **API-Base-URL** entsprechend für ALLE `feedback`/`gerbils`/`litters`-Aufrufe:
|
||||||
|
|
||||||
|
| System | API-Base | Bookmark der Züchterin |
|
||||||
|
|---|---|---|
|
||||||
|
| **Prod** (Regelfall) | `http://truenas:8090/api` | http://truenas:8090/ |
|
||||||
|
| **Lokal** (Aspire) | `http://localhost:5179` | — (App via `dotnet run --project GerbilManager.AppHost`) |
|
||||||
|
|
||||||
|
- **Ticket-CRUD läuft immer gegen das Ziel-System** (Prod: `…/api/feedback`).
|
||||||
|
Einzelticket: `GET /feedback/{id}`; nur die offenen: `GET /feedback?status=Open,Answered`
|
||||||
|
(spart den 2-MB-Volldump). Encoding: über Datei holen + Python-UTF-8 lesen.
|
||||||
|
- **Untersuchung bleibt lokal**: die Quellen (Stammbaum-xlsx, `_rpro3.db`, Wurfchronik,
|
||||||
|
`output/*.json`) liegen lokal. Datenfixes gehören re-ingest-stabil in
|
||||||
|
`conflict-decisions.json` / `rpro3-decisions.json` — NICHT die Live-DB patchen.
|
||||||
|
- **Prod-Re-Ingest ohne SSH** (bevorzugt): lokal `resolved_import.json` generieren
|
||||||
|
(extract → extract_contracts → merge_and_resolve), dann **per HTTP hochladen + ingesten**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||||
|
-F "resolved=@tools/import/output/resolved_import.json" \
|
||||||
|
[-F "photos=@neue_fotos.zip"] # photos optional, nur bei neuen Fotos
|
||||||
|
```
|
||||||
|
Der Endpoint staget die Datei serverseitig und ruft denselben UPSERT-Ingest auf
|
||||||
|
(manuell/verified/feedback überleben). **DB-Copy vom lokalen Stand ist VERBOTEN**,
|
||||||
|
sobald Prod eigenständig kuratiert wurde — es überschreibt die Prod-Kuratierung;
|
||||||
|
nur der Upsert-Ingest bewahrt sie. (Fallback ohne Endpoint: `resolved_import.json`
|
||||||
|
per `scp` + `docker cp` nach `ix-gerbilmanager-api-1:/tools/import/output/` und
|
||||||
|
`POST …/api/import/ingest-resolved`.)
|
||||||
|
- Vor jeder **Prod-Mutation** (Ingest, Ticket-Schließen im großen Stil) kurz beim
|
||||||
|
Nutzer rückversichern.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -23,6 +23,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
# Manueller „Run workflow"-Button in Gitea: testet, baut, pusht und deployt frisch.
|
||||||
|
# Fuer einen Rollback auf ein altes Image stattdessen auf der NAS:
|
||||||
|
# TAG=<git-sha> sh /opt/gerbilmanager/deploy/truenas/scripts/deploy.sh
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.rismer.de
|
REGISTRY: git.rismer.de
|
||||||
@@ -100,7 +104,7 @@ jobs:
|
|||||||
name: Docker Build & Push
|
name: Docker Build & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-backend, test-frontend]
|
needs: [test-backend, test-frontend]
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -133,3 +137,52 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
|
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}
|
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy auf TrueNAS (Custom App)
|
||||||
|
# Laeuft auf dem vorhandenen containerisierten Gitea-Runner (Label ubuntu-latest).
|
||||||
|
# Der Runner hat KEINEN Host-Dateisystem-/midclt-Zugriff, daher: per SSH zum NAS-Host
|
||||||
|
# verbinden, die Deploy-Dateien in das App-Home auf dem Pool kopieren und dort
|
||||||
|
# truenas-deploy.sh ausfuehren (legt die TrueNAS "Custom App" an bzw. rollt sie neu
|
||||||
|
# aus via `midclt` — die App bleibt unter Apps sichtbar).
|
||||||
|
# Hinweis: / (inkl. /opt) ist auf TrueNAS Goldeye read-only -> App-Home auf dem Pool.
|
||||||
|
#
|
||||||
|
# Benoetigte Repo-Secrets (Gitea -> Einstellungen -> Actions -> Secrets):
|
||||||
|
# NAS_SSH_HOST z. B. 192.168.2.115 (Host-LAN-IP der NAS)
|
||||||
|
# NAS_SSH_KEY privater SSH-Deploy-Key (Pubkey liegt in /root/.ssh/authorized_keys)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-and-push]
|
||||||
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||||
|
env:
|
||||||
|
APP_HOME: /mnt/JailStorage/DockerVolumes/gerbilmanager
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: SSH-Client + Key einrichten
|
||||||
|
run: |
|
||||||
|
# Nur openssh-client wird gebraucht (Dateitransfer laeuft unten per "tar | ssh").
|
||||||
|
# FRUEHER stand hier zusaetzlich rsync im apt-Aufruf, der aber nie lief: der Guard
|
||||||
|
# prueft nur `ssh`, und ssh ist im Runner-Image vorhanden => "rsync: command not
|
||||||
|
# found" kippte den Deploy-Job (Run 107). Deshalb kein rsync mehr.
|
||||||
|
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client)
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.NAS_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||||
|
chmod 600 ~/.ssh/id_deploy
|
||||||
|
ssh-keyscan -H "${{ secrets.NAS_SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Deploy-Dateien auf den NAS-Host kopieren
|
||||||
|
run: |
|
||||||
|
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||||
|
SSH="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new"
|
||||||
|
$SSH "$NAS" "mkdir -p $APP_HOME/deploy/truenas/scripts"
|
||||||
|
# tar-over-ssh statt rsync: rsync fehlt im Runner-Image, tar gibt es auf beiden
|
||||||
|
# Seiten. -o beim Entpacken => Dateien gehoeren root; Modi bleiben erhalten,
|
||||||
|
# die Deploy-Skripte bleiben also ausfuehrbar.
|
||||||
|
tar -C deploy/truenas -cf - custom-app.compose.yaml scripts \
|
||||||
|
| $SSH "$NAS" "tar -C $APP_HOME/deploy/truenas -xof -"
|
||||||
|
|
||||||
|
- name: Custom App neu ausrollen (midclt create/redeploy + Health-Check)
|
||||||
|
run: |
|
||||||
|
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||||
|
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new "$NAS" \
|
||||||
|
"sh $APP_HOME/deploy/truenas/scripts/truenas-deploy.sh"
|
||||||
|
|||||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -140,3 +140,24 @@ GerbilManagerWebAPI/.data-protection-keys/
|
|||||||
|
|
||||||
# node_modules — never commit, installed locally per npm install
|
# node_modules — never commit, installed locally per npm install
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Runtime: gespeicherte Vertrags-Dateien
|
||||||
|
GerbilManagerWebAPI/contract-storage/
|
||||||
|
|
||||||
|
# Agent-Worktrees (lokal, nicht versionieren)
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# Lokaler Publish-Output der öffentlichen Seite (Aspire dev)
|
||||||
|
deploy/.local-publicsite/
|
||||||
|
|
||||||
|
# RennmausPro III SQLite-Quelle (lokal, nicht committen)
|
||||||
|
tools/import/_rpro3.db
|
||||||
|
tools/import/_rpro3_extract/
|
||||||
|
tools/import/_rpro3_backup.zip
|
||||||
|
|
||||||
|
# Lokale Scratch-/Werkzeug-Verzeichnisse (nicht versionieren)
|
||||||
|
.agents/
|
||||||
|
tools/rag/
|
||||||
|
|
||||||
|
# Produktions-Secrets (nur auf dem NAS, nie im Repo)
|
||||||
|
deploy/truenas/.env
|
||||||
|
|||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mempalace": {
|
||||||
|
"command": "mempalace-mcp",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
434
CLAUDE.md
Normal file
434
CLAUDE.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## Produktion / Deployment (TrueNAS)
|
||||||
|
|
||||||
|
Prod läuft als **TrueNAS SCALE Custom-App** `gerbilmanager` auf dem Host **`truenas`**
|
||||||
|
(LAN-IP `192.168.2.115`, TrueNAS 25.10 „Goldeye", Docker). Erstinstallation 2026-07-19.
|
||||||
|
|
||||||
|
- **Zugriff (Bookmark der Züchterin):** **http://truenas:8090/** · API-Doku: `/scalar`.
|
||||||
|
(Port 80 ist durch den nginx-Reverse-Proxy der NAS belegt → App auf **8090**, LAN-only, keine Auth.)
|
||||||
|
- **Sichtbar unter Apps**, weil via `midclt call app.create {custom_app:true, app_name:"gerbilmanager",
|
||||||
|
custom_compose_config_string:<yaml>}` angelegt — **nicht** via `docker compose` (das taucht in Apps
|
||||||
|
NICHT auf). Container: `ix-gerbilmanager-{db,api,frontend,backup}-1`.
|
||||||
|
- **5 Dienste:** `db` (postgres:18) · `api` (.NET, EF-Migrationen laufen beim Start) · `frontend`
|
||||||
|
(nginx: SPA + `/api`-Proxy → `api:8080`, Host-Port 8090) · `publicsite` (nginx, **nur intern**,
|
||||||
|
Host-Port **8081** — serviert statisches HTML aus dem `publicsite`-Volume, das die API via
|
||||||
|
`POST /api/publish` schreibt; ohne Publish leer → 404) · `backup` (täglich 03:00 pg_dump +
|
||||||
|
Foto-Archiv, Shell-Scheduler).
|
||||||
|
- **App-Home auf dem Pool:** `/mnt/JailStorage/DockerVolumes/gerbilmanager/` mit
|
||||||
|
`pgdata/ photos/ keys/ backups/ scripts/ publicsite/` und `deploy/truenas/{custom-app.compose.yaml,.env,scripts}`.
|
||||||
|
Images aus der Gitea-Registry `git.rismer.de/gulum/gerbilmanager-{api,frontend}:latest`.
|
||||||
|
|
||||||
|
### Deployen
|
||||||
|
|
||||||
|
**Automatisch (Regelfall):** Push auf `main` → Gitea Actions (`.gitea/workflows/ci.yml`): Tests →
|
||||||
|
Images bauen+pushen → Job `deploy` verbindet sich per SSH auf den NAS-Host und ruft
|
||||||
|
`deploy/truenas/scripts/truenas-deploy.sh` (→ `midclt app.redeploy`, zieht `:latest` neu, `pgdata`/
|
||||||
|
`photos` bleiben erhalten). Braucht die Gitea-Repo-Secrets **`NAS_SSH_HOST`** (=192.168.2.115) und
|
||||||
|
**`NAS_SSH_KEY`** (privater Deploy-Key; Pubkey liegt in `/root/.ssh/authorized_keys`).
|
||||||
|
|
||||||
|
**Manuell (auf dem NAS-Host):**
|
||||||
|
```bash
|
||||||
|
sh /mnt/JailStorage/DockerVolumes/gerbilmanager/deploy/truenas/scripts/truenas-deploy.sh
|
||||||
|
```
|
||||||
|
Legt die App an, wenn sie fehlt (create), sonst redeploy. Rendert `custom-app.compose.yaml` mit den
|
||||||
|
Werten aus `deploy/truenas/.env` (enthält `POSTGRES_PASSWORD` — **nur auf der NAS, nie im Repo**).
|
||||||
|
|
||||||
|
**Lokale DB → Prod kopieren** (einmalig gemacht): `pg_dump` der Aspire-DB → `psql`-Restore in den
|
||||||
|
`ix-gerbilmanager-db-1`-Container (dabei `api` kurz stoppen); Fotos aus `GerbilManagerWebAPI/photo-storage/`
|
||||||
|
per `tar | ssh` nach `…/gerbilmanager/photos/`.
|
||||||
|
|
||||||
|
### Deployment-Stolperfallen
|
||||||
|
- **`/opt` ist auf Goldeye read-only** → App-Home MUSS auf den Pool (`/mnt/JailStorage/...`).
|
||||||
|
- **`postgres:18`** legt PGDATA in `/var/lib/postgresql/18/docker` ab und deklariert das Volume als
|
||||||
|
`/var/lib/postgresql`. Mount auf `/var/lib/postgresql/data` (alte v≤17-Konvention) lässt v18 **nicht
|
||||||
|
starten** ("data in unused mount/volume") → db unhealthy → App-Rollback. Immer `/var/lib/postgresql`
|
||||||
|
mounten. (Debian-Image, nicht alpine → Locale `libc/en_US.utf8` = Quell-DB.)
|
||||||
|
- Gitea-Runner ist **containerisiert** (Label `ubuntu-latest`, kein `goldeye`, kein Host-/`midclt`-Zugriff)
|
||||||
|
und läuft **`maxParallel=1`** (Jobs sequenziell) — daher der SSH-Weg im Deploy-Job.
|
||||||
|
- **Auto-Deploy lief bis 2026-08-19 nie durch** (Runs 99–107 rot), zwei Ursachen: (1) die Repo-Secrets
|
||||||
|
`NAS_SSH_HOST`/`NAS_SSH_KEY` waren nie angelegt → `ssh root@` mit leerem Key („Could not resolve
|
||||||
|
hostname"); jetzt gesetzt, Deploy-Key `gitea-ci-deploy@gerbilmanager` (privat nur im Gitea-Secret,
|
||||||
|
Pubkey in `/root/.ssh/authorized_keys`). (2) Der Runner hat **kein `rsync`**, und der apt-Guard prüfte
|
||||||
|
nur `ssh` → „rsync: command not found". Dateitransfer läuft daher per **`tar | ssh`** (tar gibt es auf
|
||||||
|
beiden Seiten; `-xof` erhält die Modi, Skripte bleiben ausführbar).
|
||||||
|
- CI-Run-Status notfalls aus der Gitea-DB: `docker exec -e PGPASSWORD=gitea ix-gitea-postgresdb-1
|
||||||
|
psql -U gitea -d gitea -c "select name,status from action_run_job where run_id=(select max(id) from action_run);"`
|
||||||
|
(`status`: 1=success, 2=failure, 4=skipped).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
> **Arbeitsweise (verbindlich, Owner-Regel — aktualisiert 2026-07-12):** Für **variable**
|
||||||
|
> (von der Züchterin **nicht** angefasste) Import-Tiere gilt weiter: Datenfehler IMMER **im
|
||||||
|
> Importierer** beheben (Pipeline-Code + `conflict-decisions.json` / `rpro3-decisions.json`)
|
||||||
|
> und **neu importieren** — für diese Tiere **nicht** die Live-DB patchen. Jeder Importer-Fix
|
||||||
|
> muss **re-ingest-stabil** sein (data-driven, deterministische GUIDs).
|
||||||
|
>
|
||||||
|
> **Neu:** Das Ziel ist **nicht** mehr „Importer perfekt, dann einmal final", sondern
|
||||||
|
> **Tier für Tier konvergieren**. Die Züchterin **darf** über die UI kuratieren, und das
|
||||||
|
> **überlebt den Ingest** (der wischt **nicht** mehr alles): (a) **manuell angelegte Tiere/
|
||||||
|
> Würfe/Kontakte** (`IsManual=true`) und (b) **Overrides** (`GerbilOverride`) für als
|
||||||
|
> **„vollständig korrekt"** markierte bzw. **von Hand bearbeitete** Tiere. Deren eigene Felder
|
||||||
|
> werden nach jedem Ingest **eingefroren** (Freeze). Siehe „Geprüfte Tiere / Freeze" unten.
|
||||||
|
> Zusätzlich zu diesen Overrides überleben weiterhin **Tickets/Feedback**.
|
||||||
|
|
||||||
|
```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): UPSERT + Freeze
|
||||||
|
curl -X POST http://localhost:5179/import/ingest-resolved
|
||||||
|
# 4b. Gegen PROD (ohne SSH): resolved_import.json per HTTP hochladen + ingesten
|
||||||
|
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||||
|
-F "resolved=@tools/import/output/resolved_import.json" # [-F "photos=@neue_fotos.zip"] nur bei neuen Fotos
|
||||||
|
```
|
||||||
|
- **`POST /import/ingest-resolved/upload`** (multipart `resolved` + optional `photos`-ZIP): staged die
|
||||||
|
hochgeladene Datei serverseitig in `Import:SourcePath` und ruft denselben UPSERT-Ingest — damit läuft
|
||||||
|
der Import **gegen Prod ohne SSH/`docker cp`**. Der alte `POST /import/ingest-resolved` (liest die
|
||||||
|
serverseitige Datei) bleibt unverändert. **DB-Copy vom lokalen Stand ist verboten**, sobald Prod
|
||||||
|
kuratiert wurde — nur der Upsert-Ingest bewahrt manuelle/verified/feedback-Zeilen.
|
||||||
|
|
||||||
|
- **`IngestResolvedService` (seit 2026-07-12: UPSERT statt Total-Wipe).** Importierte Zeilen
|
||||||
|
(Gerbils/Litters/Contacts) werden **per deterministischer Id gematcht und in place
|
||||||
|
aktualisiert**; **stale** importierte Zeilen (nicht mehr im Payload) werden entfernt (dangling
|
||||||
|
Referenzen manueller Zeilen werden vorher genullt + im Rückgabestring gewarnt). **Nie gelöscht/
|
||||||
|
überschrieben:** Zeilen mit `IsManual=true` sowie **user-eingegebene Sub-Records**
|
||||||
|
(Weights/Health) und Fotos manueller Tiere. **SaleContracts** werden weiter aus dem Payload
|
||||||
|
neu erzeugt (manuelle Verträge sind noch **nicht** geschützt — Folgeaufgabe). Am Ende werden
|
||||||
|
alle **`GerbilOverride`** angewandt (**Freeze** der eigenen Felder) und für verifizierte Tiere
|
||||||
|
der Rohimport-Stand vorher als Drift-Snapshot erfasst.
|
||||||
|
- **Feedback + GerbilOverride überleben den Ingest** (werden nicht gewischt) — daher haben sie nur
|
||||||
|
**lose, nullable Guid-Spalten ohne FK** (`Feedback`: GerbilId/LitterId/ContactId; `GerbilOverride`:
|
||||||
|
GerbilId). 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.
|
||||||
|
- **Bestandstier (`isResident`) vs. reiner Vorfahre** (Regel der Züchterin, Ticket 09bcac78):
|
||||||
|
Ein Tier ist **ihr Zuchttier** ⇔ es hatte bei ihr Nachzucht mit Herkunft **„von den
|
||||||
|
Kleinen Chaoten"** (nur dann konnten die Jungtiere ihren Zuchtnamen tragen). Trägt die
|
||||||
|
Nachzucht einen **fremden** Herkunftsnamen (z. B. „of Black Forest", „of Topolino"), ist
|
||||||
|
das Tier nur **Vorfahre** (`isResident=false`, keine Würfe/Charakter-Bereich). **Ausnahme:
|
||||||
|
zugekaufte Zuchttiere** (fremder Herkunftsname, aber bei ihr gezüchtet → Nachzucht „von den
|
||||||
|
Kleinen Chaoten") bleiben **Bestandstiere** (z. B. Echo/Elia of Black Forest).
|
||||||
|
**Sweep umgesetzt (2026-06-27, Ticket 381f7e51):** Der Wurfchronik-Import-Default ist
|
||||||
|
**nicht mehr `is_resident=True`**. Neue Regel in `merge_and_resolve.py` (später Sweep
|
||||||
|
nach Eltern-/`addLitters`-Verknüpfung): `isResident=true` **genau dann**, wenn (1) ein
|
||||||
|
expliziter `conflict-decisions.json`-`isResident`-Override greift (gewinnt immer — `true`
|
||||||
|
wie Echo/Elia, `false` wie Akane/Naémi/Bentley), **oder** (2) das Tier **Elternteil eines
|
||||||
|
eigenen Wurfs** ist (nicht-virtuell ODER aus Wurfchronik ODER Clan-Nachzucht ODER mit
|
||||||
|
residentem Elternteil ODER `addLitter` mit `ShowInChronicle`). Reine virtuelle
|
||||||
|
Ahnen-Verpaarungen machen **nicht** resident (z. B. Antares bleibt `false`). Sonst
|
||||||
|
`false`. `ShowInChronicle` wird **nach** dem Sweep berechnet (nutzt die finale Residenz).
|
||||||
|
Effekt: resident **1711 → 442**. UI-Label (`de.ts` `isResidentLabel`) = **„Zuchtanwärter/
|
||||||
|
innen"** — die Züchterin markiert künftige Zuchttiere selbst. **Offene Folgeaufgabe:**
|
||||||
|
manuelle UI-Residenz-Markierungen überleben den Re-Ingest noch **nicht** (bräuchten eine
|
||||||
|
ingest-stabile, FK-freie Override-Ablage analog `Feedback`).
|
||||||
|
- **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. Rezessiver Fuchs ist
|
||||||
|
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
|
||||||
|
- **C-Locus-Zygotie (GEN-6, Tickets bde4ec70/f89e95ad — Regel der Züchterin):** Platin,
|
||||||
|
Saphir und Platin-Hell unterscheiden sich **nur** in der C-Zygotie: `aa CC …pp` = **Platin**,
|
||||||
|
`aa Cc[chm] …pp` = **Saphir**, `aa Cc[h] …pp` = **Platin-Hell**. Der Katalog-Match kann
|
||||||
|
deshalb pro Locus ein **exaktes, ungeordnetes Allelpaar** verlangen — Token-Form `"x/y"`
|
||||||
|
(z. B. `C: 'C/cchm'`), sonst reduziert `locusToken()` auf das dominante Allel und der erste
|
||||||
|
Treffer (Platin) verschluckt die anderen. Gilt in **beiden** Mirrors (`catalog.ts` +
|
||||||
|
`genotype.py`) und im Seed (`colorVarietySeed.*`, `ApplicationContext.SeedColorVarieties`,
|
||||||
|
dort in Kompaktnotation `Ccchm`/`Cch` ohne Klammern).
|
||||||
|
- **`c[hm]` ist ein Schreibfehler-Alias für `c[chm]`** (fehlendes c in einigen Stammbaum-Charts):
|
||||||
|
beide Parser normalisieren ihn, sonst ist der Gencode unparsebar bzw. ergibt „…-Hell".
|
||||||
|
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
|
||||||
|
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
|
||||||
|
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
|
||||||
|
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
|
||||||
|
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
|
||||||
|
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||||
|
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||||
|
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||||
|
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`.
|
||||||
|
- **Geprüfte Tiere / „vollständig korrekt"-Freeze**: Backend `Models/GerbilOverride.cs` (FK-frei,
|
||||||
|
ingest-überlebend), `Import/GerbilSnapshotService.cs` (Akten-Snapshot + Freeze-Map + Diff, von App
|
||||||
|
**und** Test geteilt), `Endpoints/VerifiedGerbilEndpoints.cs` (`/verified-gerbils`: POST markieren,
|
||||||
|
DELETE entfernen/Toggle, GET Liste+Drift, GET `/export` = Golden-Fixture). Freeze wird in
|
||||||
|
`IngestResolvedService` angewandt; `GerbilEndpoints` setzt `IsManual=true` bei POST und legt bei
|
||||||
|
Edit eines importierten Tiers einen per-field-Override an (verifiziert → Golden auto-aktualisiert).
|
||||||
|
Frontend: `src/api/verified.ts`, Badge/Toggle/Drift-Karte in `pages/GerbilDetailPage.tsx`,
|
||||||
|
Übersicht `pages/VerifizierteTierePage.tsx` (`/hilfe/verifizierte-tiere`); Texte in `strings/de.ts`
|
||||||
|
(`verified`-Block). **Modell:** `OverrideJson` = Feld→Wert-Map; `IsVerified=true` = zertifiziert +
|
||||||
|
voller Freeze + Regression/Export, `false` = „geschützt" (nur geänderte Felder). Status/Gehege und
|
||||||
|
Abstammung/Nachzucht werden **nicht** eingefroren (nur erkannt/gemeldet). Regressionstest:
|
||||||
|
`GerbilManager.Tests/VerifiedGoldenTests.cs` gegen `tools/import/verified-golden.json` (via
|
||||||
|
`GET /verified-gerbils/export` erzeugen + committen; skippt, wenn Fixture/`resolved_import.json` fehlt).
|
||||||
|
**Scope-Grenze:** Freeze/Override gelten für **Gerbils**; Edits an importierten Würfen/Kontakten und
|
||||||
|
manuelle SaleContracts sind noch **nicht** geschützt (Folgeaufgaben).
|
||||||
|
- **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).
|
||||||
|
- **RennmausPro-III-Import (rpro3)**: Backend-Importer `GerbilManagerWebAPI/Import/Rpro3/*`
|
||||||
|
(`Rpro3ImportService` Analyze/Execute, `Rpro3Dedup` = automatischer Dubletten-Merge, Port von
|
||||||
|
`tools/import/compare_rpro3.py`). **Manuelle Dubletten-Entscheidungen der Züchterin** (aus den
|
||||||
|
„rpro3-import"-Tickets) liegen in `GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json` und
|
||||||
|
werden von `Rpro3Dedup.Run(animals, decisions)` honoriert: `same` = Force-Merge (eine rid zieht
|
||||||
|
den Auto-Cluster mit), `different` = Force-Split, `fields` = Feld-Overrides (Farbe/DOB/Herkunft/
|
||||||
|
`genotype` (roher Fcode)/`resident`/Notiz) am Cluster. Der `genotype`-Override ist nötig, wenn
|
||||||
|
Varianten sich im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und der Auto-Merge sonst
|
||||||
|
den falschen Gencode (erstes Cluster-Mitglied) zöge — die Züchterin gibt den korrekten vor
|
||||||
|
(z. B. MilkyWay → `aa Cc[h] DD ee Gg P- spsp`). **Schlüssel ist die RPRO3-Nummer (rid), NIE der Variantenbuchstabe**
|
||||||
|
(Buchstaben verschieben sich bei geänderter Clusterbildung) — rids aus dem Ticket-`agentContext`.
|
||||||
|
Triage-Helfer `tools/import/rpro3_lookup.py` (Name- oder `--rids`-Modus) zieht Farbe/Gencode/
|
||||||
|
Eltern/Nachzucht+Partner aus `_rpro3.db` für die Rückfragen an die Züchterin.
|
||||||
|
- **Eltern an der Tier-Akte bearbeiten (QOL)**: Im Tier-Formular gibt es den Block
|
||||||
|
**„Abstammung"** (`gerbil-manager-web/src/pages/GerbilFormPage.tsx`) mit Wurf-Auswahl +
|
||||||
|
Vater/Mutter-Picker (`components/AnimalPicker`), damit die Züchterin die Eltern **ohne** den
|
||||||
|
Umweg über die Wurf-Seite pflegen kann. **Datenmodell unverändert**: Eltern hängen weiter am
|
||||||
|
Geburtswurf. Backend `PUT /gerbils/{id}/parents` (`GerbilEndpoints`, DTOs `GerbilParentsInput`/
|
||||||
|
`GerbilParentsResult`, Tests `GerbilManager.Tests/GerbilParentsTests.cs`) schreibt genau dorthin:
|
||||||
|
hat das Tier einen Wurf → dessen `FatherId`/`MotherId` (gilt zwangsläufig für alle Geschwister,
|
||||||
|
die UI warnt mit `siblingCount`); hat es keinen → ein Wurf mit **gleichem Elternpaar + gleichem
|
||||||
|
Datum** wird verknüpft (`litterAttached`), sonst wird ein Träger-Wurf angelegt (`litterCreated`,
|
||||||
|
Name „Wurf von X + Y", `ShowInChronicle=false`, `IsManual=true`). Geschlechts-Validierung
|
||||||
|
wiederverwendet `LitterEndpoints.ValidateParents` (400 `InvalidParentGender`).
|
||||||
|
**Grenze:** Eltern/Abstammung sind vom Freeze **ausgenommen** (`GerbilSnapshotService.FreezeFields`
|
||||||
|
ohne `litterId`) → an einem **importierten** Wurf überlebt eine Eltern-Korrektur den Re-Ingest
|
||||||
|
**nicht** (gleiches Verhalten wie beim Editieren über die Wurf-Seite; dauerhaft nur über den
|
||||||
|
Importer/`conflict-decisions.json`).
|
||||||
|
- **Manuell ergänzte Würfe / versteckte Würfe**: `conflict-decisions.json` kennt neben
|
||||||
|
`resolutions`/`addAnimals` auch **`addLitters`** [{name, mother(+motherDob), father, children[],
|
||||||
|
showInChronicle, totalBorn, date, notes}] — materialisiert in `merge_and_resolve.py` als
|
||||||
|
**späten** Schritt (nach Dedup/Parent-Roles, vor json.dump): Wurf mit deterministischer
|
||||||
|
`generate_guid("decision-litter-<slug>")`, verknüpft Mutter/Vater/Kinder per Name(+dob),
|
||||||
|
setzt Kinder-`LitterId`. Die name+dob-Resolutions können zusätzlich `isResident`, `notes`,
|
||||||
|
`receiver` (Kontaktname→ReceiverContactId) auf bestehende Tiere setzen (läuft NACH der
|
||||||
|
Residenz-Propagation). `Litter.ShowInChronicle` (bool, Default true; Migration
|
||||||
|
`AddLitterShowInChronicle`): `false` blendet den Wurf aus der Wurfchronik
|
||||||
|
(`WuerfeListPage`, Filter `showInChronicle==true`) aus, zeigt ihn aber weiter auf der
|
||||||
|
Tier-Akte (parentLitters-Query, ungefiltert). Use-Case: extern entstandener Wurf eines
|
||||||
|
nicht-residenten Tieres (z. B. Akane × Bonaparte).
|
||||||
|
- **Über-gemergte Würfe korrigieren**: `conflict-decisions.json` kennt **`litterChildren`**
|
||||||
|
[{date, matchChild, keep[], renameTo?, note}] — autoritative Kinder-Whitelist eines Wurfs
|
||||||
|
(später Schritt in `merge_and_resolve.py` nach `litterOrder`). Match per `date` + `matchChild`
|
||||||
|
(ein aktuell zugeordnetes Kind, disambiguiert gleich-datierte Würfe); Kinder außerhalb `keep`
|
||||||
|
werden aus dem Wurf gelöst (`LitterId=None`, Tier bleibt erhalten), optional `renameTo` setzt
|
||||||
|
den kanonischen Wurf-Namen. Use-Case: Wurfchronik-Abnehmer-Sammelseiten, die fälschlich in einen
|
||||||
|
datierten Buchstaben-Wurf gemergt wurden (Tickets 4b9f49fb M-Wurf, 8d259edf Gale).
|
||||||
|
**`add`** [Name | {name, dob}] ist das Gegenstück (seit 2026-08-18): hängt ein Jungtier an
|
||||||
|
**diesen** Wurf, wenn die Chart-Positions-Heuristik es in einen falschen **virtuellen**
|
||||||
|
Stammbaum-Wurf gezogen hat; wird der alte Wurf dadurch kinderlos UND virtuell, entfernt der
|
||||||
|
Importer ihn (sonst bliebe ein Phantom-Wurf in der Wurfchronik stehen). Use-Case Akane
|
||||||
|
(Ticket 88389f8e): die neuen Stammbäume führen die Eltern eine Zeile unter dem Kind-Block
|
||||||
|
(Vater N80/Mutter N81) — `_reconstruct_parents` greift dann eine Zeile zu hoch und paart die
|
||||||
|
Mutter des Nachbar-Blocks mit dem echten Vater.
|
||||||
|
- **EMF/WMF sind keine Fotos**: Excel legt neben dem Foto teils ein Vektor-Metafile ab; Browser
|
||||||
|
können es nicht darstellen (kaputte Bildkachel in der Akte) → `extract._attach_photos`
|
||||||
|
überspringt `.emf`/`.wmf` (Regressionstest in `test_extract.py`).
|
||||||
|
- **Resolution-Schlüssel in `conflict-decisions.json` (Stand 2026-08-19)**: neben
|
||||||
|
`father/mother(+Dob)`, `isResident`, `notes`, `receiver`, `dateOfDeath`, `deceased`,
|
||||||
|
`correctDob`, `renameTo`, `mergeExternalRefs`, `suppressExternalRefs`, `farbschlag` auch
|
||||||
|
**`goHomeDate`** (autoritativ, überschreibt auch einen vorhandenen/kaputten Wert und zieht
|
||||||
|
Status `GivenAway` nach, sofern nicht `Deceased`) und **`spottingType`** (Scheckungsart —
|
||||||
|
der Importer leitet sie aus keiner Quelle ab; `IngestResolvedService` überträgt sie nur,
|
||||||
|
wenn der Payload einen Wert hat, sonst bliebe eine von Hand erfasste Angabe nicht stehen).
|
||||||
|
**`genotype`** gewinnt bei **präzisem** Match (externalRef ODER name+dob) auch gegen einen
|
||||||
|
bereits gefüllten Wert — bei Wurfchronik-Tieren ist der meist nur der Kanon-Fill des
|
||||||
|
Farbschlags; ein name-only-Match füllt weiterhin nur leere Felder (Namensvetter-Schutz).
|
||||||
|
- **Pro Tier greift nur EINE Resolution** (first match: externalRef → endswith → name+dob →
|
||||||
|
name-only). Nie einen zweiten Eintrag mit derselben externalRef/demselben (name,dob) anlegen,
|
||||||
|
sondern den bestehenden **in place** erweitern — sonst gewinnt still der letzte und Felder
|
||||||
|
gehen verloren. **Wurfchronik-Jungtiere haben zur Override-Zeit noch kein DOB** (das kommt
|
||||||
|
später aus dem Wurfdatum) → dort ist `externalRef` der einzige zuverlässige Schlüssel.
|
||||||
|
- **`renameTo` zieht durch Freitext-Notizen** (`apply_rename_note_sweep`, letzter Schritt vor
|
||||||
|
dem Payload-Dump): der alte Name blieb sonst in Wurf-Notizen stehen („Blacky + Kruke v. …").
|
||||||
|
Wortgenau, mit Guards — Quellname < 4 Zeichen oder > 2 Wörter wird ignoriert, kein Ersetzen,
|
||||||
|
wenn der alte Name im neuen Rufnamen steckt, und Notizen, die die Umbenennung selbst
|
||||||
|
dokumentieren („umbenannt"/„ursprünglich"), bleiben unberührt. Ohne diese Guards trifft der
|
||||||
|
Sweep Abnehmer-Namen und Gewichtsnotizen (im Probelauf 16 von 22 Ersetzungen falsch).
|
||||||
|
- **`parse_date` verwirft implausible Jahre** (< 1900 oder > aktuelles Jahr + 1) → `None`;
|
||||||
|
Wurfchronik-Transkriptionen bauen Daten sonst falsch zusammen (JackJack: „1310-05-13").
|
||||||
|
- **Sternloses Geburtsdatum in Stammbaum-Charts**: `extract.py` normalisiert Zellen wie
|
||||||
|
`22.08.2019/+09.04.2024` (ohne `*`) zu `*…`, wenn Block-Kontext vorliegt (Genotyp ≤3 Zeilen
|
||||||
|
darunter, Namenszelle ≤3 darüber). Ohne das erkennt die Blockheuristik das Tier nicht und
|
||||||
|
`_reconstruct_parents` greift in den Nachbar-Ast (Ticket 65266679 Bijou).
|
||||||
|
- **`renameTo` ist eltern-verknüpfungssicher**: umbenannte Tiere werden im Eltern-Namensindex
|
||||||
|
zusätzlich unter ihrem **Quell-Namen** (`_pre_rename_name`) geführt, sonst verlieren Würfe, die
|
||||||
|
den alten Namen referenzieren, ihren Eltern-Link (Ticket e0a0c304 Kruke→Kuke). `dateOfDeath`
|
||||||
|
ist als Resolution-Override unterstützt (setzt Todesdatum + Status Deceased; rpro3 hat keine
|
||||||
|
Sterbespalte).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Subagenten MÜSSEN MemPalace ebenfalls nutzen** — das im Subagent-Prompt IMMER explizit
|
||||||
|
anweisen: zu Aufgabenbeginn den Palast abfragen (am einfachsten per CLI, kein Key nötig:
|
||||||
|
`mempalace search "<keywords>"` bzw. `mempalace search "<…>" --wing gerbilmanager`; falls
|
||||||
|
MCP-Tools verfügbar auch `mempalace_search`/`mempalace_kg_query`) — relevante Domänenregeln,
|
||||||
|
frühere Entscheidungen und Stolperfallen holen, BEVOR im Code gesucht wird; durable
|
||||||
|
Erkenntnisse am Ende ablegen (`mempalace add-drawer`/MCP). Gilt für JEDEN gespawnten Agenten.
|
||||||
|
|
||||||
|
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||||
|
|
||||||
|
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
||||||
|
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.
|
||||||
@@ -4,6 +4,13 @@ using System.Net.Sockets;
|
|||||||
|
|
||||||
var builder = DistributedApplication.CreateBuilder(args);
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// WEB-2: lokales Verzeichnis für die veröffentlichte öffentliche Seite. Die API schreibt
|
||||||
|
// nach <root>/live (POST /api/publish), der publicsite-nginx serviert genau dieses live/
|
||||||
|
// — dieselbe Form wie in Produktion (deploy/truenas), nur lokal zum Testen.
|
||||||
|
var publicSiteRoot = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", ".local-publicsite"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(publicSiteRoot, "live")); // leeres live/, damit nginx sofort startet
|
||||||
|
var publicSiteConf = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", "truenas", "nginx", "publicsite.conf"));
|
||||||
|
|
||||||
var postgres = builder.AddPostgres("postgres")
|
var postgres = builder.AddPostgres("postgres")
|
||||||
.WithDataVolume();
|
.WithDataVolume();
|
||||||
|
|
||||||
@@ -19,6 +26,8 @@ var webapi = builder.AddProject<Projects.GerbilManagerWebAPI>("webapi")
|
|||||||
// Proxyless so the app binds the port directly — Program.cs then widens the
|
// Proxyless so the app binds the port directly — Program.cs then widens the
|
||||||
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
|
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
|
||||||
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
|
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
|
||||||
|
// WEB-2: Publish-Ziel der öffentlichen Seite (sonst gibt POST /api/publish 503).
|
||||||
|
.WithEnvironment("PublicSite__RootPath", publicSiteRoot)
|
||||||
.WithExternalHttpEndpoints();
|
.WithExternalHttpEndpoints();
|
||||||
|
|
||||||
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
|
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
|
||||||
@@ -40,6 +49,15 @@ builder.AddViteApp("frontend", "../gerbil-manager-web")
|
|||||||
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
|
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WEB-2: publicsite-nginx — serviert die veröffentlichte öffentliche Seite lokal (gleiche
|
||||||
|
// nginx-Config wie in Produktion). Liest das live/-Verzeichnis aus dem Publish-Output der API.
|
||||||
|
// Test: in der App veröffentlichen (oder POST /api/publish), dann http://localhost:8081 öffnen.
|
||||||
|
builder.AddContainer("publicsite", "nginx", "alpine")
|
||||||
|
.WithBindMount(publicSiteRoot, "/usr/share/nginx/html", isReadOnly: true)
|
||||||
|
.WithBindMount(publicSiteConf, "/etc/nginx/conf.d/default.conf", isReadOnly: true)
|
||||||
|
.WithHttpEndpoint(port: 8081, targetPort: 80, name: "http")
|
||||||
|
.WithExternalHttpEndpoints();
|
||||||
|
|
||||||
builder.Build().Run();
|
builder.Build().Run();
|
||||||
|
|
||||||
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
|
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
|||||||
public string ContractRoot { get; } =
|
public string ContractRoot { get; } =
|
||||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
/// <summary>Optionaler Isolations-Override für den Import-Quellordner (Import:SourcePath),
|
||||||
|
/// damit der Upload-Ingest-Test in einem Temp-Verzeichnis arbeitet statt im echten Repo.</summary>
|
||||||
|
public string? ImportSourcePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optionaler Isolations-Override für den Foto-Ordner (Photos:RootPath).</summary>
|
||||||
|
public string? PhotosRootPath { get; init; }
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Testing");
|
builder.UseEnvironment("Testing");
|
||||||
@@ -37,6 +44,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
|||||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||||
"Host=localhost;Database=test;Username=test;Password=test");
|
"Host=localhost;Database=test;Username=test;Password=test");
|
||||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||||
|
if (ImportSourcePath is not null) builder.UseSetting("Import:SourcePath", ImportSourcePath);
|
||||||
|
if (PhotosRootPath is not null) builder.UseSetting("Photos:RootPath", PhotosRootPath);
|
||||||
|
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,11 +43,343 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").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)
|
// GET returns it (newest first)
|
||||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
Assert.Contains(listed.EnumerateArray(),
|
Assert.Contains(listed.EnumerateArray(),
|
||||||
f => f.GetProperty("entityName").GetString() == "Krümel"
|
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||||
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
|
&& 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);
|
||||||
|
|
||||||
|
// Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
|
||||||
|
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
|
||||||
|
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
|
var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||||
|
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
|
||||||
|
|
||||||
|
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
|
||||||
|
var restore = await client.PostAsync($"/feedback/{id}/restore", null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
|
||||||
|
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
|
||||||
|
|
||||||
|
// Delete/Restore eines unbekannten Tickets -> 404.
|
||||||
|
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
|
||||||
|
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var create = await client.PostAsJsonAsync("/feedback", new
|
||||||
|
{
|
||||||
|
message = "Farbschlag stimmt nicht.",
|
||||||
|
context = "gerbil-detail",
|
||||||
|
});
|
||||||
|
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Direkt (ohne Rückfrage) lösen …
|
||||||
|
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
|
||||||
|
|
||||||
|
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
|
||||||
|
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||||
|
{
|
||||||
|
status = "NeedsInfo",
|
||||||
|
question = "Bitte beschreibe, was noch fehlt.",
|
||||||
|
});
|
||||||
|
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||||
|
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
|
||||||
|
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
|
||||||
|
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Attachment_upload_list_serve_delete_lifecycle()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var create = await client.PostAsJsonAsync("/feedback", new
|
||||||
|
{
|
||||||
|
message = "Foto vom Fellschlag.",
|
||||||
|
context = "gerbil-detail",
|
||||||
|
});
|
||||||
|
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// 1×1-PNG hochladen.
|
||||||
|
const string pngB64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||||
|
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||||
|
{
|
||||||
|
fileName = "maus.png",
|
||||||
|
contentType = "image/png",
|
||||||
|
dataBase64 = pngB64,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
|
||||||
|
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var attId = att.GetProperty("id").GetString();
|
||||||
|
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
|
||||||
|
Assert.True(att.GetProperty("size").GetInt32() > 0);
|
||||||
|
|
||||||
|
// GET /feedback liefert die Metadaten (ohne Bytes).
|
||||||
|
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
|
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||||
|
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
|
||||||
|
|
||||||
|
// Bytes ausliefern.
|
||||||
|
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
|
||||||
|
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
|
||||||
|
|
||||||
|
// Löschen -> danach keine Anhänge mehr.
|
||||||
|
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
|
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||||
|
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Attachment_upload_rejects_invalid_base64()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||||
|
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
|
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||||
|
{
|
||||||
|
fileName = "x.png",
|
||||||
|
contentType = "image/png",
|
||||||
|
dataBase64 = "###nicht base64###",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var create = await client.PostAsJsonAsync("/feedback", new
|
||||||
|
{
|
||||||
|
message = "Welches Tier ist gemeint?",
|
||||||
|
context = "gerbil-detail",
|
||||||
|
});
|
||||||
|
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Rückfrage stellen (NeedsInfo) …
|
||||||
|
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
|
||||||
|
// … manuell lösen …
|
||||||
|
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||||
|
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
|
||||||
|
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||||
|
{
|
||||||
|
status = "NeedsInfo",
|
||||||
|
question = "Meinst du A oder B?",
|
||||||
|
});
|
||||||
|
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||||
|
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
|
||||||
|
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
|
||||||
|
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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]
|
[Fact]
|
||||||
@@ -108,6 +440,14 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
EntityName = "Papa",
|
EntityName = "Papa",
|
||||||
Url = "http://localhost/rennmaeuse/papa",
|
Url = "http://localhost/rennmaeuse/papa",
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
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,
|
// 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.
|
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||||
@@ -137,6 +477,15 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||||
Assert.Equal(litterId, survivor.LitterId);
|
Assert.Equal(litterId, survivor.LitterId);
|
||||||
Assert.Equal("Papa", survivor.EntityName);
|
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).
|
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||||
@@ -151,6 +500,38 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_single_feedback_by_id_and_status_filter()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
// Zwei Tickets mit unterscheidbaren Namen anlegen.
|
||||||
|
var open = await client.PostAsJsonAsync("/feedback", new { message = "Offenes Ticket", context = "gerbil-detail", entityName = "FilterOpen" });
|
||||||
|
var openId = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||||
|
var other = await client.PostAsJsonAsync("/feedback", new { message = "Zweites Ticket", context = "gerbil-detail", entityName = "FilterOther" });
|
||||||
|
var otherId = JsonDocument.Parse(await other.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||||
|
|
||||||
|
// Einzel-GET liefert genau dieses Ticket (mit attachments-Feld).
|
||||||
|
var single = JsonDocument.Parse(await client.GetStringAsync($"/feedback/{openId}")).RootElement;
|
||||||
|
Assert.Equal("FilterOpen", single.GetProperty("entityName").GetString());
|
||||||
|
Assert.Equal(JsonValueKind.Array, single.GetProperty("attachments").ValueKind);
|
||||||
|
|
||||||
|
// Unbekannte Id -> 404.
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/feedback/{Guid.NewGuid()}")).StatusCode);
|
||||||
|
|
||||||
|
// Zweites Ticket auf Answered setzen, dann Status-Filter prüfen.
|
||||||
|
await client.PutAsJsonAsync($"/feedback/{otherId}", new { status = "Answered" });
|
||||||
|
var answered = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Answered")).RootElement;
|
||||||
|
Assert.Contains(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == otherId);
|
||||||
|
Assert.DoesNotContain(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == openId);
|
||||||
|
|
||||||
|
// Kombinierter Filter (Open,Answered) enthält beide.
|
||||||
|
var both = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Open,Answered")).RootElement;
|
||||||
|
var ids = both.EnumerateArray().Select(f => f.GetProperty("id").GetString()).ToHashSet();
|
||||||
|
Assert.Contains(openId, ids);
|
||||||
|
Assert.Contains(otherId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
|
|||||||
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// QOL-ELTERN: PUT /gerbils/{id}/parents — Eltern direkt an der Tier-Akte pflegen.
|
||||||
|
/// Das Datenmodell bleibt unverändert (Eltern hängen am Geburtswurf), der Endpoint
|
||||||
|
/// schreibt nur dorthin bzw. legt den Träger-Wurf an, wenn es noch keinen gibt.
|
||||||
|
/// </summary>
|
||||||
|
public class GerbilParentsTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
public GerbilParentsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private async Task<string> CreateGerbil(string name, string gender, string? dob = null, string? litterId = null)
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/gerbils", new
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
gender,
|
||||||
|
dateOfBirth = dob,
|
||||||
|
litterId,
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||||
|
.GetProperty("id").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateLitter(string name, string date, string? fatherId = null, string? motherId = null)
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync("/litters", new { name, date, fatherId, motherId });
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||||
|
.GetProperty("id").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> SetParents(string gerbilId, string? fatherId, string? motherId)
|
||||||
|
{
|
||||||
|
var resp = await _client.PutAsJsonAsync($"/gerbils/{gerbilId}/parents", new { fatherId, motherId });
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> GetLitter(string litterId) =>
|
||||||
|
JsonDocument.Parse(await _client.GetStringAsync($"/litters/{litterId}")).RootElement;
|
||||||
|
|
||||||
|
private static string? Str(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Parents_of_existing_litter_are_written_to_that_litter()
|
||||||
|
{
|
||||||
|
var father = await CreateGerbil("Vater A", "male", "2024-01-01");
|
||||||
|
var mother = await CreateGerbil("Mutter A", "female", "2024-01-01");
|
||||||
|
var litterId = await CreateLitter("A-Wurf", "2025-03-01");
|
||||||
|
var pup = await CreateGerbil("Jungtier A", "female", "2025-03-01", litterId);
|
||||||
|
|
||||||
|
var result = await SetParents(pup, father, mother);
|
||||||
|
|
||||||
|
Assert.Equal(litterId, Str(result, "litterId"));
|
||||||
|
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||||
|
Assert.False(result.GetProperty("litterAttached").GetBoolean());
|
||||||
|
var litter = await GetLitter(litterId);
|
||||||
|
Assert.Equal(father, Str(litter, "fatherId"));
|
||||||
|
Assert.Equal(mother, Str(litter, "motherId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiblingCount_reports_the_other_pups_the_change_applies_to()
|
||||||
|
{
|
||||||
|
var litterId = await CreateLitter("B-Wurf", "2025-04-01");
|
||||||
|
var pup = await CreateGerbil("Jungtier B1", "female", "2025-04-01", litterId);
|
||||||
|
await CreateGerbil("Jungtier B2", "male", "2025-04-01", litterId);
|
||||||
|
await CreateGerbil("Jungtier B3", "male", "2025-04-01", litterId);
|
||||||
|
var mother = await CreateGerbil("Mutter B", "female", "2024-02-01");
|
||||||
|
|
||||||
|
var result = await SetParents(pup, null, mother);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.GetProperty("siblingCount").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gerbil_without_litter_gets_a_hidden_carrier_litter()
|
||||||
|
{
|
||||||
|
var father = await CreateGerbil("Vater C", "male", "2024-03-01");
|
||||||
|
var mother = await CreateGerbil("Mutter C", "female", "2024-03-01");
|
||||||
|
var orphan = await CreateGerbil("Waise C", "female", "2025-05-01");
|
||||||
|
|
||||||
|
var result = await SetParents(orphan, father, mother);
|
||||||
|
|
||||||
|
Assert.True(result.GetProperty("litterCreated").GetBoolean());
|
||||||
|
var litterId = Str(result, "litterId")!;
|
||||||
|
Assert.Equal("Wurf von Vater C + Mutter C", Str(result, "litterName"));
|
||||||
|
|
||||||
|
var litter = await GetLitter(litterId);
|
||||||
|
Assert.Equal(father, Str(litter, "fatherId"));
|
||||||
|
Assert.Equal(mother, Str(litter, "motherId"));
|
||||||
|
// Nur zur Abstammung — der Hilfs-Wurf darf die Wurfchronik nicht zumüllen.
|
||||||
|
Assert.False(litter.GetProperty("showInChronicle").GetBoolean());
|
||||||
|
// Wurfdatum = Geburtsdatum des Tiers.
|
||||||
|
Assert.Equal("2025-05-01", Str(litter, "date"));
|
||||||
|
|
||||||
|
// Das Tier hängt jetzt an diesem Wurf.
|
||||||
|
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||||
|
Assert.Equal(litterId, Str(gerbil, "litterId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Same_parents_and_same_dob_attach_to_the_existing_litter_instead_of_duplicating()
|
||||||
|
{
|
||||||
|
var father = await CreateGerbil("Vater D", "male", "2024-04-01");
|
||||||
|
var mother = await CreateGerbil("Mutter D", "female", "2024-04-01");
|
||||||
|
var first = await CreateGerbil("Waise D1", "female", "2025-06-01");
|
||||||
|
var second = await CreateGerbil("Waise D2", "male", "2025-06-01");
|
||||||
|
|
||||||
|
var created = await SetParents(first, father, mother);
|
||||||
|
Assert.True(created.GetProperty("litterCreated").GetBoolean());
|
||||||
|
|
||||||
|
var attached = await SetParents(second, father, mother);
|
||||||
|
Assert.False(attached.GetProperty("litterCreated").GetBoolean());
|
||||||
|
Assert.True(attached.GetProperty("litterAttached").GetBoolean());
|
||||||
|
// Beide sind jetzt Geschwister im selben Wurf.
|
||||||
|
Assert.Equal(Str(created, "litterId"), Str(attached, "litterId"));
|
||||||
|
Assert.Equal(1, attached.GetProperty("siblingCount").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clearing_parents_nulls_them_on_the_litter()
|
||||||
|
{
|
||||||
|
var father = await CreateGerbil("Vater E", "male", "2024-05-01");
|
||||||
|
var litterId = await CreateLitter("E-Wurf", "2025-07-01", father);
|
||||||
|
var pup = await CreateGerbil("Jungtier E", "female", "2025-07-01", litterId);
|
||||||
|
|
||||||
|
await SetParents(pup, null, null);
|
||||||
|
|
||||||
|
var litter = await GetLitter(litterId);
|
||||||
|
Assert.Equal(JsonValueKind.Null, litter.GetProperty("fatherId").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Null, litter.GetProperty("motherId").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task No_litter_and_no_parents_is_a_no_op()
|
||||||
|
{
|
||||||
|
var orphan = await CreateGerbil("Waise F", "female", "2025-08-01");
|
||||||
|
|
||||||
|
var result = await SetParents(orphan, null, null);
|
||||||
|
|
||||||
|
Assert.Equal(JsonValueKind.Null, result.GetProperty("litterId").ValueKind);
|
||||||
|
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||||
|
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||||
|
Assert.Equal(JsonValueKind.Null, gerbil.GetProperty("litterId").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_gender_parent_returns_400_InvalidParentGender()
|
||||||
|
{
|
||||||
|
var female = await CreateGerbil("Weibchen G", "female", "2024-06-01");
|
||||||
|
var pup = await CreateGerbil("Jungtier G", "male", "2025-09-01");
|
||||||
|
|
||||||
|
var resp = await _client.PutAsJsonAsync($"/gerbils/{pup}/parents",
|
||||||
|
new { fatherId = female, motherId = (string?)null });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
Assert.Contains("InvalidParentGender", await resp.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Self_as_parent_returns_400()
|
||||||
|
{
|
||||||
|
var g = await CreateGerbil("Selbstbezug H", "male", "2025-10-01");
|
||||||
|
|
||||||
|
var resp = await _client.PutAsJsonAsync($"/gerbils/{g}/parents",
|
||||||
|
new { fatherId = g, motherId = (string?)null });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_gerbil_returns_404()
|
||||||
|
{
|
||||||
|
var resp = await _client.PutAsJsonAsync($"/gerbils/{Guid.NewGuid()}/parents",
|
||||||
|
new { fatherId = (string?)null, motherId = (string?)null });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /import/ingest-resolved/upload — Upload-Variante des Ingests: eine hochgeladene
|
||||||
|
/// resolved_import.json wird serverseitig in den Import-Quellordner gestaged und direkt ingested.
|
||||||
|
/// Damit lässt sich der Import gegen PROD fahren, ohne die Datei per SSH/docker cp in den Container
|
||||||
|
/// zu kopieren. Der Test isoliert Quell-/Fotoordner über ApiFactory in Temp-Verzeichnisse.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportIngestUploadEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_stages_resolved_json_and_runs_ingest()
|
||||||
|
{
|
||||||
|
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var photoDir = Path.Combine(Path.GetTempPath(), "ingest-upload-photos-" + Guid.NewGuid().ToString("N"));
|
||||||
|
using var factory = new ApiFactory { ImportSourcePath = srcDir, PhotosRootPath = photoDir };
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Minimaler, aber valider Payload (leere Sammlungen) — genügt, um Staging + Ingest-Lauf zu prüfen.
|
||||||
|
const string json = "{\"Gerbils\":[],\"Litters\":[],\"Contacts\":[],\"SaleContracts\":[],\"GerbilPhotos\":[]}";
|
||||||
|
|
||||||
|
using var form = new MultipartFormDataContent();
|
||||||
|
var file = new ByteArrayContent(Encoding.UTF8.GetBytes(json));
|
||||||
|
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||||
|
form.Add(file, "resolved", "resolved_import.json");
|
||||||
|
|
||||||
|
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain("Error:", body); // Ingest lief, keine "file not found"-Fehlermeldung
|
||||||
|
|
||||||
|
// Die hochgeladene Datei wurde an den erwarteten Ort gestaged.
|
||||||
|
Assert.True(File.Exists(Path.Combine(srcDir, "resolved_import.json")));
|
||||||
|
|
||||||
|
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||||
|
try { Directory.Delete(photoDir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_rejects_empty_file_with_400()
|
||||||
|
{
|
||||||
|
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||||
|
using var factory = new ApiFactory { ImportSourcePath = srcDir };
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var form = new MultipartFormDataContent();
|
||||||
|
var file = new ByteArrayContent(Array.Empty<byte>());
|
||||||
|
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||||
|
form.Add(file, "resolved", "resolved_import.json");
|
||||||
|
|
||||||
|
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
|
||||||
|
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using GerbilManagerWebAPI.Models;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
@@ -10,6 +11,10 @@ namespace GerbilManager.Tests
|
|||||||
{
|
{
|
||||||
private readonly string _dir;
|
private readonly string _dir;
|
||||||
private readonly string _resolvedJsonPath;
|
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()
|
public IngestResolvedServiceTests()
|
||||||
{
|
{
|
||||||
@@ -22,6 +27,10 @@ namespace GerbilManager.Tests
|
|||||||
var motherId = Guid.NewGuid();
|
var motherId = Guid.NewGuid();
|
||||||
var litterId = Guid.NewGuid();
|
var litterId = Guid.NewGuid();
|
||||||
var photoId = Guid.NewGuid();
|
var photoId = Guid.NewGuid();
|
||||||
|
ContactId = contactId;
|
||||||
|
FatherId = fatherId;
|
||||||
|
ContractId = Guid.NewGuid();
|
||||||
|
UnknownGerbilId = Guid.NewGuid();
|
||||||
|
|
||||||
var data = new
|
var data = new
|
||||||
{
|
{
|
||||||
@@ -127,6 +136,20 @@ namespace GerbilManager.Tests
|
|||||||
FileName = "photo1.jpg",
|
FileName = "photo1.jpg",
|
||||||
SortOrder = 0
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +222,121 @@ namespace GerbilManager.Tests
|
|||||||
Assert.NotNull(litter.Provenance);
|
Assert.NotNull(litter.Provenance);
|
||||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||||
Assert.Contains("fromWurfchronik", 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestResolved_rederives_status_after_deceased_override()
|
||||||
|
{
|
||||||
|
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
|
||||||
|
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
|
||||||
|
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
|
||||||
|
using var db = NewDb();
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
db.GerbilOverrides.Add(new GerbilOverride
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GerbilId = FatherId,
|
||||||
|
EntityName = "Papa",
|
||||||
|
IsVerified = false, // "geschützt" — wird trotzdem angewandt
|
||||||
|
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
|
||||||
|
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||||
|
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt bzw. entfernt die Scheckungsart im gestageten Payload (Testdaten werden hier
|
||||||
|
/// selbst aufgebaut — der Test hängt bewusst an keiner echten conflict-decisions.json).
|
||||||
|
/// </summary>
|
||||||
|
private void SetPayloadSpottingType(string? value, bool remove = false)
|
||||||
|
{
|
||||||
|
var root = JsonNode.Parse(File.ReadAllText(_resolvedJsonPath))!;
|
||||||
|
var papa = root["Gerbils"]!.AsArray()
|
||||||
|
.First(g => (string?)g!["Name"] == "Papa")!.AsObject();
|
||||||
|
if (remove) papa.Remove("SpottingType");
|
||||||
|
else papa["SpottingType"] = value;
|
||||||
|
File.WriteAllText(_resolvedJsonPath, root.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestResolved_writes_spotting_type_and_keeps_it_when_payload_has_none()
|
||||||
|
{
|
||||||
|
// Ticket a547be62 (Eliza): Die Scheckungsart kennt der Importer nur über
|
||||||
|
// conflict-decisions.json — sie MUSS also durch den Ingest getragen werden.
|
||||||
|
// Umgekehrt darf ein leerer Payload-Wert eine von Hand in der App erfasste
|
||||||
|
// Scheckungsart NICHT nullen (der Importer weiß von ihr nichts).
|
||||||
|
using var db = NewDb();
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// (1) Neues Tier: Wert aus dem Payload landet in der DB.
|
||||||
|
SetPayloadSpottingType("Ansatzschecke");
|
||||||
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||||
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||||
|
|
||||||
|
// (2) Re-Ingest, Feld fehlt im Payload → bestehender Wert bleibt stehen.
|
||||||
|
SetPayloadSpottingType(null, remove: true);
|
||||||
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||||
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||||
|
|
||||||
|
// (3) Re-Ingest mit explizitem null → ebenfalls kein Nullen (Guard im Update-Zweig).
|
||||||
|
SetPayloadSpottingType(null);
|
||||||
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||||
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||||
|
|
||||||
|
// (4) Ein NEUER Wert aus dem Payload gewinnt weiterhin (Entscheidung korrigiert).
|
||||||
|
SetPayloadSpottingType("Mantelschecke");
|
||||||
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||||
|
Assert.Equal("Mantelschecke", father.SpottingType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
|
||||||
|
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
|
||||||
|
/// </summary>
|
||||||
|
public class PushEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public PushEndpointTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Vapid_public_key_is_exposed()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
|
||||||
|
Assert.True(doc.GetProperty("enabled").GetBoolean());
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
|
||||||
|
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
|
||||||
|
|
||||||
|
var first = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
|
||||||
|
var second = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||||
|
|
||||||
|
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||||
|
|
||||||
|
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
407
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
407
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dedup_force_merges_variants_declared_same_by_breeder()
|
||||||
|
{
|
||||||
|
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
|
||||||
|
var animals = new List<Rpro3Animal>
|
||||||
|
{
|
||||||
|
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
|
||||||
|
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
|
||||||
|
};
|
||||||
|
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
|
||||||
|
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
|
||||||
|
|
||||||
|
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
|
||||||
|
var decisions = new Rpro3Decisions
|
||||||
|
{
|
||||||
|
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
|
||||||
|
};
|
||||||
|
var r = Rpro3Dedup.Run(animals, decisions);
|
||||||
|
Assert.Single(r.MergeClusters);
|
||||||
|
Assert.Equal(2, r.MergeClusters.Values.First().Count);
|
||||||
|
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dedup_force_splits_variants_declared_different_by_breeder()
|
||||||
|
{
|
||||||
|
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
|
||||||
|
var animals = new List<Rpro3Animal>
|
||||||
|
{
|
||||||
|
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||||
|
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||||
|
};
|
||||||
|
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
|
||||||
|
|
||||||
|
// … bleiben durch „Different" getrennt.
|
||||||
|
var decisions = new Rpro3Decisions
|
||||||
|
{
|
||||||
|
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
|
||||||
|
};
|
||||||
|
var r = Rpro3Dedup.Run(animals, decisions);
|
||||||
|
Assert.Empty(r.MergeClusters);
|
||||||
|
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rpro3Decisions_load_returns_empty_when_file_missing()
|
||||||
|
{
|
||||||
|
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
|
||||||
|
Assert.Empty(d.Decisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rpro3Decisions_roundtrips_through_json()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}," +
|
||||||
|
"{\"name\":\"MilkyWay\",\"same\":[[\"u1031\",\"u1019\"]],\"fields\":{\"u1031\":{\"genotype\":\"aa Cc[h] DD ee Gg P- spsp\",\"color\":\"Kohlfuchs-Hell\"}}}]}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var d = Rpro3Decisions.Load(path);
|
||||||
|
Assert.Equal(2, d.Decisions.Count);
|
||||||
|
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||||
|
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||||
|
// Gencode-Override (z. B. MilkyWay: korrektes C-Locus c[h]) muss durch den JSON-Roundtrip kommen.
|
||||||
|
Assert.Equal("aa Cc[h] DD ee Gg P- spsp", d.BuildFieldIndex()["u1031"].Genotype);
|
||||||
|
}
|
||||||
|
finally { File.Delete(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the "Freeze/Override" mechanism (verified key animals + manual work
|
||||||
|
/// survive the re-ingest). Exercised at the service + ingest level with an InMemory DB, mirroring
|
||||||
|
/// the harness in <see cref="IngestResolvedServiceTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class VerifiedGerbilTests : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Guid Schwarz = new("00000000-0000-0000-0000-000000000006");
|
||||||
|
private readonly List<string> _dirs = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var d in _dirs)
|
||||||
|
try { Directory.Delete(d, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Harness helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string NewDir()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "verified-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
_dirs.Add(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationContext NewDb()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseInMemoryDatabase("verified-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IngestResolvedService Service(ApplicationContext db, string dir)
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Import:SourcePath", dir },
|
||||||
|
{ "Photos:RootPath", dir },
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
return new IngestResolvedService(db, config, null!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePayload(string dir, object[] gerbils, object[]? litters = null, object[]? contacts = null) =>
|
||||||
|
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
Contacts = contacts ?? Array.Empty<object>(),
|
||||||
|
Litters = litters ?? Array.Empty<object>(),
|
||||||
|
Gerbils = gerbils,
|
||||||
|
GerbilPhotos = Array.Empty<object>(),
|
||||||
|
SaleContracts = Array.Empty<object>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
private static object ImportGerbil(Guid id, string name, string genotype, string gender = "female", string dob = "2022-01-01") => new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Gender = gender,
|
||||||
|
Status = "Breeding",
|
||||||
|
DateOfBirth = dob,
|
||||||
|
Genotype = genotype,
|
||||||
|
ColorVarietyId = Schwarz,
|
||||||
|
IsResident = true,
|
||||||
|
OriginBreeder = "Test Zucht",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. Manual animal survives the ingest (never wiped) ───────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Manual_animal_survives_ingest_that_omits_it()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var dir = NewDir();
|
||||||
|
|
||||||
|
var manualId = Guid.NewGuid();
|
||||||
|
db.Gerbils.Add(new Gerbil { Id = manualId, Name = "Handmaus", Gender = Gender.female, IsManual = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var importedId = Guid.NewGuid();
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(importedId, "Importiert", "aa CC DD EE GG PP spsp") });
|
||||||
|
|
||||||
|
var result = await Service(db, dir).RunAsync();
|
||||||
|
|
||||||
|
Assert.StartsWith("Ingestion successful!", result);
|
||||||
|
// Manual row is still there, unchanged.
|
||||||
|
var manual = await db.Gerbils.FindAsync(manualId);
|
||||||
|
Assert.NotNull(manual);
|
||||||
|
Assert.True(manual!.IsManual);
|
||||||
|
Assert.Equal("Handmaus", manual.Name);
|
||||||
|
// Imported row was loaded.
|
||||||
|
Assert.NotNull(await db.Gerbils.FindAsync(importedId));
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Verified freeze forces the golden values over a drifted import ─
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Verified_override_forces_golden_and_records_drift()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var dir = NewDir();
|
||||||
|
var id = new Guid("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
// First ingest: import brings X in with the WRONG genotype/name.
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(id, "Falschname", "aa dd DD EE GG PP spsp") });
|
||||||
|
var svc = Service(db, dir);
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
// The breeder corrects X and marks it "vollständig korrekt".
|
||||||
|
var g = await db.Gerbils.FindAsync(id);
|
||||||
|
Assert.NotNull(g);
|
||||||
|
g!.Name = "Korrekt";
|
||||||
|
g.Genotype = "aa CC DD EE GG PP spsp";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||||
|
db.GerbilOverrides.Add(new GerbilOverride
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GerbilId = id,
|
||||||
|
EntityName = g.Name,
|
||||||
|
IsVerified = true,
|
||||||
|
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||||
|
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||||
|
VerifiedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// A later import DRIFTS back to wrong values.
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(id, "Wiederfalsch", "aa dd DD EE GG PP spsp") });
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
// Golden wins (freeze re-applied on top of the fresh import).
|
||||||
|
var after = await db.Gerbils.FindAsync(id);
|
||||||
|
Assert.Equal("Korrekt", after!.Name);
|
||||||
|
Assert.Equal("aa CC DD EE GG PP spsp", after.Genotype);
|
||||||
|
|
||||||
|
// Drift was detected and recorded, naming the offending fields.
|
||||||
|
var ov = await db.GerbilOverrides.SingleAsync();
|
||||||
|
Assert.False(string.IsNullOrEmpty(ov.LastImportDiffJson));
|
||||||
|
Assert.Contains("genotype", ov.LastImportDiffJson);
|
||||||
|
Assert.Contains("name", ov.LastImportDiffJson);
|
||||||
|
Assert.NotNull(ov.LastImportSnapshotJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. No drift → LastImportDiffJson stays null ──────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Verified_override_without_drift_leaves_diff_null()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var dir = NewDir();
|
||||||
|
var id = new Guid("22222222-2222-2222-2222-222222222222");
|
||||||
|
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(id, "Stabil", "aa CC DD EE GG PP spsp") });
|
||||||
|
var svc = Service(db, dir);
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
var g = await db.Gerbils.FindAsync(id);
|
||||||
|
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||||
|
db.GerbilOverrides.Add(new GerbilOverride
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GerbilId = id,
|
||||||
|
EntityName = g!.Name,
|
||||||
|
IsVerified = true,
|
||||||
|
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||||
|
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||||
|
VerifiedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Re-ingest with the IDENTICAL payload (== golden).
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
var ov = await db.GerbilOverrides.SingleAsync();
|
||||||
|
Assert.Null(ov.LastImportDiffJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Removing the override → import-driven again ───────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Removing_override_restores_import_driven_values()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var dir = NewDir();
|
||||||
|
var id = new Guid("33333333-3333-3333-3333-333333333333");
|
||||||
|
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(id, "Import-Wert", "aa CC DD EE GG PP spsp") });
|
||||||
|
var svc = Service(db, dir);
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
var g = await db.Gerbils.FindAsync(id);
|
||||||
|
g!.Genotype = "aa cc DD EE GG PP spsp"; // "golden"
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||||
|
var ov = new GerbilOverride
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GerbilId = id,
|
||||||
|
IsVerified = true,
|
||||||
|
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||||
|
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.GerbilOverrides.Add(ov);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Freeze wins while the override exists.
|
||||||
|
await svc.RunAsync();
|
||||||
|
Assert.Equal("aa cc DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||||
|
|
||||||
|
// Remove the override → the animal follows the import again.
|
||||||
|
db.GerbilOverrides.Remove(ov);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await svc.RunAsync();
|
||||||
|
Assert.Equal("aa CC DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Per-field protection (pure service level) ─────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerField_change_pins_only_the_changed_field()
|
||||||
|
{
|
||||||
|
var g = new Gerbil { Name = "Quelle", Notes = "alte Notiz", Genotype = "aa CC DD EE GG PP spsp" };
|
||||||
|
|
||||||
|
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||||
|
g.Notes = "neue Notiz";
|
||||||
|
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||||
|
|
||||||
|
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||||
|
var only = Assert.Single(changed); // ONLY notes changed
|
||||||
|
Assert.Equal("notes", only.Key);
|
||||||
|
|
||||||
|
var merged = GerbilSnapshotService.MergeOverrideJson("{}", changed);
|
||||||
|
var mergedObj = JsonNode.Parse(merged)!.AsObject();
|
||||||
|
Assert.True(mergedObj.ContainsKey("notes"));
|
||||||
|
Assert.False(mergedObj.ContainsKey("genotype")); // untouched fields not pinned
|
||||||
|
|
||||||
|
// Applying it onto a different animal overwrites ONLY notes, not the genotype.
|
||||||
|
var target = new Gerbil { Name = "Ziel", Notes = "Ziel-Notiz", Genotype = "zz ZZ dd ee gg pp spsp" };
|
||||||
|
GerbilSnapshotService.ApplyOverride(target, merged);
|
||||||
|
Assert.Equal("neue Notiz", target.Notes);
|
||||||
|
Assert.Equal("zz ZZ dd ee gg pp spsp", target.Genotype);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Dangling reference from a manual row is nulled + warned ────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stale_parent_reference_from_manual_litter_is_nulled_with_warning()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var dir = NewDir();
|
||||||
|
var fatherId = new Guid("44444444-4444-4444-4444-444444444444");
|
||||||
|
|
||||||
|
// Ingest 1 brings in the (imported) father.
|
||||||
|
WritePayload(dir, new[] { ImportGerbil(fatherId, "Vater", "aa CC DD EE GG PP spsp", gender: "male") });
|
||||||
|
var svc = Service(db, dir);
|
||||||
|
await svc.RunAsync();
|
||||||
|
|
||||||
|
// The breeder wires a MANUAL litter to that imported father.
|
||||||
|
var litterId = Guid.NewGuid();
|
||||||
|
db.Litters.Add(new Litter { Id = litterId, Name = "Manueller Wurf", FatherId = fatherId, IsManual = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Ingest 2 no longer contains the father → it becomes stale and is removed.
|
||||||
|
WritePayload(dir, Array.Empty<object>());
|
||||||
|
var result = await svc.RunAsync();
|
||||||
|
|
||||||
|
// The manual litter survives, but its dangling parent link was nulled...
|
||||||
|
var litter = await db.Litters.FindAsync(litterId);
|
||||||
|
Assert.NotNull(litter);
|
||||||
|
Assert.True(litter!.IsManual);
|
||||||
|
Assert.Null(litter.FatherId);
|
||||||
|
// ...and a warning was surfaced.
|
||||||
|
Assert.Contains("warning", result);
|
||||||
|
Assert.Contains("nicht mehr existiert", result);
|
||||||
|
// The stale imported father is gone.
|
||||||
|
Assert.Null(await db.Gerbils.FindAsync(fatherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Diff detects ancestry / offspring differences ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diff_detects_parent_and_children_differences()
|
||||||
|
{
|
||||||
|
var self = new SnapshotSelf(
|
||||||
|
"Proband", "female", "2022-01-01", null, null, null, "aa CC DD EE GG PP spsp",
|
||||||
|
null, "Schwarz", true, null, false, null, null, null, null,
|
||||||
|
new List<string>(), null, null);
|
||||||
|
|
||||||
|
var golden = new GerbilSnapshot(
|
||||||
|
self,
|
||||||
|
new SnapshotParent("Papa Alt", "2020-01-01", "aa CC"),
|
||||||
|
null,
|
||||||
|
new List<SnapshotLitter>
|
||||||
|
{
|
||||||
|
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||||
|
{
|
||||||
|
new("Kind1", "2023-01-01", "female"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
var other = new GerbilSnapshot(
|
||||||
|
self,
|
||||||
|
new SnapshotParent("Papa Neu", "2020-01-01", "aa CC"), // different father
|
||||||
|
null,
|
||||||
|
new List<SnapshotLitter>
|
||||||
|
{
|
||||||
|
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||||
|
{
|
||||||
|
new("Kind1", "2023-01-01", "female"),
|
||||||
|
new("Kind2", "2023-01-02", "male"), // extra child
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
var diffs = GerbilSnapshotService.Diff(golden, other);
|
||||||
|
Assert.NotEmpty(diffs);
|
||||||
|
Assert.Contains(diffs, d => d.Path == "father");
|
||||||
|
Assert.Contains(diffs, d => d.Path.StartsWith("wurf["));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Golden regression fixture: ingests the REAL <c>tools/import/output/resolved_import.json</c>
|
||||||
|
/// (gitignored, local only) and asserts that every verified key animal's Akte still matches its
|
||||||
|
/// frozen golden snapshot in <c>tools/import/verified-golden.json</c> (committed fixture).
|
||||||
|
///
|
||||||
|
/// Both files are optional: if EITHER is missing the test skips cleanly (no failure), so it is a
|
||||||
|
/// no-op on CI / fresh clones but a real guard on the breeder's machine once she has verified
|
||||||
|
/// animals and exported the fixture.
|
||||||
|
/// </summary>
|
||||||
|
public class VerifiedGoldenTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public VerifiedGoldenTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private sealed record GoldenEntry(Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, GerbilSnapshot? Snapshot);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private static string? FindRepoRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(dir.FullName, "tools", "import")))
|
||||||
|
return dir.FullName;
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Verified_animals_still_match_their_golden_snapshot()
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepoRoot();
|
||||||
|
if (repoRoot is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: repo root (tools/import) not found from the test host.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var goldenPath = Path.Combine(repoRoot, "tools", "import", "verified-golden.json");
|
||||||
|
var outputDir = Path.Combine(repoRoot, "tools", "import", "output");
|
||||||
|
var resolvedPath = Path.Combine(outputDir, "resolved_import.json");
|
||||||
|
|
||||||
|
if (!File.Exists(goldenPath))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: golden fixture not present ({goldenPath}).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!File.Exists(resolvedPath))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: resolved_import.json not present ({resolvedPath}) — run the import pipeline.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GoldenEntry>? golden;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
golden = JsonSerializer.Deserialize<List<GoldenEntry>>(await File.ReadAllTextAsync(goldenPath), Web);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: could not parse golden fixture: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (golden is null || golden.Count == 0)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: golden fixture is empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest the real resolved_import.json into an InMemory DB.
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseInMemoryDatabase("verified-golden-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var photoDir = Path.Combine(Path.GetTempPath(), "verified-golden-photos-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Import:SourcePath", outputDir },
|
||||||
|
{ "Photos:RootPath", photoDir },
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
Assert.StartsWith("Ingestion successful!", result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: ingest of the real resolved_import.json failed: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(photoDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
var mismatches = new List<string>();
|
||||||
|
int checkedCount = 0;
|
||||||
|
foreach (var entry in golden)
|
||||||
|
{
|
||||||
|
if (entry.Snapshot is null) continue;
|
||||||
|
checkedCount++;
|
||||||
|
GerbilSnapshot? actual;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
actual = await GerbilSnapshotService.BuildSnapshotAsync(db, entry.GerbilId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: Akte konnte nicht gebaut werden — {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual is null)
|
||||||
|
{
|
||||||
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: fehlt im Import (verifiziertes Tier verschwunden).");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var diffs = GerbilSnapshotService.Diff(entry.Snapshot, actual);
|
||||||
|
if (diffs.Count > 0)
|
||||||
|
{
|
||||||
|
var fields = string.Join("; ", diffs.Select(d => $"{d.Path}: golden='{d.GoldenValue}' ≠ import='{d.OtherValue}'"));
|
||||||
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: {diffs.Count} Abweichung(en) — {fields}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"Golden-Fixture geprüft: {checkedCount} verifizierte Tiere, {mismatches.Count} abweichend.");
|
||||||
|
if (mismatches.Count > 0)
|
||||||
|
{
|
||||||
|
var report = $"Golden-Regression fehlgeschlagen — {mismatches.Count} von {checkedCount} verifizierten Tieren weichen vom eingefrorenen Snapshot ab:\n"
|
||||||
|
+ string.Join("\n", mismatches);
|
||||||
|
Assert.Fail(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,14 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Request> Requests => Set<Request>();
|
public DbSet<Request> Requests => Set<Request>();
|
||||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||||
|
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||||
|
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||||
|
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||||
|
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||||
|
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||||
|
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||||
|
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||||
|
public DbSet<GerbilOverride> GerbilOverrides => Set<GerbilOverride>();
|
||||||
|
|
||||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
// 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.
|
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||||
@@ -67,6 +75,10 @@ public class ApplicationContext : DbContext
|
|||||||
// Residency defaults to true (own stock unless explicitly marked external).
|
// Residency defaults to true (own stock unless explicitly marked external).
|
||||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||||
|
|
||||||
|
// Manual-flag defaults false: every pre-existing / imported row is import-driven.
|
||||||
|
// Only POST /gerbils sets it true, so the ingest wipe never touches manual animals.
|
||||||
|
e.Property(g => g.IsManual).HasDefaultValue(false);
|
||||||
|
|
||||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||||
if (isNpgsql)
|
if (isNpgsql)
|
||||||
{
|
{
|
||||||
@@ -118,6 +130,8 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasIndex(l => l.ExternalRef)
|
e.HasIndex(l => l.ExternalRef)
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
|
e.Property(l => l.IsManual).HasDefaultValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<HealthRecord>(e =>
|
modelBuilder.Entity<HealthRecord>(e =>
|
||||||
@@ -212,6 +226,68 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasIndex(f => f.CreatedAt);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// VERIFIED/PROTECTED OVERRIDE: deliberately relationship-free (same pattern as Feedback).
|
||||||
|
// GerbilId is a plain Guid column (no navigation property → EF creates NO foreign key), so
|
||||||
|
// the import re-ingest wipe of Gerbils never cascades into — or breaks — override rows. They
|
||||||
|
// survive re-ingest and are re-applied on top of the fresh import (the freeze), which is the
|
||||||
|
// whole point.
|
||||||
|
modelBuilder.Entity<GerbilOverride>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(o => o.GerbilId).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual-flag default false for Contact (imported/pre-existing rows are import-driven).
|
||||||
|
modelBuilder.Entity<Contact>().Property(c => c.IsManual).HasDefaultValue(false);
|
||||||
|
|
||||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
if (isNpgsql)
|
if (isNpgsql)
|
||||||
{
|
{
|
||||||
@@ -248,9 +324,9 @@ public class ApplicationContext : DbContext
|
|||||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||||
{
|
{
|
||||||
(7, "impressum", "Impressum",
|
(7, "impressum", "Impressum",
|
||||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*"),
|
||||||
(8, "datenschutz", "Datenschutz",
|
(8, "datenschutz", "Datenschutz",
|
||||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*"),
|
||||||
};
|
};
|
||||||
|
|
||||||
var pageRows = new List<Page>();
|
var pageRows = new List<Page>();
|
||||||
@@ -335,10 +411,12 @@ public class ApplicationContext : DbContext
|
|||||||
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||||
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
// GEN-6 (Tickets bde4ec70/f89e95ad): C-Zygotie unterscheidet Platin (CC),
|
||||||
|
// Saphir (Ccchm) und Platin-Hell (Cch) — vorher waren alle drei "aa CC ...".
|
||||||
|
("Saphir", "aa Ccchm DD EE GG pp spsp rere", 24),
|
||||||
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||||
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
("Platin-Hell", "aa Cch DD EE GG pp spsp rere", 27),
|
||||||
// --- Dilute (dd) varieties (IDs 29-32) ---
|
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||||
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||||
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||||
@@ -346,25 +424,28 @@ public class ApplicationContext : DbContext
|
|||||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
|
||||||
|
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
|
||||||
|
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
|
||||||
// --- Hell variants (IDs 38-48) ---
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
|
||||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
|
||||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
|
||||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
|
||||||
|
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
|
||||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseA
|
|||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# curl für den Container-Healthcheck (das Basis-Image bringt weder curl noch wget mit;
|
||||||
|
# ohne dieses würde der compose-Healthcheck dauerhaft fehlschlagen und api nie "healthy").
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Dtos
|
namespace GerbilManagerWebAPI.Dtos
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
bool IsResident,
|
bool IsResident,
|
||||||
string? ProfilePhotoUrl,
|
string? ProfilePhotoUrl,
|
||||||
bool IsCastrated,
|
bool IsCastrated,
|
||||||
string? Provenance);
|
string? Provenance,
|
||||||
|
int? BirthOrder = null);
|
||||||
|
|
||||||
public record LitterDto(
|
public record LitterDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -41,15 +42,20 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
DateOnly? Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
int? DeathsWithin8Weeks,
|
int? DeathsWithin8Weeks,
|
||||||
|
int? Stillborn,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? Provenance);
|
string? Provenance,
|
||||||
|
bool ShowInChronicle);
|
||||||
|
|
||||||
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 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);
|
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
||||||
|
|
||||||
@@ -62,8 +68,33 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
|
|
||||||
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
||||||
|
|
||||||
|
// VERIFIED/PROTECTED OVERRIDE -------------------------------------------
|
||||||
|
// Status: "unchanged" = raw import agrees with the golden state; "drifted" = the raw import
|
||||||
|
// differs from the golden state (see ImportDiff — a signal for what the importer might still
|
||||||
|
// get wrong); "missing" = the verified animal no longer exists in the imported data.
|
||||||
|
public record VerifiedGerbilDto(
|
||||||
|
Guid GerbilId,
|
||||||
|
string? EntityName,
|
||||||
|
bool IsVerified,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset? VerifiedAt,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
string? Note,
|
||||||
|
IReadOnlyList<string> ProtectedFields,
|
||||||
|
IReadOnlyList<SnapshotDiffDto> ImportDiff);
|
||||||
|
|
||||||
|
/// <summary>One field difference between the golden state and the raw import.</summary>
|
||||||
|
public record SnapshotDiffDto(string Path, string? GoldenValue, string? ImportValue);
|
||||||
|
|
||||||
|
/// <summary>Export entry for the committed regression fixture (verified-golden.json).</summary>
|
||||||
|
public record VerifiedGoldenEntry(
|
||||||
|
Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, Import.GerbilSnapshot? Snapshot);
|
||||||
|
|
||||||
// Request DTOs -----------------------------------------------------------
|
// Request DTOs -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Body for POST /verified-gerbils/{id} — optional free-text note.</summary>
|
||||||
|
public record VerifyGerbilInput(string? Note);
|
||||||
|
|
||||||
public record GerbilInput(
|
public record GerbilInput(
|
||||||
string? Name,
|
string? Name,
|
||||||
Gender? Gender,
|
Gender? Gender,
|
||||||
@@ -89,19 +120,35 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
bool? IsResident,
|
bool? IsResident,
|
||||||
bool? IsCastrated);
|
bool? IsCastrated);
|
||||||
|
|
||||||
|
/// <summary>QOL: Eltern direkt in der Tier-Akte setzen (PUT /gerbils/{id}/parents).
|
||||||
|
/// Das Datenmodell bleibt unverändert — geschrieben werden die Eltern des GEBURTSWURFS.</summary>
|
||||||
|
public record GerbilParentsInput(Guid? FatherId, Guid? MotherId);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von PUT /gerbils/{id}/parents: welcher Wurf die Eltern jetzt trägt, ob er
|
||||||
|
/// dafür neu angelegt (LitterCreated) bzw. ein bestehender verknüpft wurde (LitterAttached) und
|
||||||
|
/// wie viele Geschwister im selben Wurf die Änderung mitbetrifft.</summary>
|
||||||
|
public record GerbilParentsResult(
|
||||||
|
Guid? LitterId, string? LitterName, bool LitterCreated, bool LitterAttached,
|
||||||
|
int SiblingCount, Guid? FatherId, Guid? MotherId);
|
||||||
|
|
||||||
public record LitterInput(
|
public record LitterInput(
|
||||||
string Name,
|
string Name,
|
||||||
DateOnly? Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
int? DeathsWithin8Weeks,
|
int? DeathsWithin8Weeks,
|
||||||
|
int? Stillborn,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
bool? ShowInChronicle = null);
|
||||||
|
|
||||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
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);
|
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
IReadOnlyList<Guid> GerbilIds,
|
IReadOnlyList<Guid> GerbilIds,
|
||||||
// Download-URL der .docx ("/contracts/{id}/file").
|
// 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(
|
public record SaleContractInput(
|
||||||
Guid ContactId,
|
Guid ContactId,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,28 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? Url,
|
string? Url,
|
||||||
DateTimeOffset? ClientTimestamp);
|
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: lightweight metadata for an attachment (no bytes).</summary>
|
||||||
|
public record FeedbackAttachmentDto(
|
||||||
|
Guid Id,
|
||||||
|
string FileName,
|
||||||
|
string ContentType,
|
||||||
|
int Size);
|
||||||
|
|
||||||
|
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||||
|
public record FeedbackAttachmentInput(
|
||||||
|
string FileName,
|
||||||
|
string ContentType,
|
||||||
|
string DataBase64);
|
||||||
|
|
||||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||||
public record FeedbackDto(
|
public record FeedbackDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -23,5 +45,49 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? Url,
|
string? Url,
|
||||||
DateTimeOffset? ClientTimestamp,
|
DateTimeOffset? ClientTimestamp,
|
||||||
string? UserAgent,
|
string? UserAgent,
|
||||||
DateTimeOffset CreatedAt);
|
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>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
|
||||||
|
DateTimeOffset? ReopenedAt = null,
|
||||||
|
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
|
||||||
|
DateTimeOffset? DeletedAt = null,
|
||||||
|
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
|
||||||
|
string? Category = null,
|
||||||
|
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
|
||||||
|
bool? Helpful = null,
|
||||||
|
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
|
||||||
|
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||||
|
/// 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,
|
||||||
|
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
||||||
|
string? Category = null,
|
||||||
|
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
|
||||||
|
bool? Helpful = null,
|
||||||
|
/// <summary>
|
||||||
|
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
|
||||||
|
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
|
||||||
|
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
|
||||||
|
/// </summary>
|
||||||
|
bool? Notify = null);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
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, IsManual = true };
|
||||||
db.Contacts.Add(c);
|
db.Contacts.Add(c);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||||
|
|||||||
@@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||||
|
|
||||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
|
||||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
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)
|
.Include(c => c.Animals)
|
||||||
.ToPagedResultAsync(query, ToDto)));
|
.ToPagedResultAsync(query, c => ToDto(c, root)));
|
||||||
|
});
|
||||||
|
|
||||||
// GET /contracts/{id}
|
// 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()
|
var c = await db.SaleContracts.AsNoTracking()
|
||||||
.Include(x => x.Animals)
|
.Include(x => x.Animals)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id);
|
.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.
|
// POST /contracts — der Abgabe-Abschluss.
|
||||||
@@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return cleaned.Trim('-');
|
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.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
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) =>
|
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||||
|
Apply(e, input);
|
||||||
db.Enclosures.Add(e);
|
db.Enclosures.Add(e);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
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);
|
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (e is null) return TypedResults.NotFound();
|
if (e is null) return TypedResults.NotFound();
|
||||||
e.Name = input.Name; e.Notes = input.Notes;
|
e.Name = input.Name; e.Notes = input.Notes;
|
||||||
|
Apply(e, input);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
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.
|
// 409 if the enclosure still houses gerbils.
|
||||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
@@ -54,6 +66,16 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
@@ -6,14 +7,23 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace GerbilManagerWebAPI.Endpoints
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEEDBACK: the "Fehler melden" report sink.
|
/// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets").
|
||||||
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||||
/// GET /feedback -> list reports, newest first (for later review).
|
/// 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
|
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||||
/// rows survive the import re-ingest wipe.
|
/// rows survive the import re-ingest wipe.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FeedbackEndpoints
|
public static class FeedbackEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||||
|
private const int TrashRetentionDays = 30;
|
||||||
|
|
||||||
|
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
|
||||||
|
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||||
@@ -37,27 +47,372 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
ClientTimestamp = input.ClientTimestamp,
|
ClientTimestamp = input.ClientTimestamp,
|
||||||
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
Status = "Open",
|
||||||
|
ResolvedAt = null,
|
||||||
};
|
};
|
||||||
db.Feedback.Add(entity);
|
db.Feedback.Add(entity);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/", async (ApplicationContext db) =>
|
group.MapGet("/", async (ApplicationContext db, string? status) =>
|
||||||
{
|
{
|
||||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
// auf DateTimeOffset-Spalten übersetzen.
|
||||||
|
var rows = await db.Feedback.ToListAsync();
|
||||||
|
|
||||||
|
// Optionaler Status-Filter (?status=Open,Answered) — kommagetrennt, case-insensitiv.
|
||||||
|
// Spart der Triage den 2-MB-Volldump; soft-gelöschte Tickets werden dann ausgeblendet.
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
{
|
||||||
|
var wanted = status.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
rows = rows.Where(f => f.DeletedAt is null && wanted.Contains(f.Status)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||||
|
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||||
|
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||||
|
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
|
||||||
|
if (expired.Count > 0)
|
||||||
|
{
|
||||||
|
db.Feedback.RemoveRange(expired);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
rows = rows.Except(expired).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
|
||||||
|
var attMeta = await db.FeedbackAttachments
|
||||||
|
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
|
||||||
|
.ToListAsync();
|
||||||
|
var byTicket = attMeta
|
||||||
|
.GroupBy(a => a.FeedbackId)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => (IReadOnlyList<FeedbackAttachmentDto>)g
|
||||||
|
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
return TypedResults.Ok(rows
|
return TypedResults.Ok(rows
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
.Select(ToDto)
|
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||||
.ToList());
|
.ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Einzelnes Ticket (inkl. Anhang-Metadaten). Praktisch für die Triage, um ein Ticket
|
||||||
|
// gezielt zu laden, statt die ganze Liste zu ziehen. 404 bei unbekannter Id.
|
||||||
|
group.MapGet("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (entity is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var atts = await db.FeedbackAttachments
|
||||||
|
.Where(a => a.FeedbackId == id)
|
||||||
|
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||||
|
.ToListAsync();
|
||||||
|
return TypedResults.Ok(ToDto(entity, atts));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||||
|
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
|
||||||
|
{
|
||||||
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (entity is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
|
||||||
|
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
|
||||||
|
|
||||||
|
if (input.Message is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Message))
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||||
|
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
|
||||||
|
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
|
||||||
|
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
|
||||||
|
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||||
|
entity.ReopenedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen.
|
||||||
|
if (input.Category is not null)
|
||||||
|
{
|
||||||
|
var cat = input.Category.Trim();
|
||||||
|
entity.Category = cat.Length == 0 ? null : cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert).
|
||||||
|
if (input.Helpful is not null)
|
||||||
|
entity.Helpful = input.Helpful;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
|
||||||
|
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
|
||||||
|
if (input.Notify == true && push.Enabled)
|
||||||
|
{
|
||||||
|
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
|
||||||
|
var (title, body) = entity.Status switch
|
||||||
|
{
|
||||||
|
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
|
||||||
|
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
|
||||||
|
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypedResults.Ok(ToDto(entity));
|
||||||
|
});
|
||||||
|
|
||||||
|
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
|
||||||
|
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
|
||||||
|
// über POST /feedback/{id}/restore.
|
||||||
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (entity is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
entity.DeletedAt ??= DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
|
||||||
|
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
|
||||||
|
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (entity is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
entity.DeletedAt = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Ok(ToDto(entity));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
|
||||||
|
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
|
||||||
|
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (ticket is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
|
||||||
|
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// erlaubt sowohl reines base64 als auch eine data:-URL
|
||||||
|
var raw = input.DataBase64;
|
||||||
|
var comma = raw.IndexOf(',');
|
||||||
|
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
|
||||||
|
raw = raw[(comma + 1)..];
|
||||||
|
bytes = Convert.FromBase64String(raw);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
|
||||||
|
}
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
return TypedResults.BadRequest("Datei ist leer.");
|
||||||
|
if (bytes.Length > MaxAttachmentBytes)
|
||||||
|
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
|
||||||
|
|
||||||
|
var att = new FeedbackAttachment
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FeedbackId = id,
|
||||||
|
FileName = input.FileName.Trim(),
|
||||||
|
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
|
||||||
|
Size = bytes.Length,
|
||||||
|
Data = bytes,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.FeedbackAttachments.Add(att);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/feedback/attachments/{att.Id}",
|
||||||
|
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ANHANG-Bytes ausliefern (für <img>/Download).
|
||||||
|
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||||
|
Guid attId, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
|
||||||
|
if (att is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
return TypedResults.File(att.Data, att.ContentType, att.FileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ANHANG löschen.
|
||||||
|
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
|
Guid attId, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
|
||||||
|
if (att is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
db.FeedbackAttachments.Remove(att);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FeedbackDto ToDto(Feedback f) =>
|
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
|
||||||
|
private static string? Trim(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
var t = s.Trim();
|
||||||
|
return t.Length > 140 ? t[..139] + "…" : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
||||||
|
/// before it gets overwritten by a new round, then clear the current Answer. Only the
|
||||||
|
/// 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, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
|
||||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
|
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||||
|
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||||
|
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
|
||||||
|
attachments ?? Array.Empty<FeedbackAttachmentDto>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GerbilManagerWebAPI.Common;
|
using GerbilManagerWebAPI.Common;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using GerbilManagerWebAPI.Services;
|
using GerbilManagerWebAPI.Services;
|
||||||
using Gridify;
|
using Gridify;
|
||||||
@@ -61,7 +62,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
// Manually created in the UI → IsManual=true, so the import re-ingest never wipes or
|
||||||
|
// overwrites this animal (and its sub-records).
|
||||||
|
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name!, IsManual = true };
|
||||||
Apply(g, input, isCreate: true);
|
Apply(g, input, isCreate: true);
|
||||||
db.Gerbils.Add(g);
|
db.Gerbils.Add(g);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -73,11 +76,79 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (g is null) return TypedResults.NotFound();
|
if (g is null) return TypedResults.NotFound();
|
||||||
|
|
||||||
|
// Capture the own-field values BEFORE the edit so manual changes to an IMPORTED animal
|
||||||
|
// can be pinned per-field (survive the next ingest). Manual animals need no override —
|
||||||
|
// they survive the ingest wholesale.
|
||||||
|
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||||
Apply(g, input, isCreate: false);
|
Apply(g, input, isCreate: false);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (!g.IsManual)
|
||||||
|
await RecordEditOverrideAsync(db, g, before);
|
||||||
|
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /gerbils/{id}/parents — QOL (Wunsch der Züchterin): Eltern DIREKT in der
|
||||||
|
// Tier-Akte pflegen, ohne vorher den Wurf suchen zu müssen. Das Datenmodell bleibt
|
||||||
|
// unverändert — Eltern hängen weiterhin am GEBURTSWURF (Litter.FatherId/MotherId),
|
||||||
|
// dieser Endpoint schreibt nur genau dorthin:
|
||||||
|
// • Tier hat einen Wurf → dessen Eltern werden gesetzt. Das gilt zwangsläufig für
|
||||||
|
// ALLE Jungtiere des Wurfs; die UI warnt vorher mit SiblingCount.
|
||||||
|
// • Tier hat keinen Wurf → gibt es einen Wurf mit genau diesem Elternpaar UND
|
||||||
|
// demselben Datum wie das Geburtsdatum (= dieselbe Geburt), wird das Tier dort
|
||||||
|
// eingehängt (LitterAttached). Sonst wird ein Wurf angelegt (LitterCreated) mit
|
||||||
|
// ShowInChronicle=false (nicht in der Wurfchronik, nur zur Abstammung) und
|
||||||
|
// IsManual=true (überlebt den Re-Ingest).
|
||||||
|
group.MapPut("/{id:guid}/parents", async Task<Results<Ok<GerbilParentsResult>, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (
|
||||||
|
Guid id, GerbilParentsInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (g is null) return TypedResults.NotFound();
|
||||||
|
if (input.FatherId == id || input.MotherId == id)
|
||||||
|
return TypedResults.BadRequest("Ein Tier kann nicht sein eigener Elternteil sein.");
|
||||||
|
var genderErr = await LitterEndpoints.ValidateParents(db, input.FatherId, input.MotherId);
|
||||||
|
if (genderErr is not null) return TypedResults.BadRequest(genderErr);
|
||||||
|
|
||||||
|
var litter = g.LitterId is Guid lid
|
||||||
|
? await db.Litters.FirstOrDefaultAsync(l => l.Id == lid)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Nichts zu tun: kein Wurf und keine Eltern angegeben.
|
||||||
|
if (litter is null && input.FatherId is null && input.MotherId is null)
|
||||||
|
return TypedResults.Ok(new GerbilParentsResult(null, null, false, false, 0, null, null));
|
||||||
|
|
||||||
|
var created = false;
|
||||||
|
var attached = false;
|
||||||
|
if (litter is null)
|
||||||
|
{
|
||||||
|
// Gleiches Elternpaar + gleiches Datum ⇒ dieselbe Geburt: an den bestehenden
|
||||||
|
// Wurf hängen (macht das Tier korrekt zum Geschwister), statt zu duplizieren.
|
||||||
|
if (g.DateOfBirth is DateOnly dob)
|
||||||
|
{
|
||||||
|
litter = await db.Litters.FirstOrDefaultAsync(l =>
|
||||||
|
l.FatherId == input.FatherId && l.MotherId == input.MotherId && l.Date == dob);
|
||||||
|
attached = litter is not null;
|
||||||
|
}
|
||||||
|
litter ??= NewParentLitter(g, await NameOfAsync(db, input.FatherId), await NameOfAsync(db, input.MotherId));
|
||||||
|
if (!attached)
|
||||||
|
{
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
created = true;
|
||||||
|
}
|
||||||
|
g.LitterId = litter.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
litter.FatherId = input.FatherId;
|
||||||
|
litter.MotherId = input.MotherId;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var siblingCount = await db.Gerbils.CountAsync(x => x.LitterId == litter.Id && x.Id != id);
|
||||||
|
return TypedResults.Ok(new GerbilParentsResult(
|
||||||
|
litter.Id, litter.Name, created, attached, siblingCount, litter.FatherId, litter.MotherId));
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
|
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
|
||||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
@@ -98,6 +169,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> NameOfAsync(ApplicationContext db, Guid? gerbilId) =>
|
||||||
|
gerbilId is Guid gid
|
||||||
|
? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>Träger-Wurf für die Eltern eines Tiers ohne Geburtswurf. Namensschema wie die
|
||||||
|
/// vom Import erzeugten Stammbaum-Würfe ("Wurf von X + Y"); ShowInChronicle=false, damit die
|
||||||
|
/// Wurfchronik nicht mit Hilfs-Würfen zuwächst; IsManual=true, damit der Re-Ingest ihn nicht wegräumt.</summary>
|
||||||
|
private static Litter NewParentLitter(Gerbil g, string? fatherName, string? motherName) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = $"Wurf von {fatherName ?? "—"} + {motherName ?? "—"}",
|
||||||
|
Date = g.DateOfBirth,
|
||||||
|
ShowInChronicle = false,
|
||||||
|
IsManual = true,
|
||||||
|
Notes = $"Automatisch angelegt, um die Eltern von {g.Name} zu tragen.",
|
||||||
|
};
|
||||||
|
|
||||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
// 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,
|
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||||
@@ -133,11 +222,47 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EDIT PROTECTION: after a manual edit to an IMPORTED animal, pin the change so the next
|
||||||
|
// ingest can't overwrite it. A VERIFIED animal keeps its certification and its whole golden
|
||||||
|
// is refreshed to the edited state (Frage 5). An unverified animal gets/updates a "geschützt"
|
||||||
|
// override carrying ONLY the changed fields (per-field) — untouched fields keep receiving
|
||||||
|
// import improvements. Called after the edit is already persisted.
|
||||||
|
private static async Task RecordEditOverrideAsync(ApplicationContext db, Gerbil g, System.Text.Json.Nodes.JsonObject before)
|
||||||
|
{
|
||||||
|
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == g.Id);
|
||||||
|
if (ov is { IsVerified: true })
|
||||||
|
{
|
||||||
|
ov.EntityName = g.Name;
|
||||||
|
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||||
|
var snap = await GerbilSnapshotService.BuildSnapshotAsync(db, g.Id);
|
||||||
|
if (snap is not null) ov.SnapshotJson = GerbilSnapshotService.SerializeSnapshot(snap);
|
||||||
|
ov.LastImportSnapshotJson = null;
|
||||||
|
ov.LastImportDiffJson = null;
|
||||||
|
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||||
|
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||||
|
if (changed.Count == 0) return;
|
||||||
|
|
||||||
|
if (ov is null)
|
||||||
|
{
|
||||||
|
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = g.Id, IsVerified = false, OverrideJson = "{}" };
|
||||||
|
db.GerbilOverrides.Add(ov);
|
||||||
|
}
|
||||||
|
ov.EntityName = g.Name;
|
||||||
|
ov.OverrideJson = GerbilSnapshotService.MergeOverrideJson(ov.OverrideJson, changed);
|
||||||
|
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||||
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
||||||
g.Provenance);
|
g.Provenance, g.BirthOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Import;
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Import.Rpro3;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
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/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public static class ImportEndpoints
|
public static class ImportEndpoints
|
||||||
{
|
{
|
||||||
@@ -36,7 +44,161 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(result);
|
return TypedResults.Ok(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload-Variante: lädt eine resolved_import.json (+ optional Fotos-ZIP) hoch, legt sie in
|
||||||
|
// dem Verzeichnis ab, aus dem der Ingest liest, und ingested direkt. Damit lässt sich der
|
||||||
|
// Import gegen PROD fahren, OHNE die Datei per SSH/docker cp in den Container zu kopieren
|
||||||
|
// (der lokal generierte Payload geht per HTTP an die Prod-API). Abwärtskompatibel — der
|
||||||
|
// bestehende /ingest-resolved-Endpoint bleibt unverändert.
|
||||||
|
group.MapPost("/ingest-resolved/upload", async Task<Results<Ok<string>, BadRequest<string>>> (
|
||||||
|
IFormFile resolved, IFormFile? photos,
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
if (resolved.Length == 0)
|
||||||
|
return TypedResults.BadRequest("resolved_import.json ist leer.");
|
||||||
|
|
||||||
|
var sourceDir = IngestResolvedService.ResolveSourceDir(config, env);
|
||||||
|
Directory.CreateDirectory(sourceDir);
|
||||||
|
|
||||||
|
// resolved_import.json an den erwarteten Ort schreiben.
|
||||||
|
var destPath = Path.Combine(sourceDir, "resolved_import.json");
|
||||||
|
await using (var fs = File.Create(destPath))
|
||||||
|
await resolved.CopyToAsync(fs);
|
||||||
|
|
||||||
|
// Optionale Fotos-ZIP in den Source-Dir entpacken (Einträge relativ), damit der
|
||||||
|
// Foto-Kopierschritt (Path.Combine(sourceDir, relPath)) neue Fotos findet.
|
||||||
|
if (photos is not null)
|
||||||
|
{
|
||||||
|
await using var zs = photos.OpenReadStream();
|
||||||
|
using var seekable = await ToSeekable(zs);
|
||||||
|
ExtractZipInto(seekable, sourceDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||||
|
return TypedResults.Ok(result);
|
||||||
|
}).WithTags("Import").DisableAntiforgery();
|
||||||
|
|
||||||
|
// ── 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;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entpackt ein ZIP relativ in destDir (Einträge behalten ihren relativen Pfad). Schützt vor
|
||||||
|
// Zip-Slip (Einträge, die aus destDir ausbrechen wollen) und überspringt Verzeichnis-Einträge.
|
||||||
|
private static void ExtractZipInto(Stream zip, string destDir)
|
||||||
|
{
|
||||||
|
var root = Path.GetFullPath(destDir);
|
||||||
|
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||||
|
foreach (var e in archive.Entries)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(e.Name)) continue; // Verzeichnis-Eintrag
|
||||||
|
var target = Path.GetFullPath(Path.Combine(root, e.FullName));
|
||||||
|
if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||||
|
&& !string.Equals(target, root, StringComparison.Ordinal))
|
||||||
|
continue; // Zip-Slip abwehren
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||||
|
try { e.ExtractToFile(target, overwrite: true); }
|
||||||
|
catch { /* defektes Archiv-Entry überspringen */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDir(string dir)
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using GerbilManagerWebAPI.Common;
|
using GerbilManagerWebAPI.Common;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Gridify;
|
using Gridify;
|
||||||
@@ -30,7 +30,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||||
|
|
||||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date, IsManual = true };
|
||||||
Apply(l, input);
|
Apply(l, input);
|
||||||
db.Litters.Add(l);
|
db.Litters.Add(l);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -65,7 +65,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
// father must not be female; mother must not be male (unknown is allowed).
|
// father must not be female; mother must not be male (unknown is allowed).
|
||||||
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
// internal: auch von PUT /gerbils/{id}/parents genutzt (QOL-Eltern-Editor), damit die
|
||||||
|
// Geschlechts-Regel für Eltern an genau EINER Stelle lebt.
|
||||||
|
internal static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
||||||
{
|
{
|
||||||
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
|
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
|
||||||
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
|
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
|
||||||
@@ -81,10 +83,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
l.TotalBorn = i.TotalBorn;
|
l.TotalBorn = i.TotalBorn;
|
||||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||||
|
l.Stillborn = i.Stillborn;
|
||||||
l.FatherId = i.FatherId;
|
l.FatherId = i.FatherId;
|
||||||
l.MotherId = i.MotherId;
|
l.MotherId = i.MotherId;
|
||||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||||
l.Notes = i.Notes;
|
l.Notes = i.Notes;
|
||||||
|
// Nur überschreiben, wenn explizit gesetzt — sonst bestehenden Wert / Modell-Default
|
||||||
|
// (true) behalten, damit ein Edit ohne dieses Feld einen versteckten Wurf nicht
|
||||||
|
// versehentlich wieder in die Wurfchronik holt.
|
||||||
|
if (i.ShowInChronicle is bool show) l.ShowInChronicle = show;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||||
@@ -97,8 +104,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static LitterDto ToDto(Litter l) => new(
|
private static LitterDto ToDto(Litter l) => new(
|
||||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks, l.Stillborn,
|
||||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance, l.ShowInChronicle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||||
|
|||||||
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Push;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-PUSH: PWA-Benachrichtigungen für die Züchterin.
|
||||||
|
/// GET /push/vapid-public-key -> öffentlicher VAPID-Schlüssel (für das Abonnieren im Browser).
|
||||||
|
/// POST /push/subscribe -> Browser-Abo speichern (idempotent über die Endpoint-URL).
|
||||||
|
/// POST /push/unsubscribe -> Abo entfernen.
|
||||||
|
/// Das eigentliche Senden passiert über <see cref="PushNotifier"/> (z. B. wenn die KI eine
|
||||||
|
/// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback).
|
||||||
|
/// </summary>
|
||||||
|
public static class PushEndpoints
|
||||||
|
{
|
||||||
|
public record PushSubscriptionInput(string Endpoint, string P256dh, string Auth);
|
||||||
|
public record PushUnsubscribeInput(string Endpoint);
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/push").WithTags("Push");
|
||||||
|
|
||||||
|
group.MapGet("/vapid-public-key", (PushNotifier push) =>
|
||||||
|
TypedResults.Ok(new { publicKey = push.PublicKey, enabled = push.Enabled }));
|
||||||
|
|
||||||
|
group.MapPost("/subscribe", async Task<Results<Ok, BadRequest<string>>> (
|
||||||
|
PushSubscriptionInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Endpoint)
|
||||||
|
|| string.IsNullOrWhiteSpace(input.P256dh)
|
||||||
|
|| string.IsNullOrWhiteSpace(input.Auth))
|
||||||
|
return TypedResults.BadRequest("Endpoint, P256dh und Auth sind erforderlich.");
|
||||||
|
|
||||||
|
var existing = await db.WebPushSubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.Endpoint == input.Endpoint);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.WebPushSubscriptions.Add(new WebPushSubscription
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Endpoint = input.Endpoint,
|
||||||
|
P256dh = input.P256dh,
|
||||||
|
Auth = input.Auth,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.P256dh = input.P256dh;
|
||||||
|
existing.Auth = input.Auth;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/unsubscribe", async (PushUnsubscribeInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var subs = await db.WebPushSubscriptions
|
||||||
|
.Where(s => s.Endpoint == input.Endpoint)
|
||||||
|
.ToListAsync();
|
||||||
|
if (subs.Count > 0)
|
||||||
|
{
|
||||||
|
db.WebPushSubscriptions.RemoveRange(subs);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// VERIFIED / PROTECTED OVERRIDES — the breeder's hand-curated "this animal is correct" layer.
|
||||||
|
/// POST /verified-gerbils/{gerbilId} -> mark "vollständig korrekt": snapshot the full Akte,
|
||||||
|
/// freeze all own fields, apply the freeze immediately. Toggle-on. Optional {note}.
|
||||||
|
/// DELETE /verified-gerbils/{gerbilId} -> remove the override (un-flag / release protection).
|
||||||
|
/// Non-destructive: current values stay; the next ingest makes the animal import-driven again.
|
||||||
|
/// GET /verified-gerbils -> all overrides (verified + protected) with drift status.
|
||||||
|
/// GET /verified-gerbils/{gerbilId} -> single status (badge on the Rennmausakte).
|
||||||
|
/// GET /verified-gerbils/export -> canonical golden dump for the committed regression fixture.
|
||||||
|
/// Overrides are decoupled from gerbils (loose GerbilId, no FK), so they survive the import
|
||||||
|
/// re-ingest wipe and are re-applied on top of the fresh import (the "freeze").
|
||||||
|
/// </summary>
|
||||||
|
public static class VerifiedGerbilEndpoints
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapVerifiedGerbilEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/verified-gerbils").WithTags("VerifiedGerbils");
|
||||||
|
|
||||||
|
group.MapPost("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||||
|
Guid gerbilId, VerifyGerbilInput? input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == gerbilId);
|
||||||
|
if (g is null) return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var snapshot = await GerbilSnapshotService.BuildSnapshotAsync(db, gerbilId);
|
||||||
|
|
||||||
|
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||||
|
if (ov is null)
|
||||||
|
{
|
||||||
|
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = gerbilId, OverrideJson = "{}" };
|
||||||
|
db.GerbilOverrides.Add(ov);
|
||||||
|
}
|
||||||
|
ov.EntityName = g.Name;
|
||||||
|
ov.IsVerified = true;
|
||||||
|
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||||
|
ov.SnapshotJson = snapshot is null ? null : GerbilSnapshotService.SerializeSnapshot(snapshot);
|
||||||
|
// Reset the drift cache — recomputed on the next ingest against the fresh import.
|
||||||
|
ov.LastImportSnapshotJson = null;
|
||||||
|
ov.LastImportDiffJson = null;
|
||||||
|
ov.Note = string.IsNullOrWhiteSpace(input?.Note) ? ov.Note : input!.Note!.Trim();
|
||||||
|
ov.VerifiedAt ??= DateTimeOffset.UtcNow;
|
||||||
|
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{gerbilId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
|
Guid gerbilId, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||||
|
if (ov is null) return TypedResults.NotFound();
|
||||||
|
db.GerbilOverrides.Remove(ov);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/", async (ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var rows = await db.GerbilOverrides.ToListAsync();
|
||||||
|
var existingIds = await db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||||
|
var list = new List<VerifiedGerbilDto>();
|
||||||
|
foreach (var ov in rows.OrderByDescending(o => o.UpdatedAt))
|
||||||
|
list.Add(await ToDtoAsync(db, ov, existingIds));
|
||||||
|
return TypedResults.Ok(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/export", async (ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var rows = await db.GerbilOverrides.Where(o => o.IsVerified).ToListAsync();
|
||||||
|
var export = rows
|
||||||
|
.OrderBy(o => o.EntityName, StringComparer.Ordinal)
|
||||||
|
.ThenBy(o => o.GerbilId)
|
||||||
|
.Select(o => new VerifiedGoldenEntry(
|
||||||
|
o.GerbilId, o.EntityName, o.VerifiedAt,
|
||||||
|
GerbilSnapshotService.DeserializeSnapshot(o.SnapshotJson)))
|
||||||
|
.ToList();
|
||||||
|
return TypedResults.Ok(export);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||||
|
Guid gerbilId, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||||
|
if (ov is null) return TypedResults.NotFound();
|
||||||
|
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<VerifiedGerbilDto> ToDtoAsync(
|
||||||
|
ApplicationContext db, GerbilOverride ov, HashSet<Guid>? existingIds = null)
|
||||||
|
{
|
||||||
|
var exists = existingIds?.Contains(ov.GerbilId) ?? await db.Gerbils.AnyAsync(g => g.Id == ov.GerbilId);
|
||||||
|
var diffs = DeserializeDiff(ov.LastImportDiffJson);
|
||||||
|
var status = !exists ? "missing" : (diffs.Count > 0 ? "drifted" : "unchanged");
|
||||||
|
return new VerifiedGerbilDto(
|
||||||
|
ov.GerbilId, ov.EntityName, ov.IsVerified, status, ov.VerifiedAt, ov.UpdatedAt, ov.Note,
|
||||||
|
ExtractFieldKeys(ov.OverrideJson), diffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SnapshotDiffDto> DeserializeDiff(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<SnapshotDiffDto>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = JsonSerializer.Deserialize<List<SnapshotDiff>>(json, Json) ?? new();
|
||||||
|
return raw.Select(d => new SnapshotDiffDto(d.Path, d.GoldenValue, d.OtherValue)).ToList();
|
||||||
|
}
|
||||||
|
catch (JsonException) { return Array.Empty<SnapshotDiffDto>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ExtractFieldKeys(string? overrideJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(overrideJson)) return Array.Empty<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(overrideJson);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return Array.Empty<string>();
|
||||||
|
return doc.RootElement.EnumerateObject().Select(p => p.Name).ToList();
|
||||||
|
}
|
||||||
|
catch (JsonException) { return Array.Empty<string>(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
<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" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
|
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -33,6 +36,12 @@
|
|||||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
|
||||||
|
damit der Importer sie zur Laufzeit findet. -->
|
||||||
|
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||||
|
|||||||
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal file
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
// ── Canonical, human-readable "Akte" snapshot ────────────────────────────
|
||||||
|
// Names (not ids) are stored so the golden state stays human-verifiable and robust to
|
||||||
|
// deterministic-id reseeds. Used for the drift report, the export fixture and the
|
||||||
|
// regression test. Lists are sorted canonically by the builder so equality is stable.
|
||||||
|
|
||||||
|
public sealed record GerbilSnapshot(
|
||||||
|
SnapshotSelf Self,
|
||||||
|
SnapshotParent? Father,
|
||||||
|
SnapshotParent? Mother,
|
||||||
|
List<SnapshotLitter> Litters);
|
||||||
|
|
||||||
|
public sealed record SnapshotSelf(
|
||||||
|
string Name, string Gender, string? DateOfBirth, string? DateOfDeath, string? CauseOfDeath,
|
||||||
|
string? GoHomeDate, string? Genotype, string? SpottingType, string? ColorName,
|
||||||
|
bool IsResident, bool? IsDeaf, bool IsCastrated, string? OriginBreeder,
|
||||||
|
string? OriginContactName, string? ReceiverContactName, string? Notes,
|
||||||
|
List<string> CharacterTraits, string? CharacterNote, string? BirthLitterName);
|
||||||
|
|
||||||
|
public sealed record SnapshotParent(string Name, string? DateOfBirth, string? Genotype);
|
||||||
|
|
||||||
|
public sealed record SnapshotLitter(
|
||||||
|
string Name, string? Date, string Role, int? TotalBorn, bool ShowInChronicle,
|
||||||
|
List<SnapshotChild> Children);
|
||||||
|
|
||||||
|
public sealed record SnapshotChild(string Name, string? DateOfBirth, string Gender);
|
||||||
|
|
||||||
|
/// <summary>One field-level difference between two snapshots (golden ↔ current/raw import).</summary>
|
||||||
|
public sealed record SnapshotDiff(string Path, string? GoldenValue, string? OtherValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the canonical Akte snapshot for a gerbil, the machine-readable freeze map (own fields
|
||||||
|
/// that "vollständig korrekt" pins), applies an override onto a gerbil, and diffs two snapshots.
|
||||||
|
/// SHARED by the app (endpoints + ingest freeze) and the regression test so the comparison logic
|
||||||
|
/// can never drift between the two.
|
||||||
|
/// </summary>
|
||||||
|
public static class GerbilSnapshotService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>Own-field keys that the "vollständig korrekt" freeze pins. Deliberately excludes
|
||||||
|
/// Status (lifecycle, derived) and EnclosureId (current location) and LitterId/lineage
|
||||||
|
/// (abstammung is detect-only). Keys match the JSON serialised in <see cref="BuildFreezeObject"/>.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> FreezeFields = new[]
|
||||||
|
{
|
||||||
|
"name", "gender", "dateOfBirth", "dateOfDeath", "causeOfDeath", "goHomeDate",
|
||||||
|
"genotype", "spottingType", "colorVarietyId", "isResident", "isDeaf", "isCastrated",
|
||||||
|
"originBreeder", "originContactId", "receiverContactId", "notes",
|
||||||
|
"characterTraits", "characterNote",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Snapshot (human-readable Akte) ───────────────────────────────────
|
||||||
|
|
||||||
|
public static async Task<GerbilSnapshot?> BuildSnapshotAsync(ApplicationContext db, Guid gerbilId)
|
||||||
|
{
|
||||||
|
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == gerbilId);
|
||||||
|
if (g is null) return null;
|
||||||
|
|
||||||
|
var colorName = g.ColorVarietyId is { } cv
|
||||||
|
? await db.ColorVarieties.AsNoTracking().Where(c => c.Id == cv).Select(c => c.Name).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
var originName = g.OriginContactId is { } oc
|
||||||
|
? await db.Contacts.AsNoTracking().Where(c => c.Id == oc).Select(c => c.Name).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
var receiverName = g.ReceiverContactId is { } rc
|
||||||
|
? await db.Contacts.AsNoTracking().Where(c => c.Id == rc).Select(c => c.Name).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Birth litter → parents.
|
||||||
|
Litter? birthLitter = g.LitterId is { } lid
|
||||||
|
? await db.Litters.AsNoTracking().FirstOrDefaultAsync(l => l.Id == lid)
|
||||||
|
: null;
|
||||||
|
var father = await BuildParentAsync(db, birthLitter?.FatherId);
|
||||||
|
var mother = await BuildParentAsync(db, birthLitter?.MotherId);
|
||||||
|
|
||||||
|
// Own litters (as parent) + children.
|
||||||
|
var ownLitters = await db.Litters.AsNoTracking()
|
||||||
|
.Where(l => l.FatherId == gerbilId || l.MotherId == gerbilId)
|
||||||
|
.ToListAsync();
|
||||||
|
var ownLitterIds = ownLitters.Select(l => l.Id).ToList();
|
||||||
|
var children = ownLitterIds.Count == 0
|
||||||
|
? new List<Gerbil>()
|
||||||
|
: await db.Gerbils.AsNoTracking().Where(c => c.LitterId != null && ownLitterIds.Contains(c.LitterId.Value)).ToListAsync();
|
||||||
|
|
||||||
|
var litterSnaps = ownLitters
|
||||||
|
.Select(l => new SnapshotLitter(
|
||||||
|
l.Name,
|
||||||
|
Iso(l.Date),
|
||||||
|
l.FatherId == gerbilId ? "father" : "mother",
|
||||||
|
l.TotalBorn,
|
||||||
|
l.ShowInChronicle,
|
||||||
|
children.Where(c => c.LitterId == l.Id)
|
||||||
|
.Select(c => new SnapshotChild(c.Name, Iso(c.DateOfBirth), c.Gender.ToString()))
|
||||||
|
.OrderBy(c => c.DateOfBirth ?? "", StringComparer.Ordinal)
|
||||||
|
.ThenBy(c => c.Name, StringComparer.Ordinal)
|
||||||
|
.ThenBy(c => c.Gender, StringComparer.Ordinal)
|
||||||
|
.ToList()))
|
||||||
|
.OrderBy(l => l.Date ?? "", StringComparer.Ordinal)
|
||||||
|
.ThenBy(l => l.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var self = new SnapshotSelf(
|
||||||
|
g.Name, g.Gender.ToString(), Iso(g.DateOfBirth), Iso(g.DateOfDeath), g.CauseOfDeath,
|
||||||
|
Iso(g.GoHomeDate), g.Genotype, g.SpottingType, colorName,
|
||||||
|
g.IsResident, g.IsDeaf, g.IsCastrated, g.OriginBreeder,
|
||||||
|
originName, receiverName, g.Notes,
|
||||||
|
(g.CharacterTraits ?? new List<string>()).OrderBy(t => t, StringComparer.Ordinal).ToList(),
|
||||||
|
g.CharacterNote, birthLitter?.Name);
|
||||||
|
|
||||||
|
return new GerbilSnapshot(self, father, mother, litterSnaps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SnapshotParent?> BuildParentAsync(ApplicationContext db, Guid? parentId)
|
||||||
|
{
|
||||||
|
if (parentId is not { } pid) return null;
|
||||||
|
var p = await db.Gerbils.AsNoTracking().Where(x => x.Id == pid)
|
||||||
|
.Select(x => new { x.Name, x.DateOfBirth, x.Genotype }).FirstOrDefaultAsync();
|
||||||
|
return p is null ? null : new SnapshotParent(p.Name, Iso(p.DateOfBirth), p.Genotype);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string SerializeSnapshot(GerbilSnapshot snap) => JsonSerializer.Serialize(snap, Json);
|
||||||
|
|
||||||
|
public static GerbilSnapshot? DeserializeSnapshot(string? json) =>
|
||||||
|
string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<GerbilSnapshot>(json, Json);
|
||||||
|
|
||||||
|
// ── Diff (golden ↔ other) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Flat, human-readable field diff between a golden snapshot and another (current
|
||||||
|
/// DB or raw import) snapshot. Empty = identical.</summary>
|
||||||
|
public static List<SnapshotDiff> Diff(GerbilSnapshot? golden, GerbilSnapshot? other)
|
||||||
|
{
|
||||||
|
var g = golden is null ? new Dictionary<string, string?>() : Flatten(golden);
|
||||||
|
var o = other is null ? new Dictionary<string, string?>() : Flatten(other);
|
||||||
|
var diffs = new List<SnapshotDiff>();
|
||||||
|
foreach (var key in g.Keys.Union(o.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var gv = g.GetValueOrDefault(key);
|
||||||
|
var ov = o.GetValueOrDefault(key);
|
||||||
|
if (!string.Equals(gv, ov, StringComparison.Ordinal))
|
||||||
|
diffs.Add(new SnapshotDiff(key, gv, ov));
|
||||||
|
}
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string?> Flatten(GerbilSnapshot s)
|
||||||
|
{
|
||||||
|
var d = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["name"] = s.Self.Name,
|
||||||
|
["gender"] = s.Self.Gender,
|
||||||
|
["dateOfBirth"] = s.Self.DateOfBirth,
|
||||||
|
["dateOfDeath"] = s.Self.DateOfDeath,
|
||||||
|
["causeOfDeath"] = s.Self.CauseOfDeath,
|
||||||
|
["goHomeDate"] = s.Self.GoHomeDate,
|
||||||
|
["genotype"] = s.Self.Genotype,
|
||||||
|
["spottingType"] = s.Self.SpottingType,
|
||||||
|
["colorName"] = s.Self.ColorName,
|
||||||
|
["isResident"] = s.Self.IsResident.ToString(),
|
||||||
|
["isDeaf"] = s.Self.IsDeaf?.ToString(),
|
||||||
|
["isCastrated"] = s.Self.IsCastrated.ToString(),
|
||||||
|
["originBreeder"] = s.Self.OriginBreeder,
|
||||||
|
["originContact"] = s.Self.OriginContactName,
|
||||||
|
["receiverContact"] = s.Self.ReceiverContactName,
|
||||||
|
["notes"] = s.Self.Notes,
|
||||||
|
["characterTraits"] = string.Join(", ", s.Self.CharacterTraits),
|
||||||
|
["characterNote"] = s.Self.CharacterNote,
|
||||||
|
["birthLitter"] = s.Self.BirthLitterName,
|
||||||
|
["father"] = FormatParent(s.Father),
|
||||||
|
["mother"] = FormatParent(s.Mother),
|
||||||
|
};
|
||||||
|
foreach (var l in s.Litters)
|
||||||
|
{
|
||||||
|
var lk = $"wurf[{l.Name} {l.Date}]";
|
||||||
|
d[$"{lk}.rolle"] = l.Role;
|
||||||
|
d[$"{lk}.geboren"] = l.TotalBorn?.ToString();
|
||||||
|
d[$"{lk}.inChronik"] = l.ShowInChronicle.ToString();
|
||||||
|
foreach (var c in l.Children)
|
||||||
|
d[$"{lk}.kind[{c.Name} {c.DateOfBirth}]"] = c.Gender;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatParent(SnapshotParent? p) =>
|
||||||
|
p is null ? null : $"{p.Name} (*{p.DateOfBirth}) {p.Genotype}".Trim();
|
||||||
|
|
||||||
|
// ── Freeze map (machine-readable own-field override) ─────────────────
|
||||||
|
|
||||||
|
/// <summary>Builds the full own-field freeze object (all <see cref="FreezeFields"/>) from a
|
||||||
|
/// gerbil's current values. Used by verify (store as OverrideJson) and by the per-field
|
||||||
|
/// edit-protection diff.</summary>
|
||||||
|
public static JsonObject BuildFreezeObject(Gerbil g)
|
||||||
|
{
|
||||||
|
var o = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = g.Name,
|
||||||
|
["gender"] = g.Gender.ToString(),
|
||||||
|
["dateOfBirth"] = Iso(g.DateOfBirth),
|
||||||
|
["dateOfDeath"] = Iso(g.DateOfDeath),
|
||||||
|
["causeOfDeath"] = g.CauseOfDeath,
|
||||||
|
["goHomeDate"] = Iso(g.GoHomeDate),
|
||||||
|
["genotype"] = g.Genotype,
|
||||||
|
["spottingType"] = g.SpottingType,
|
||||||
|
["colorVarietyId"] = g.ColorVarietyId?.ToString(),
|
||||||
|
["isResident"] = g.IsResident,
|
||||||
|
["isDeaf"] = g.IsDeaf,
|
||||||
|
["isCastrated"] = g.IsCastrated,
|
||||||
|
["originBreeder"] = g.OriginBreeder,
|
||||||
|
["originContactId"] = g.OriginContactId?.ToString(),
|
||||||
|
["receiverContactId"] = g.ReceiverContactId?.ToString(),
|
||||||
|
["notes"] = g.Notes,
|
||||||
|
["characterTraits"] = new JsonArray((g.CharacterTraits ?? new List<string>()).Select(t => (JsonNode?)JsonValue.Create(t)).ToArray()),
|
||||||
|
["characterNote"] = g.CharacterNote,
|
||||||
|
};
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildFreezeJson(Gerbil g) => BuildFreezeObject(g).ToJsonString(Json);
|
||||||
|
|
||||||
|
/// <summary>Per-field change detection for the PUT edit-protection path. Returns the subset of
|
||||||
|
/// freeze keys whose value differs between <paramref name="before"/> and <paramref name="after"/>,
|
||||||
|
/// as a JsonObject carrying the AFTER values (the ones to pin). Empty if nothing relevant changed.</summary>
|
||||||
|
public static JsonObject ChangedFields(JsonObject before, JsonObject after)
|
||||||
|
{
|
||||||
|
var changed = new JsonObject();
|
||||||
|
foreach (var key in FreezeFields)
|
||||||
|
{
|
||||||
|
var b = before[key]?.ToJsonString() ?? "null";
|
||||||
|
var a = after[key]?.ToJsonString() ?? "null";
|
||||||
|
if (!string.Equals(b, a, StringComparison.Ordinal))
|
||||||
|
changed[key] = after[key] is { } node ? node.DeepClone() : null;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Merge <paramref name="incoming"/> field values into an existing override JSON
|
||||||
|
/// object (incoming wins). Returns the merged JSON string.</summary>
|
||||||
|
public static string MergeOverrideJson(string? existingJson, JsonObject incoming)
|
||||||
|
{
|
||||||
|
var baseObj = ParseObject(existingJson) ?? new JsonObject();
|
||||||
|
foreach (var kv in incoming)
|
||||||
|
baseObj[kv.Key] = kv.Value is { } n ? n.DeepClone() : null;
|
||||||
|
return baseObj.ToJsonString(Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies a stored override JSON object onto a gerbil, writing only the keys present
|
||||||
|
/// (the "freeze"). Unknown keys are ignored.</summary>
|
||||||
|
public static void ApplyOverride(Gerbil g, string? overrideJson)
|
||||||
|
{
|
||||||
|
var o = ParseObject(overrideJson);
|
||||||
|
if (o is null) return;
|
||||||
|
foreach (var kv in o)
|
||||||
|
{
|
||||||
|
var v = kv.Value;
|
||||||
|
switch (kv.Key)
|
||||||
|
{
|
||||||
|
case "name": if (Str(v) is { Length: > 0 } nm) g.Name = nm; break;
|
||||||
|
case "gender": if (Str(v) is { } gd && Enum.TryParse<Gender>(gd, out var gender)) g.Gender = gender; break;
|
||||||
|
case "dateOfBirth": g.DateOfBirth = Date(v); break;
|
||||||
|
case "dateOfDeath": g.DateOfDeath = Date(v); break;
|
||||||
|
case "causeOfDeath": g.CauseOfDeath = Str(v); break;
|
||||||
|
case "goHomeDate": g.GoHomeDate = Date(v); break;
|
||||||
|
case "genotype": g.Genotype = Str(v); break;
|
||||||
|
case "spottingType": g.SpottingType = Str(v); break;
|
||||||
|
case "colorVarietyId": g.ColorVarietyId = Guid_(v); break;
|
||||||
|
case "isResident": g.IsResident = Bool(v) ?? g.IsResident; break;
|
||||||
|
case "isDeaf": g.IsDeaf = Bool(v); break;
|
||||||
|
case "isCastrated": g.IsCastrated = Bool(v) ?? g.IsCastrated; break;
|
||||||
|
case "originBreeder": g.OriginBreeder = Str(v); break;
|
||||||
|
case "originContactId": g.OriginContactId = Guid_(v); break;
|
||||||
|
case "receiverContactId": g.ReceiverContactId = Guid_(v); break;
|
||||||
|
case "notes": g.Notes = Str(v); break;
|
||||||
|
case "characterTraits": g.CharacterTraits = StrList(v); break;
|
||||||
|
case "characterNote": g.CharacterNote = Str(v); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static JsonObject? ParseObject(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||||
|
try { return JsonNode.Parse(json) as JsonObject; }
|
||||||
|
catch (JsonException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Str(JsonNode? n) => n?.GetValueKind() == JsonValueKind.String ? n.GetValue<string>() : null;
|
||||||
|
private static bool? Bool(JsonNode? n) => n?.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? n!.GetValue<bool>() : null;
|
||||||
|
private static Guid? Guid_(JsonNode? n) => Guid.TryParse(Str(n), out var id) ? id : null;
|
||||||
|
private static DateOnly? Date(JsonNode? n) => DateOnly.TryParse(Str(n), out var d) ? d : null;
|
||||||
|
|
||||||
|
private static List<string> StrList(JsonNode? n)
|
||||||
|
{
|
||||||
|
if (n is JsonArray arr)
|
||||||
|
return arr.Where(x => x is not null).Select(x => x!.GetValue<string>()).ToList();
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Iso(DateOnly? d) => d?.ToString("yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,15 +13,28 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
private readonly string _sourceDir;
|
private readonly string _sourceDir;
|
||||||
private readonly string _photoRoot;
|
private readonly string _photoRoot;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions DiffJson = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
_sourceDir = ResolveSourceDir(config, env);
|
||||||
_sourceDir = config["Import:SourcePath"]
|
|
||||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
|
||||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||||
_photoRoot = config["Photos:RootPath"]
|
_photoRoot = config["Photos:RootPath"]
|
||||||
?? Path.Combine(contentRoot, "photo-storage");
|
?? Path.Combine(env?.ContentRootPath ?? Directory.GetCurrentDirectory(), "photo-storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verzeichnis, in dem der Ingest <c>resolved_import.json</c> (+ referenzierte Fotos) erwartet.
|
||||||
|
/// Öffentlich, damit der Upload-Endpoint die hochgeladene Datei an genau denselben Ort stagen kann
|
||||||
|
/// (siehe <c>POST /import/ingest-resolved/upload</c>) — so lässt sich der Ingest gegen Prod fahren,
|
||||||
|
/// ohne die Datei per SSH/docker cp in den Container zu kopieren.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveSourceDir(IConfiguration config, IWebHostEnvironment? env)
|
||||||
|
{
|
||||||
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
|
return config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> RunAsync()
|
public async Task<string> RunAsync()
|
||||||
@@ -32,11 +45,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
|
|
||||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||||
var jsonOptions = new JsonSerializerOptions
|
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
{
|
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
|
||||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||||
|
|
||||||
if (data == null)
|
if (data == null)
|
||||||
@@ -44,163 +54,191 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
return "Error: Failed to deserialize resolved_import.json.";
|
return "Error: Failed to deserialize resolved_import.json.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
// ── UPSERT-based ingest (no total wipe) ──────────────────────────────────────
|
||||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
// The ingest NO LONGER wipes everything. Imported rows are matched by their
|
||||||
{
|
// (deterministic) id and updated in place; manually-created rows (IsManual=true) and
|
||||||
// Break circular references first
|
// user-entered sub-records (weights/health/photos) are never deleted, so the breeder's
|
||||||
foreach (var l in _db.Litters)
|
// work survives the re-import. Only STALE imported rows (IsManual=false, absent from the
|
||||||
{
|
// new payload) are removed — after nulling any manual references to them (with a warning).
|
||||||
l.FatherId = null;
|
// Finally, hand-curated GerbilOverrides are re-applied on top (the "freeze").
|
||||||
l.MotherId = null;
|
var payloadContactIds = data.Contacts.Select(c => c.Id).ToHashSet();
|
||||||
}
|
var payloadLitterIds = data.Litters.Select(l => l.Id).ToHashSet();
|
||||||
foreach (var g in _db.Gerbils)
|
var payloadGerbilIds = data.Gerbils.Select(g => g.Id).ToHashSet();
|
||||||
{
|
var warnings = new List<string>();
|
||||||
g.LitterId = null;
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
// 1. Upsert contacts (preserve manual rows + their IsManual flag).
|
||||||
_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 existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||||
var addedContactIds = new HashSet<Guid>();
|
int contactsAdded = 0, contactsUpdated = 0;
|
||||||
int contactsAdded = 0;
|
|
||||||
int contactsUpdated = 0;
|
|
||||||
foreach (var c in data.Contacts)
|
foreach (var c in data.Contacts)
|
||||||
{
|
{
|
||||||
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
if (existingContacts.TryGetValue(c.Id, out var ec))
|
||||||
{
|
{
|
||||||
existingContact.Name = c.Name;
|
ec.Name = c.Name; ec.Email = c.Email; ec.Phone = c.Phone; ec.Address = c.Address;
|
||||||
existingContact.Email = c.Email;
|
ec.Notes = c.Notes; ec.IsBreeder = c.IsBreeder; ec.IsReceiver = c.IsReceiver;
|
||||||
existingContact.Phone = c.Phone;
|
ec.NameSuffix = c.NameSuffix; ec.Provenance = c.Provenance;
|
||||||
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++;
|
contactsUpdated++;
|
||||||
}
|
}
|
||||||
else if (addedContactIds.Add(c.Id))
|
else
|
||||||
{
|
{
|
||||||
|
c.IsManual = false;
|
||||||
_db.Contacts.Add(c);
|
_db.Contacts.Add(c);
|
||||||
contactsAdded++;
|
contactsAdded++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
// 2. Upsert litters (parent FKs reset to null; set in pass 2 to avoid ordering issues).
|
||||||
var littersToInsert = new List<Litter>();
|
var existingLitters = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||||
foreach (var l in data.Litters)
|
foreach (var l in data.Litters)
|
||||||
{
|
{
|
||||||
littersToInsert.Add(new Litter
|
if (existingLitters.TryGetValue(l.Id, out var el))
|
||||||
{
|
{
|
||||||
Id = l.Id,
|
el.Name = l.Name; el.Date = l.Date; el.TotalBorn = l.TotalBorn;
|
||||||
Name = l.Name,
|
el.DeathsWithin8Weeks = l.DeathsWithin8Weeks; el.Stillborn = l.Stillborn;
|
||||||
Date = l.Date,
|
el.ExpectedGoHomeDate = l.ExpectedGoHomeDate; el.Notes = l.Notes;
|
||||||
TotalBorn = l.TotalBorn,
|
el.PairingCode = l.PairingCode; el.ExternalRef = l.ExternalRef;
|
||||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
el.LitterLetter = l.LitterLetter; el.Provenance = l.Provenance;
|
||||||
FatherId = null, // Set in Pass 2
|
el.ShowInChronicle = l.ShowInChronicle;
|
||||||
MotherId = null, // Set in Pass 2
|
el.FatherId = null; el.MotherId = null;
|
||||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
}
|
||||||
Notes = l.Notes,
|
else
|
||||||
PairingCode = l.PairingCode,
|
{
|
||||||
ExternalRef = l.ExternalRef,
|
_db.Litters.Add(new Litter
|
||||||
LitterLetter = l.LitterLetter,
|
{
|
||||||
Provenance = l.Provenance
|
Id = l.Id, Name = l.Name, Date = l.Date, TotalBorn = l.TotalBorn,
|
||||||
});
|
DeathsWithin8Weeks = l.DeathsWithin8Weeks, Stillborn = l.Stillborn, FatherId = null, MotherId = null,
|
||||||
|
ExpectedGoHomeDate = l.ExpectedGoHomeDate, Notes = l.Notes, PairingCode = l.PairingCode,
|
||||||
|
ExternalRef = l.ExternalRef, LitterLetter = l.LitterLetter, Provenance = l.Provenance,
|
||||||
|
ShowInChronicle = l.ShowInChronicle, IsManual = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_db.Litters.AddRange(littersToInsert);
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
// 3. Upsert gerbils (LitterId reset to null; set in pass 2). Manual rows keep IsManual=true
|
||||||
var gerbilsToInsert = new List<Gerbil>();
|
// and are never matched here (their random ids never collide with deterministic import ids).
|
||||||
|
var existingGerbils = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||||
foreach (var g in data.Gerbils)
|
foreach (var g in data.Gerbils)
|
||||||
{
|
{
|
||||||
var importedGerbil = new Gerbil
|
Gerbil target;
|
||||||
|
if (existingGerbils.TryGetValue(g.Id, out var eg))
|
||||||
{
|
{
|
||||||
Id = g.Id,
|
eg.Name = g.Name; eg.Gender = g.Gender; eg.Status = g.Status;
|
||||||
Name = g.Name,
|
eg.OriginContactId = g.OriginContactId; eg.ReceiverContactId = g.ReceiverContactId;
|
||||||
Gender = g.Gender,
|
eg.EnclosureId = g.EnclosureId; eg.ColorVarietyId = g.ColorVarietyId;
|
||||||
Status = g.Status,
|
eg.DateOfBirth = g.DateOfBirth; eg.DateOfDeath = g.DateOfDeath; eg.CauseOfDeath = g.CauseOfDeath;
|
||||||
LitterId = null, // Set in Pass 2
|
eg.GoHomeDate = g.GoHomeDate; eg.Genotype = g.Genotype; eg.Notes = g.Notes;
|
||||||
OriginContactId = g.OriginContactId,
|
// SpottingType nur übernehmen, wenn der Payload einen Wert liefert: die
|
||||||
ReceiverContactId = g.ReceiverContactId,
|
// Scheckungsart kennt der Importer ausschließlich über conflict-decisions
|
||||||
EnclosureId = g.EnclosureId,
|
// (Ticket a547be62). Ohne diesen Guard würde eine von Hand in der App
|
||||||
ColorVarietyId = g.ColorVarietyId,
|
// erfasste Scheckungsart bei jedem Ingest genullt.
|
||||||
DateOfBirth = g.DateOfBirth,
|
if (g.SpottingType is not null) eg.SpottingType = g.SpottingType;
|
||||||
DateOfDeath = g.DateOfDeath,
|
eg.ImportSource = g.ImportSource; eg.ExternalRef = g.ExternalRef; eg.RawImportData = g.RawImportData;
|
||||||
CauseOfDeath = g.CauseOfDeath,
|
eg.Provenance = g.Provenance; eg.OriginBreeder = g.OriginBreeder;
|
||||||
GoHomeDate = g.GoHomeDate,
|
eg.CharacterTraits = g.CharacterTraits; eg.CharacterNote = g.CharacterNote;
|
||||||
Genotype = g.Genotype,
|
eg.IsDeaf = g.IsDeaf; eg.IsResident = g.IsResident; eg.LitterId = null;
|
||||||
Notes = g.Notes,
|
eg.BirthOrder = g.BirthOrder;
|
||||||
ImportSource = g.ImportSource,
|
target = eg;
|
||||||
ExternalRef = g.ExternalRef,
|
}
|
||||||
RawImportData = g.RawImportData,
|
else
|
||||||
Provenance = g.Provenance,
|
{
|
||||||
OriginBreeder = g.OriginBreeder,
|
var ng = new Gerbil
|
||||||
NameSearch = g.NameSearch,
|
{
|
||||||
CharacterTraits = g.CharacterTraits,
|
Id = g.Id, Name = g.Name, Gender = g.Gender, Status = g.Status, LitterId = null,
|
||||||
CharacterNote = g.CharacterNote,
|
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
|
||||||
IsDeaf = g.IsDeaf,
|
EnclosureId = g.EnclosureId, ColorVarietyId = g.ColorVarietyId,
|
||||||
IsResident = g.IsResident
|
DateOfBirth = g.DateOfBirth, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
|
||||||
};
|
GoHomeDate = g.GoHomeDate, Genotype = g.Genotype, SpottingType = g.SpottingType,
|
||||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
Notes = g.Notes,
|
||||||
gerbilsToInsert.Add(importedGerbil);
|
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, IsManual = false,
|
||||||
|
BirthOrder = g.BirthOrder,
|
||||||
|
};
|
||||||
|
_db.Gerbils.Add(ng);
|
||||||
|
target = ng;
|
||||||
|
}
|
||||||
|
GerbilStatusService.Apply(target, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// 5. Pass 2: Set the actual foreign key relationships
|
// 4. Pass 2: set the payload FK relationships (only for payload rows; manual rows untouched).
|
||||||
// Update Litters with their parent animal IDs
|
|
||||||
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||||
foreach (var l in data.Litters)
|
foreach (var l in data.Litters)
|
||||||
{
|
if (littersInDb.TryGetValue(l.Id, out var dl)) { dl.FatherId = l.FatherId; dl.MotherId = l.MotherId; }
|
||||||
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);
|
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||||
foreach (var g in data.Gerbils)
|
foreach (var g in data.Gerbils)
|
||||||
|
if (gerbilsInDb.TryGetValue(g.Id, out var dg)) dg.LitterId = g.LitterId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 5. Remove STALE imported rows (IsManual=false, no longer in the payload). Null any
|
||||||
|
// dangling references from surviving rows first; warn when a MANUAL row is affected.
|
||||||
|
var allGerbils = await _db.Gerbils.ToListAsync();
|
||||||
|
var allLitters = await _db.Litters.ToListAsync();
|
||||||
|
var allContacts = await _db.Contacts.ToListAsync();
|
||||||
|
var gerbilNameById = allGerbils.ToDictionary(x => x.Id, x => x.Name);
|
||||||
|
var litterNameById = allLitters.ToDictionary(x => x.Id, x => x.Name);
|
||||||
|
var contactNameById = allContacts.ToDictionary(x => x.Id, x => x.Name);
|
||||||
|
|
||||||
|
var staleGerbilIds = allGerbils.Where(g => !g.IsManual && !payloadGerbilIds.Contains(g.Id)).Select(g => g.Id).ToHashSet();
|
||||||
|
var staleLitterIds = allLitters.Where(l => !l.IsManual && !payloadLitterIds.Contains(l.Id)).Select(l => l.Id).ToHashSet();
|
||||||
|
var staleContactIds = allContacts.Where(c => !c.IsManual && !payloadContactIds.Contains(c.Id)).Select(c => c.Id).ToHashSet();
|
||||||
|
|
||||||
|
if (staleGerbilIds.Count > 0)
|
||||||
{
|
{
|
||||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
foreach (var l in allLitters)
|
||||||
{
|
{
|
||||||
dbGerbil.LitterId = g.LitterId;
|
if (l.FatherId is { } f && staleGerbilIds.Contains(f))
|
||||||
|
{
|
||||||
|
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier „{gerbilNameById.GetValueOrDefault(f, "?")}\" (Vater), das im Import nicht mehr existiert — Elternteil entfernt.");
|
||||||
|
l.FatherId = null;
|
||||||
|
}
|
||||||
|
if (l.MotherId is { } m && staleGerbilIds.Contains(m))
|
||||||
|
{
|
||||||
|
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier „{gerbilNameById.GetValueOrDefault(m, "?")}\" (Mutter), das im Import nicht mehr existiert — Elternteil entfernt.");
|
||||||
|
l.MotherId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (staleContactIds.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in allGerbils)
|
||||||
|
{
|
||||||
|
if (g.OriginContactId is { } o && staleContactIds.Contains(o))
|
||||||
|
{
|
||||||
|
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt „{contactNameById.GetValueOrDefault(o, "?")}\" (Herkunft), der im Import nicht mehr existiert — Zuordnung entfernt.");
|
||||||
|
g.OriginContactId = null;
|
||||||
|
}
|
||||||
|
if (g.ReceiverContactId is { } r && staleContactIds.Contains(r))
|
||||||
|
{
|
||||||
|
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt „{contactNameById.GetValueOrDefault(r, "?")}\" (Abnehmer), der im Import nicht mehr existiert — Zuordnung entfernt.");
|
||||||
|
g.ReceiverContactId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (staleLitterIds.Count > 0)
|
||||||
|
{
|
||||||
|
// Gerbil.LitterId FK is SetNull on delete → nulled automatically when the litter is
|
||||||
|
// removed; we only surface a warning for affected MANUAL animals.
|
||||||
|
foreach (var g in allGerbils)
|
||||||
|
if (g.IsManual && g.LitterId is { } lid && staleLitterIds.Contains(lid))
|
||||||
|
warnings.Add($"Manuelles Tier „{g.Name}\" war im Wurf „{litterNameById.GetValueOrDefault(lid, "?")}\" geboren, der im Import nicht mehr existiert — Wurf-Zuordnung entfernt.");
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
int gerbilsDeleted = staleGerbilIds.Count, littersDeleted = staleLitterIds.Count, contactsDeleted = staleContactIds.Count;
|
||||||
|
if (staleGerbilIds.Count > 0) _db.Gerbils.RemoveRange(allGerbils.Where(g => staleGerbilIds.Contains(g.Id)));
|
||||||
|
if (staleLitterIds.Count > 0) _db.Litters.RemoveRange(allLitters.Where(l => staleLitterIds.Contains(l.Id)));
|
||||||
|
if (staleContactIds.Count > 0) _db.Contacts.RemoveRange(allContacts.Where(c => staleContactIds.Contains(c.Id)));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 6. Photos: replace photos of IMPORTED gerbils (manual gerbils' photos survive). Copy files.
|
||||||
|
var manualGerbilIds = allGerbils.Where(g => g.IsManual).Select(g => g.Id).ToHashSet();
|
||||||
|
var allPhotos = await _db.GerbilPhotos.ToListAsync();
|
||||||
|
_db.GerbilPhotos.RemoveRange(allPhotos.Where(p => !manualGerbilIds.Contains(p.GerbilId)));
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// 6. Insert all Photos & copy physical files
|
|
||||||
Directory.CreateDirectory(_photoRoot);
|
Directory.CreateDirectory(_photoRoot);
|
||||||
foreach (var p in data.GerbilPhotos)
|
foreach (var p in data.GerbilPhotos)
|
||||||
{
|
{
|
||||||
@@ -211,14 +249,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (File.Exists(src))
|
if (File.Exists(src))
|
||||||
{
|
{
|
||||||
var dest = Path.Combine(_photoRoot, p.FileName);
|
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||||
try
|
try { File.Copy(src, dest, overwrite: true); }
|
||||||
{
|
catch (Exception ex) { Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}"); }
|
||||||
File.Copy(src, dest, overwrite: true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -233,12 +265,85 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
FileName = p.FileName,
|
FileName = p.FileName,
|
||||||
Caption = p.Caption,
|
Caption = p.Caption,
|
||||||
SortOrder = p.SortOrder,
|
SortOrder = p.SortOrder,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
// 7. Imported Abgabeverträge: recreate from payload (unchanged behaviour — the imported set
|
||||||
|
// is part of the payload). Manual sale-contract protection is a separate follow-up.
|
||||||
|
var scToRemove = await _db.SaleContracts.ToListAsync();
|
||||||
|
_db.SaleContracts.RemoveRange(scToRemove);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
int contractsAdded = 0, 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;
|
||||||
|
if (!contactIds.Contains(sc.ContactId)) continue;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. FREEZE: capture the raw-import snapshot for verified overrides (BEFORE applying the
|
||||||
|
// freeze), then re-apply every override on top of the fresh import so the breeder's
|
||||||
|
// curated values win. Overrides survive re-ingest by being their own (FK-free) table.
|
||||||
|
var overrides = await _db.GerbilOverrides.ToListAsync();
|
||||||
|
foreach (var ov in overrides.Where(o => o.IsVerified))
|
||||||
|
{
|
||||||
|
var raw = await GerbilSnapshotService.BuildSnapshotAsync(_db, ov.GerbilId);
|
||||||
|
if (raw is null) continue; // verified animal vanished from import → surfaced as "missing" in the UI
|
||||||
|
ov.LastImportSnapshotJson = GerbilSnapshotService.SerializeSnapshot(raw);
|
||||||
|
var golden = GerbilSnapshotService.DeserializeSnapshot(ov.SnapshotJson);
|
||||||
|
var diff = GerbilSnapshotService.Diff(golden, raw);
|
||||||
|
ov.LastImportDiffJson = diff.Count == 0 ? null : JsonSerializer.Serialize(diff, DiffJson);
|
||||||
|
}
|
||||||
|
var gerbilsById = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||||
|
int overridesApplied = 0;
|
||||||
|
foreach (var ov in overrides)
|
||||||
|
{
|
||||||
|
if (gerbilsById.TryGetValue(ov.GerbilId, out var gg))
|
||||||
|
{
|
||||||
|
GerbilSnapshotService.ApplyOverride(gg, ov.OverrideJson);
|
||||||
|
// Der Override kann Todesdatum/Abgabe/Geburtsdatum setzen; Status neu ableiten,
|
||||||
|
// damit ein verstorbenes/abgegebenes Tier nicht als 'Zucht' hängen bleibt
|
||||||
|
// (Ticket 37ab228a "Gaida"). Status/Gehege sind selbst NICHT eingefroren.
|
||||||
|
GerbilStatusService.Apply(gg, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
overridesApplied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var warnPart = warnings.Count > 0 ? $" {warnings.Count} warning(s): {string.Join(" | ", warnings)}" : "";
|
||||||
|
return $"Ingestion successful! Contacts +{contactsAdded}/~{contactsUpdated} (−{contactsDeleted} stale), " +
|
||||||
|
$"{data.Litters.Count} litters (−{littersDeleted} stale), {data.Gerbils.Count} gerbils (−{gerbilsDeleted} stale), " +
|
||||||
|
$"{data.GerbilPhotos.Count} photos, {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links skipped), " +
|
||||||
|
$"{overridesApplied} override(s) applied (freeze).{warnPart}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +353,22 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
public List<Litter> Litters { get; set; } = new();
|
public List<Litter> Litters { get; set; } = new();
|
||||||
public List<Gerbil> Gerbils { get; set; } = new();
|
public List<Gerbil> Gerbils { get; set; } = new();
|
||||||
public List<ResolvedPhoto> GerbilPhotos { 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 class ResolvedPhoto
|
||||||
|
|||||||
117
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
117
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
|
||||||
|
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
|
||||||
|
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
|
||||||
|
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
|
||||||
|
///
|
||||||
|
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
|
||||||
|
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
|
||||||
|
/// der ganze Cluster wird mitgezogen).
|
||||||
|
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
|
||||||
|
/// Zusammenlegen über Gruppengrenzen).
|
||||||
|
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
|
||||||
|
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
|
||||||
|
///
|
||||||
|
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
|
||||||
|
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
|
||||||
|
/// verschieben sich, sobald sich die Clusterbildung ändert.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Rpro3Decisions
|
||||||
|
{
|
||||||
|
public List<Rpro3Decision> Decisions { get; set; } = new();
|
||||||
|
|
||||||
|
public static Rpro3Decisions Empty { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
|
||||||
|
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
|
||||||
|
public static Rpro3Decisions Load(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
|
return Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
|
||||||
|
return d ?? Empty;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
|
||||||
|
public IEnumerable<IReadOnlyList<string>> SameGroups()
|
||||||
|
{
|
||||||
|
foreach (var d in Decisions)
|
||||||
|
foreach (var g in d.Same)
|
||||||
|
if (g.Count >= 2) yield return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
|
||||||
|
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
|
||||||
|
public Dictionary<string, int> BuildSplitGroups()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
int gid = 0;
|
||||||
|
foreach (var d in Decisions)
|
||||||
|
{
|
||||||
|
if (d.Different.Count < 2) continue;
|
||||||
|
foreach (var grp in d.Different)
|
||||||
|
{
|
||||||
|
gid++;
|
||||||
|
foreach (var rid in grp)
|
||||||
|
map[rid] = gid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
|
||||||
|
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
|
||||||
|
foreach (var d in Decisions)
|
||||||
|
foreach (var (rid, ov) in d.Fields)
|
||||||
|
map[rid] = ov;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Rpro3Decision
|
||||||
|
{
|
||||||
|
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
|
||||||
|
public string? Ticket { get; set; }
|
||||||
|
public List<List<string>> Same { get; set; } = new();
|
||||||
|
public List<List<string>> Different { get; set; } = new();
|
||||||
|
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Rpro3FieldOverride
|
||||||
|
{
|
||||||
|
public string? Color { get; set; }
|
||||||
|
public string? Dob { get; set; } // ISO yyyy-MM-dd
|
||||||
|
public string? Origin { get; set; }
|
||||||
|
/// <summary>Roher Gencode (Fcode, z. B. „aa Cc[h] DD ee Gg P- spsp"). Überschreibt den
|
||||||
|
/// automatisch aus den Cluster-Mitgliedern gewählten Gencode — nötig, wenn sich die
|
||||||
|
/// Varianten im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und die Züchterin
|
||||||
|
/// den korrekten Genotyp vorgibt.</summary>
|
||||||
|
public string? Genotype { get; set; }
|
||||||
|
public bool? Resident { get; set; }
|
||||||
|
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
226
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
226
GerbilManagerWebAPI/Import/Rpro3/Rpro3Dedup.cs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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) => Run(animals, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wie <see cref="Run(IReadOnlyList{Rpro3Animal})"/>, berücksichtigt aber manuelle
|
||||||
|
/// Entscheidungen der Züchterin (<paramref name="decisions"/>): „Different"-Gruppen
|
||||||
|
/// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über
|
||||||
|
/// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit).
|
||||||
|
/// </summary>
|
||||||
|
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals, Rpro3Decisions? decisions)
|
||||||
|
{
|
||||||
|
var result = new DedupResult();
|
||||||
|
decisions ??= Rpro3Decisions.Empty;
|
||||||
|
var splitGroup = decisions.BuildSplitGroups();
|
||||||
|
|
||||||
|
foreach (var a in animals)
|
||||||
|
{
|
||||||
|
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];
|
||||||
|
// Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen.
|
||||||
|
if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb)
|
||||||
|
continue;
|
||||||
|
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
|
||||||
|
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
|
||||||
|
Union(a.Rid, b.Rid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen
|
||||||
|
// (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids,
|
||||||
|
// die es im Datensatz auch gibt, werden berücksichtigt.
|
||||||
|
var knownRids = new HashSet<string>(animals.Select(a => a.Rid), StringComparer.Ordinal);
|
||||||
|
foreach (var grp in decisions.SameGroups())
|
||||||
|
{
|
||||||
|
string? anchor = null;
|
||||||
|
foreach (var rid in grp)
|
||||||
|
{
|
||||||
|
if (!knownRids.Contains(rid)) continue;
|
||||||
|
parent.TryAdd(rid, rid);
|
||||||
|
if (anchor is null) anchor = rid;
|
||||||
|
else Union(anchor, rid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster sammeln
|
||||||
|
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
||||||
|
foreach (var a in animals)
|
||||||
|
{
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
887
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
887
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
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;
|
||||||
|
private readonly Rpro3Decisions _decisions;
|
||||||
|
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
|
||||||
|
|
||||||
|
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
|
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
||||||
|
// Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
|
||||||
|
// überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
|
||||||
|
var decPath = config["Rpro3:DecisionsPath"]
|
||||||
|
?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
|
||||||
|
_decisions = Rpro3Decisions.Load(decPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
|
||||||
|
_fieldIndex ??= _decisions.BuildFieldIndex();
|
||||||
|
|
||||||
|
// ───────────────────────── ANALYZE ─────────────────────────
|
||||||
|
|
||||||
|
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||||
|
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||||
|
{
|
||||||
|
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
|
||||||
|
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, _decisions);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
|
||||||
|
// rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
|
||||||
|
Rpro3FieldOverride? ov = null;
|
||||||
|
foreach (var m in members)
|
||||||
|
if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
|
||||||
|
bool? residentOverride = ov?.Resident;
|
||||||
|
if (ov is not null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
|
||||||
|
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
|
||||||
|
if (!string.IsNullOrWhiteSpace(ov.Genotype)) fcode = ov.Genotype;
|
||||||
|
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = residentOverride ?? 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);
|
||||||
|
if (!string.IsNullOrWhiteSpace(ov?.Note))
|
||||||
|
gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
|
||||||
|
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||||
|
plan.Gerbils[id] = gp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
1378
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
1378
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
File diff suppressed because it is too large
Load Diff
1555
GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.Designer.cs
generated
Normal file
1555
GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFeedbackStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ResolvedAt",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Existing feedback rows default to "Open" (the lifecycle's initial state).
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Open");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ResolvedAt",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1564
GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs
generated
Normal file
1564
GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.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 AddFeedbackQuestionAnswer : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Answer",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "AnsweredAt",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Question",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Answer",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AnsweredAt",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Question",
|
||||||
|
table: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFeedbackFixNoteContextThread : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AgentContext",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FixNote",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Thread",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AgentContext",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FixNote",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Thread",
|
||||||
|
table: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1560
GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs
generated
Normal file
1560
GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEnclosureCleaning : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CleaningCycleDays",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "LastCleanedDate",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "date",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Size",
|
||||||
|
table: "Enclosures",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CleaningCycleDays",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastCleanedDate",
|
||||||
|
table: "Enclosures");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Size",
|
||||||
|
table: "Enclosures");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAcquisitionRecord : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AcquisitionRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
SourceContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AcquisitionRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AcquisitionRecords_GerbilId",
|
||||||
|
table: "AcquisitionRecords",
|
||||||
|
column: "GerbilId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AcquisitionRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs
generated
Normal file
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.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 AddSaleReservation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleReservations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ReservedForContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ContactName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
AppointmentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
HandedOverDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SaleReservations", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReservations_GerbilId",
|
||||||
|
table: "SaleReservations",
|
||||||
|
column: "GerbilId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleReservations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWaitingList : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WaitingListEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ContactName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
WishColor = table.Column<string>(type: "text", nullable: true),
|
||||||
|
WishGender = table.Column<string>(type: "text", nullable: true),
|
||||||
|
RequestedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WaitingListEntries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WaitingListEntries_CreatedAt",
|
||||||
|
table: "WaitingListEntries",
|
||||||
|
column: "CreatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WaitingListEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReturnRecord : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ReturnRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
GerbilName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ReturnDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ReturnPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||||
|
OriginalPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||||
|
OriginalSaleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
FromContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
FromContactName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ReturnRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ReturnRecords_CreatedAt",
|
||||||
|
table: "ReturnRecords",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ReturnRecords_GerbilId",
|
||||||
|
table: "ReturnRecords",
|
||||||
|
column: "GerbilId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ReturnRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExhibitionResult : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExhibitionResults",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
EventName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Placement = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Award = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExhibitionResults", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExhibitionResults_GerbilId",
|
||||||
|
table: "ExhibitionResults",
|
||||||
|
column: "GerbilId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExhibitionResults");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Gen5FuchsschimmelHetSeedFix : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efe gg PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efe GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efe GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efe gg PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efe GG pp spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efe GG pp spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efe GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC dd ee gg PP spsp rere");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efef gg PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efef GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efef GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efef gg PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "AA CC DD efef GG pp spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efef GG pp spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC DD efef GG PP spsp rere");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
column: "CanonicalGenotype",
|
||||||
|
value: "aa CC dd ee gg pp spsp rere");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FeedbackReopenedAndSoftDelete : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ReopenedAt",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReopenedAt",
|
||||||
|
table: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FeedbackCategoryAndHelpful : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Category",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "Helpful",
|
||||||
|
table: "Feedback",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "Feedback");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Helpful",
|
||||||
|
table: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FeedbackAttachments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FeedbackAttachments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ContentType = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Size = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FeedbackAttachments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WebPushSubscriptions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WebPushSubscriptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Endpoint = table.Column<string>(type: "text", nullable: false),
|
||||||
|
P256dh = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Auth = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WebPushSubscriptions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterShowInChronicle : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ShowInChronicle",
|
||||||
|
table: "Litters",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
// Bestehende Würfe bleiben in der Wurfchronik sichtbar; nur explizit als
|
||||||
|
// „nicht in Chronik" importierte Würfe (z. B. Akanes extern entstandener Wurf)
|
||||||
|
// werden beim Ingest auf false gesetzt.
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShowInChronicle",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.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 UpdateLegalPagesDsgvo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UpdateImpressumHobbyMStV : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
column: "Data",
|
||||||
|
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVerifiedGerbilAndManualFlag : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Litters",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GerbilOverrides",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
IsVerified = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
OverrideJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
LastImportSnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
LastImportDiffJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
VerifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_GerbilOverrides", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GerbilOverrides_GerbilId",
|
||||||
|
table: "GerbilOverrides",
|
||||||
|
column: "GerbilId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GerbilOverrides");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Gerbils");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsManual",
|
||||||
|
table: "Contacts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.Designer.cs
generated
Normal file
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.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 AddLitterStillborn : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Stillborn",
|
||||||
|
table: "Litters",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Stillborn",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.Designer.cs
generated
Normal file
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user