Compare commits
81 Commits
feat/akte-
...
84365bba7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 84365bba7f | |||
| a333bce1b3 | |||
| bbd305861f | |||
| 8aaddea4ef | |||
| 54cc493230 | |||
| bb1b8f8c76 | |||
| 00a3919c9f | |||
| b0e92c363a | |||
| 7262b34ae9 | |||
| ebc70cc0b8 | |||
| f9e672c707 | |||
| fe79be2eee | |||
| 41ee4ee3ab | |||
| f7a1a7724b | |||
| 750619d3d7 | |||
| 48b138bbfd | |||
| c0a7b1a5ee | |||
| a0fa33da42 | |||
| 6b264a9b71 | |||
| 26977d73ff | |||
| 12f5de00a0 | |||
| 0540498e85 | |||
| a87d1ec865 | |||
| 8c1ee75b81 | |||
| aa4552ff84 | |||
| 18d74ff3f3 | |||
| 72ad706ca6 | |||
| a66739e84d | |||
| 00dba91a5c | |||
| c7b054e909 | |||
| 00dc724605 | |||
| 156ede120e | |||
| db2c29769e | |||
| ae7d171131 | |||
| 6a64698601 | |||
| 1469a7f3dc | |||
| 6123b8ef9e | |||
| 0b0ebd5fad | |||
| 4ec0f787dd | |||
| d380a0c2ef | |||
| 40c92c70d4 | |||
| 4e7cd21b70 | |||
| 3ad276637c | |||
| b4dc37de78 | |||
| d25d2ee152 | |||
| 55e79cda51 | |||
| 23f028123d | |||
| 4e30dc7fe3 | |||
| c15dd78bc7 | |||
| dcf0e40f9b | |||
| b9021fe577 | |||
| ce1704c3b7 | |||
| 9c6ca2118a | |||
| 8bfa2a7911 | |||
| 5edd84046d | |||
| d27a389f0e | |||
| b437d09312 | |||
| 5069c0eb1e | |||
| f1c1069821 | |||
| 3f31a2efbc | |||
| 4c0cba854a | |||
| ac06cadb23 | |||
| e1299a1a63 | |||
| 5175c3229a | |||
| 5e14124322 | |||
| aa473b764e | |||
| 2befe53df9 | |||
| cf672c0933 | |||
| a31fea17fa | |||
| 438c816820 | |||
| 9c98c37d2a | |||
| b9e5b031c4 | |||
| 1845d37476 | |||
| 2868aad369 | |||
| 03ad9e6e81 | |||
| 18efab6996 | |||
| 070a896072 | |||
| 3715e32303 | |||
| cb2ee03a14 | |||
| 41ff973f9a | |||
| d2993418b6 |
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook session-start --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook stop --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook precompact --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
181
.claude/skills/ticket-triage/SKILL.md
Normal file
181
.claude/skills/ticket-triage/SKILL.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
name: ticket-triage
|
||||
description: >-
|
||||
Fehlerberichte (Tickets) der Züchterin abarbeiten: neue Tickets per Subagent
|
||||
triagieren, Rückfragen an die Tickets hängen, beantwortete Tickets wieder
|
||||
aufgreifen, Fixes gebündelt umsetzen und beim Schließen dokumentieren, was
|
||||
gefixt wurde. Nutze diesen Skill, wenn der Nutzer „Tickets abarbeiten /
|
||||
triagieren", „Fehlerberichte bearbeiten" o. Ä. verlangt.
|
||||
---
|
||||
|
||||
# Ticket-Triage & Abarbeitung
|
||||
|
||||
Tickets entstehen über das „Fehler melden"-Feature (`POST /feedback`) und werden
|
||||
unter **Hilfe → Meine Tickets** (`/hilfe/tickets`) verwaltet. Dieser Skill
|
||||
automatisiert das Triagieren, Rückfragen-Stellen, Umsetzen und Schließen.
|
||||
|
||||
## Status-Lebenszyklus (Feedback.Status)
|
||||
|
||||
| Status | Bedeutung | Wer ist dran |
|
||||
|---|---|---|
|
||||
| `Open` | Neues Ticket, noch nicht triagiert | **KI** (triagieren) |
|
||||
| `NeedsInfo` | KI hat eine **Rückfrage** angehängt | Züchterin (antworten) |
|
||||
| `Answered` | Züchterin hat geantwortet | **KI** (wieder aufgreifen, umsetzen) |
|
||||
| `Resolved` | Erledigt — mit `fixNote` (Changelog) | — |
|
||||
|
||||
**Zu bearbeiten sind `Open` (neu) und `Answered` (KI wieder dran).** `NeedsInfo`
|
||||
wartet auf die Züchterin, `Resolved` ist fertig — beide überspringen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- App läuft (Aspire): `dotnet run --project GerbilManager.AppHost`; API auf
|
||||
http://localhost:5179. Siehe `CLAUDE.md` (Architektur, Import→DB-Workflow,
|
||||
Domänenwissen: 6-Jahre-Lebensspanne, Geschwisterverpaarung, Genotyp/E-Locus,
|
||||
`isResident`).
|
||||
- Feedback überlebt den Import-Ingest (FK-freie Spalten) — Tickets bleiben
|
||||
erhalten, auch wenn die DB neu befüllt wird.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Tickets holen & einteilen
|
||||
`curl -s http://localhost:5179/feedback` (neueste zuerst). Encoding-Falle: über
|
||||
eine Datei holen (`curl -o raw.json`) und mit `python … .decode('utf-8')` lesen,
|
||||
nicht via Windows-stdin-Pipe (sonst Mojibake). Test-/Dummy-Tickets ausschließen.
|
||||
Filtere auf `status in (Open, Answered)`. Bei `Answered` zusätzlich das Feld
|
||||
`answer` (Antwort der Züchterin) mitnehmen — darauf basiert die Umsetzung.
|
||||
|
||||
### 2. Triage per Subagent (ein Agent pro Ticket)
|
||||
Nutze die **Workflow-Orchestrierung** (`pipeline`) — einen Triage-Agenten pro
|
||||
Ticket. args ASCII-sicher halten (nur id/context/gerbilId/litterId übergeben; die
|
||||
deutschen Nachrichten holt der Agent selbst aus `GET /feedback`). Defensive im
|
||||
Script: `const tickets = Array.isArray(args) ? args : JSON.parse(args)`.
|
||||
|
||||
Jeder Triage-Agent:
|
||||
1. liest `CLAUDE.md` + `docs/ticket-triage.md` (falls vorhanden),
|
||||
2. holt seinen Ticket-Text (+ `answer` bei `Answered`) aus `GET /feedback`,
|
||||
3. untersucht die echten Daten über die API (`GET /gerbils/{id}`, `/litters/{id}`,
|
||||
Eltern verfolgen; `provenance` zeigt Import-Herkunft inkl. verworfener Werte),
|
||||
4. liefert strukturiert (StructuredOutput-Schema): `ticketId, title, category,
|
||||
rootCause, recommendation, fixLayer (import-data|import-logic|backend|frontend|
|
||||
genetics-engine), affectedAreas[], overlapKey (Cluster-Slug), effort (S/M/L),
|
||||
confidence, needsUserInfo`,
|
||||
5. **terminale Aktion am Ticket selbst** (jeder Triage-Agent schließt seinen
|
||||
Vorgang ab, lässt nichts offen liegen): braucht es Infos von der Züchterin →
|
||||
**Rückfrage ordentlich stellen** (siehe 3); ist die Ursache rein eine Daten-/
|
||||
Code-Korrektur ohne Rückfrage → an den passenden Umsetzungs-Cluster (4)
|
||||
übergeben (NICHT selbst querschnittlich Dateien ändern).
|
||||
|
||||
Sammle alle Ergebnisse in **eine Datei** `docs/ticket-triage.md` (Cluster-/
|
||||
Überschneidungs-Matrix + offene Rückfragen) — token-schonend per Python aus dem
|
||||
Workflow-Ergebnis generieren, nicht 70 KB in den Kontext laden.
|
||||
|
||||
### 3. Rückfragen ordentlich stellen (durch den Triage-Agenten)
|
||||
Wenn ein Ticket Infos der Züchterin braucht, hängt der Triage-Agent die Rückfrage
|
||||
**direkt selbst** ans Ticket:
|
||||
`PUT /feedback/{id}` mit `{"question": "<Rückfrage>"}` → Status wird `NeedsInfo`.
|
||||
Anforderungen an eine gute Rückfrage: **eine konkrete Frage** (kein Sammelsurium),
|
||||
in **einfacher, freundlicher Sprache** für die Züchterin (keine Code-/Fachbegriffe),
|
||||
mit dem nötigen Kontext (welches Tier/welcher Wurf, was unklar ist), und mit einer
|
||||
klaren Handlungsaufforderung (z. B. „Wie heißt die Mutter von X?"). Die Züchterin
|
||||
beantwortet es in der App; beim nächsten Lauf erscheint es als `Answered` und wird
|
||||
wieder aufgegriffen.
|
||||
|
||||
### 4. Umsetzen — gebündelt nach Cluster (Überschneidungen vermeiden!)
|
||||
Die Triage-Matrix zeigt, welche Tickets sich Dateien teilen. Bündele nach
|
||||
**datei-disjunkten Workstreams**, damit parallele Subagenten nicht kollidieren:
|
||||
- **Genetik-Engine** → `gerbil-manager-web/src/genetics/**`, `tools/import/genotype.py`.
|
||||
- **Stammbaum-Import** (Eltern/Dedup/Gender/`isResident`) → `tools/import/extract.py`,
|
||||
`merge_and_resolve.py`, `conflict-decisions.json`.
|
||||
- **Frontend-Anzeige** (z. B. Nicht-Bestandstiere ausblenden) → `GerbilDetailPage.tsx`,
|
||||
`de.ts`.
|
||||
Gemeinsam genutzte Dateien (`de.ts`, `GerbilDetailPage.tsx`) gehören **genau einem**
|
||||
Workstream — sonst sequenziell. Datenkorrekturen, die die Züchterin nennt, als
|
||||
Override in `conflict-decisions.json` kodieren (data-driven, re-ingest-stabil).
|
||||
Subagenten committen NICHT und re-ingesten NICHT — das koordiniert der Haupt-Loop.
|
||||
|
||||
### 5. Import neu einspielen (bei Daten-/Import-Änderungen)
|
||||
`python tools/import/extract.py` → `extract_contracts.py` → `merge_and_resolve.py`,
|
||||
dann `POST /import/ingest-resolved` (wischt + lädt neu). Danach betroffene Tickets
|
||||
gegen die API verifizieren.
|
||||
|
||||
### 6. Ticket schließen — MIT Changelog (durch den bearbeitenden Subagenten)
|
||||
Der Subagent/Cluster, der den Fix umgesetzt hat, schließt **seine** Tickets selbst
|
||||
ab — aber erst **nachdem der Fix verifiziert und (bei Daten-/Import-Fixes) live
|
||||
eingespielt ist** (Schritt 5), damit „erledigt" auch stimmt:
|
||||
`PUT /feedback/{id}` mit `{"status": "Resolved", "fixNote": "<verständliche
|
||||
Erklärung, was korrigiert wurde>"}`.
|
||||
**Die `fixNote` ist ein Changelog AUSSCHLIESSLICH für die Züchterin (Laiin) —
|
||||
kein technisches Geschwafel.** Harte Regel:
|
||||
- Schreib, **was sich für SIE sichtbar geändert hat**, in Alltagssprache; 1–3 Sätze.
|
||||
- KEINE Dateinamen, Funktionen, Feldnamen, IDs, Status-Codes, „Resolver/Migration/
|
||||
Genotyp-Parser/isResident/conflict-decisions" o. Ä. Nenne Tiere/Würfe bei ihrem
|
||||
**Namen**, nicht per ID.
|
||||
- Sag ggf., was sie tun soll (z. B. „Seite neu laden").
|
||||
|
||||
Gut: „Die Mutter von **Yuki** war falsch — sie ist jetzt **Izumi** (vorher stand
|
||||
da Benjiro). Bitte den Stammbaum neu laden."
|
||||
Gut: „**Hagrid** wird nicht mehr als dein eigenes Tier geführt, sondern nur noch
|
||||
als Vorfahre — seine Würfe und der Charakter-Bereich werden nicht mehr angezeigt."
|
||||
Schlecht (NICHT so): „assign_parent_roles korrigiert, motherId via
|
||||
conflict-decisions.json gesetzt, re-ingest." / „isResident=false gesetzt."
|
||||
|
||||
### Abschluss-Regel (wichtig)
|
||||
**Kein bearbeitetes Ticket bleibt offen liegen.** Jedes `Open`/`Answered`-Ticket
|
||||
endet nach einem Lauf in genau einem Endzustand:
|
||||
- **`NeedsInfo`** — Rückfrage wurde ordentlich gestellt (Info fehlt), oder
|
||||
- **`Resolved`** — Fix umgesetzt + verifiziert, mit `fixNote`-Changelog.
|
||||
Kann ein Ticket weder gefixt noch sinnvoll erfragt werden, stelle trotzdem eine
|
||||
klärende Rückfrage (NeedsInfo) statt es offen zu lassen.
|
||||
|
||||
## Mehrrundige Rückfragen & Kontext-Kontinuität
|
||||
|
||||
Der Dialog kann mehrere Runden haben: `Open → (Rückfrage) NeedsInfo → (Antwort)
|
||||
Answered → (KI prüft) ggf. neue Rückfrage NeedsInfo → Answered → … → Resolved`.
|
||||
Jede **weitere Antwort** der Züchterin bringt das Ticket wieder auf `Answered` und
|
||||
damit erneut in die Bearbeitung (Schritt 1 filtert `Answered` immer mit ein).
|
||||
|
||||
**Kontext-Träger ist das Ticket, nicht der Subagent.** Subagenten sind
|
||||
sitzungsgebunden — antwortet die Züchterin später (neue Session), existiert der
|
||||
ursprüngliche Agent nicht mehr. Daher MUSS der gesamte Verlauf am Ticket hängen
|
||||
(Frage/Antwort je Runde) + der Triage-Eintrag in `docs/ticket-triage.md`. Beim
|
||||
Wiederaufgreifen eines `Answered`-Tickets lädt der Subagent diesen vollständigen
|
||||
Verlauf + die frühere Triage und macht **mit demselben Kontext** weiter (statt bei
|
||||
null neu zu untersuchen) — praktisch „derselbe Vorgang, fortgesetzt".
|
||||
|
||||
**Innerhalb derselben Session** kann man stattdessen denselben Subagenten direkt
|
||||
fortsetzen (SendMessage an seine Agent-ID, Kontext bleibt erhalten) — nur dann ist
|
||||
echte Agent-Kontinuität möglich.
|
||||
|
||||
Das Datenmodell unterstützt Mehrrunden über einen **Verlauf/Thread** am Ticket
|
||||
(mehrere Frage/Antwort-Einträge mit Autor + Zeit), nicht nur ein einzelnes
|
||||
Frage/Antwort-Paar — so geht bei weiteren Antworten nichts verloren.
|
||||
|
||||
### Internes Agenten-Kontextfeld (für die Züchterin unsichtbar)
|
||||
|
||||
Das Ticket hat ein **internes Kontextfeld** (`agentContext`, NICHT in der Züchterin-
|
||||
UI sichtbar) — das **Arbeitsgedächtnis** des Agenten. Damit ersetzt das Ticket den
|
||||
sitzungsgebundenen Subagenten-Kontext:
|
||||
- **Schreiben:** Bei jeder Bearbeitung legt der Agent dort seinen Arbeitsstand ab
|
||||
(kompaktes JSON/Markdown): Befunde, vermutete Ursache, geprüfte Hypothesen,
|
||||
betroffene Dateien/Daten, der Plan, **worauf gewartet wird** (welche Info die
|
||||
Rückfrage klären soll) und schon erledigte Teilschritte.
|
||||
- **Lesen:** Beim Wiederaufgreifen eines `Answered`-Tickets liest der Agent zuerst
|
||||
`agentContext` (+ Verlauf + Triage-Eintrag) und setzt **dort an, wo er aufgehört
|
||||
hat** — keine Neu-Untersuchung von null.
|
||||
- **Trennung:** `agentContext` ist intern/technisch (darf IDs/Dateinamen/Hypothesen
|
||||
enthalten); die **sichtbaren** Felder bleiben laienverständlich (Frage, Antwort,
|
||||
`fixNote`). `agentContext` wird in der Tickets-Seite NICHT angezeigt.
|
||||
- `PUT /feedback/{id}` mit `{"agentContext": "<arbeitsstand>"}` aktualisiert es,
|
||||
ohne den Status zu ändern.
|
||||
|
||||
## Verifikation vor „fertig"
|
||||
- Code-Fixes: `npx tsc --noEmit`, `npx vitest run`, betroffene `npx playwright test`,
|
||||
`dotnet build`/`dotnet test`, Python-`test_*.py` — grün.
|
||||
- Daten-Fixes: in `resolved_import.json` stichprobenartig prüfen, dann re-ingest +
|
||||
API-Check des konkreten Tiers/Wurfs.
|
||||
- Jedes erledigte Ticket auf `Resolved` mit `fixNote` gesetzt.
|
||||
|
||||
## Hinweise
|
||||
- Pro Ticket genau ein Triage-Agent; Umsetzung gebündelt nach Cluster.
|
||||
- Encoding: deutsche Texte als UTF-8 (Datei, nicht stdin-Pipe).
|
||||
- Nichts committen, bis der Haupt-Loop verifiziert hat; auf `main` arbeiten.
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -140,3 +140,9 @@ GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
# Runtime: gespeicherte Vertrags-Dateien
|
||||
GerbilManagerWebAPI/contract-storage/
|
||||
|
||||
# Agent-Worktrees (lokal, nicht versionieren)
|
||||
.claude/worktrees/
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
216
CLAUDE.md
Normal file
216
CLAUDE.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# CLAUDE.md — GerbilManager
|
||||
|
||||
Leitfaden für die Arbeit an diesem Repo. Bitte vor Änderungen lesen.
|
||||
|
||||
## Was ist das?
|
||||
|
||||
Verwaltungs-App für eine **Rennmaus-Zucht** („Zucht der kleinen Chaoten"): Tiere,
|
||||
Würfe, Stammbäume/Ahnentafeln, Genetik (Farbschläge/Genotypen), Gehege, Kontakte
|
||||
(Züchter/Abnehmer), Abgabeverträge, Statistik und eine öffentliche CMS-Webseite.
|
||||
Single-User (die Züchterin), Daten größtenteils aus bestehenden Quelldateien
|
||||
importiert.
|
||||
|
||||
## Architektur / Projekte
|
||||
|
||||
| Projekt | Zweck |
|
||||
|---|---|
|
||||
| `GerbilManager.AppHost` | **.NET Aspire** Orchestrator — startet PostgreSQL (Container) + WebAPI |
|
||||
| `GerbilManagerWebAPI` | Backend: ASP.NET **Minimal API**, EF Core + Postgres, Endpoints in `Endpoints/`, Entities in `Models/`, DTOs in `Dtos/` |
|
||||
| `GerbilManager.ServiceDefaults` | Aspire-Defaults (Telemetry/Health) |
|
||||
| `GerbilManager.Tests` | Backend-Tests (xUnit) |
|
||||
| `gerbil-manager-web` | Frontend: **React + Vite + TypeScript** (React Router, react-d3-tree für den Stammbaum) |
|
||||
| `tools/import` | **Python**-Import-Pipeline (zero-dep), wandelt Quelldateien → `resolved_import.json` |
|
||||
|
||||
## App starten (IMMER über Aspire, nicht nur die WebAPI)
|
||||
|
||||
```bash
|
||||
dotnet run --project GerbilManager.AppHost
|
||||
```
|
||||
- WebAPI: http://localhost:5179 · OpenAPI: `/openapi/v1.json`
|
||||
- Aspire-Dashboard: https://localhost:17004 (Login-Token steht im AppHost-Log)
|
||||
- EF-Migrationen werden beim Start automatisch angewandt (`db.Database.Migrate()` in `Program.cs`).
|
||||
- Postgres läuft als Container (Name wechselt pro Run, z. B. `postgres-xxxx`); die
|
||||
**Daten liegen in einem persistenten Volume** → überleben Neustarts. DB-Name: `gerbilmanager`.
|
||||
|
||||
## Import → DB (3-stufig, WICHTIG)
|
||||
|
||||
Die DB wird **nicht** direkt von Python beschrieben. Nach jeder Import-Code-Änderung
|
||||
neu generieren UND neu einspielen, sonst zeigt die App den alten Stand.
|
||||
|
||||
```bash
|
||||
# 1. Stammbäume + Wurfchronik → animals.json/litters.json (+ Fotos)
|
||||
python tools/import/extract.py
|
||||
# 2. Abgabeverträge (DOCX) → contracts.json
|
||||
python tools/import/extract_contracts.py
|
||||
# 3. Dedup, Eltern-Resolver, Provenance, Vertrags-Anreicherung → resolved_import.json
|
||||
python tools/import/merge_and_resolve.py
|
||||
# 4. In die laufende DB laden (API muss laufen): WISCHT + lädt neu
|
||||
curl -X POST http://localhost:5179/import/ingest-resolved
|
||||
```
|
||||
|
||||
- **`IngestResolvedService` wischt** Gerbils/Litters/Contacts/Photos/Health/Weights/
|
||||
**SaleContracts** und lädt aus `resolved_import.json` neu. Importierte SaleContracts
|
||||
sind Teil des Payloads → werden bei jedem Ingest neu erzeugt (idempotent, IDs deterministisch).
|
||||
- **Feedback überlebt den Ingest** (wird nicht gewischt) — daher hat `Feedback` nur
|
||||
**lose, nullable Guid-Spalten ohne FK** (GerbilId/LitterId/ContactId). Dieses Muster
|
||||
für alles nutzen, was Re-Ingests überleben soll.
|
||||
- `resolved_import.json` ist **gitignored** (Runtime-Output); `review-report.md` ist getrackt.
|
||||
- Deterministische GUIDs via `generate_guid("...")` → stabile IDs über Re-Ingests.
|
||||
|
||||
### Quelldateien (lokal/Netzwerk)
|
||||
- Stammbäume: `C:\Users\gulum\dev\Sttammbäume\*.xlsx`
|
||||
- Wurfchronik: `C:\Users\gulum\dev\Wurfchronik der Kleine Chaoten Teil1.xlsx`
|
||||
- Abgabeverträge: `\\truenas\Datengrab\Rennmäuse\Verträge\*.docx` (~1,4k; kann zeitweise offline sein)
|
||||
|
||||
## Build & Test
|
||||
|
||||
**Frontend** (`cd gerbil-manager-web`):
|
||||
```bash
|
||||
npx tsc --noEmit # Typecheck (muss sauber sein)
|
||||
npx vitest run # Unit-Tests
|
||||
npx eslint <dateien> # Lint
|
||||
npx playwright test [spec]# e2e (Projekte: desktop + phone), läuft gegen Mock-API
|
||||
```
|
||||
- e2e nutzt einen **Mock-Modus** (`e2e/mock-api.ts` + `e2e/mock-data.ts`), kein echtes Backend.
|
||||
Neue API-Routen/Felder dort spiegeln. `skipUnlessMock()` am Testanfang.
|
||||
|
||||
**Backend**:
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet test # GerbilManager.Tests
|
||||
dotnet ef migrations add <Name> --project GerbilManagerWebAPI # neue Migration
|
||||
```
|
||||
- Zum Bauen muss eine laufende WebAPI ggf. gestoppt werden (Datei-Lock auf `bin`).
|
||||
|
||||
**Python** (`cd tools/import`, zero-dep, exit 0 = pass):
|
||||
```bash
|
||||
python test_extract.py test_extract_docx.py test_genotype.py test_merge_resolve.py test_extract_contracts.py
|
||||
```
|
||||
|
||||
## Konventionen (bitte einhalten)
|
||||
|
||||
- **Alle deutschen UI-Texte in `gerbil-manager-web/src/strings/de.ts`** — niemals in
|
||||
Komponenten hartkodieren.
|
||||
- **EF-Migration** für jede Schema-Änderung; wird beim Aspire-Start angewandt.
|
||||
- **Provenance/Datenherkunft** liegt als **JSON-Text-Spalte** (`Provenance`) auf
|
||||
Gerbil/Contact/Litter → JSON-Feld-Erweiterungen brauchen **keine** Migration.
|
||||
- **Lose nullable Guid-Spalten ohne FK** für Tabellen, die den Ingest-Wipe überleben.
|
||||
- Commits: nur wenn der Nutzer es verlangt; auf `main` wird gearbeitet (Remote: `http://truenas:13000/Gulum/GerbilManager.git`). Commit-Messages auf Deutsch, Format `feat(scope): …`.
|
||||
- Subagenten committen NICHT — Haupt-Loop verifiziert und committet.
|
||||
|
||||
## Domänenwissen (Rennmaus-Zucht)
|
||||
|
||||
- **Lebensspanne ~6 Jahre** → ein Elternteil kann max. ~6 Jahre älter als sein Kind
|
||||
sein und muss vorher geboren sein (`parent_age_plausible` im Import; verwirft
|
||||
unplausible Eltern-Links).
|
||||
- **Geschwisterverpaarung** (Vollgeschwister als Eltern): erkannt, wenn Vater & Mutter
|
||||
**dieselbe `litterId`** ODER **dasselbe Geburtsdatum** teilen (gleiches DOB = starkes
|
||||
Indiz). UI: Diagramm führt den Vorfahren-Ast zusammen (Verweis-Knoten); Akte zeigt
|
||||
„⚭ Geschwisterverpaarung"-Chip.
|
||||
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
|
||||
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
|
||||
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
|
||||
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
|
||||
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
|
||||
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
|
||||
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
|
||||
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||
geringste Spalten-Fehlausrichtung bestimmt (`extract._attach_photos`).
|
||||
|
||||
## Stolperfallen
|
||||
|
||||
- **Aspire-Containername wechselt** pro Run, das Daten-Volume bleibt → DB-Inhalt
|
||||
überlebt Neustarts (auch alte Schema-Stände → Migrationskonflikte möglich, s. u.).
|
||||
- **Migrations-Historie vs. committete Migration-IDs**: Wird eine Migration angewandt
|
||||
und danach mit neuem Zeitstempel neu generiert, sieht EF sie als „pending" und der
|
||||
Start crasht („column … already exists"). Schema ist dann korrekt — nur die
|
||||
`__EFMigrationsHistory`-IDs müssen an die committeten Dateinamen angeglichen werden.
|
||||
- **PowerShell + psql**: PascalCase-Identifier (`"__EFMigrationsHistory"`) über
|
||||
PowerShell→docker→psql zerschießen das Quoting. Stattdessen Bash-Heredoc:
|
||||
`docker exec -i -e PGPASSWORD=$PW <container> psql -U postgres -d gerbilmanager <<'SQL' … SQL`.
|
||||
Passwort: `docker exec <container> printenv POSTGRES_PASSWORD`.
|
||||
- **Direkte DB-Schreibzugriffe** (psql) werden vom Auto-Mode-Classifier blockiert —
|
||||
vorher beim Nutzer rückversichern.
|
||||
- Bash-Tool ist **Git-Bash (POSIX)**, das andere Tool ist **PowerShell** — je eigene Syntax.
|
||||
|
||||
## Wo liegt was (Feature-Landkarte)
|
||||
|
||||
- **Stammbaum-Viewer**: `gerbil-manager-web/src/pages/StammbaumPage.tsx`,
|
||||
`src/pedigree/*` (build.ts = Baum-Aufbau + Geschwister-Erkennung; types.ts).
|
||||
Desktop passt den ganzen Baum ein; Touch: Long-Press öffnet das Kontextmenü
|
||||
(ID kopieren / Fehler melden).
|
||||
- **Datenherkunft/Nachverfolgung**: `src/components/ProvenanceDialog.tsx`; erzeugt im
|
||||
Import via `build_entity_provenance`/`_build_*_history` (chronologische, datei-
|
||||
attribuierte `history[]`, inkl. „⚠ … verworfen — Grund; … verwendet").
|
||||
- **Fehler melden (Feedback)**: `src/components/ReportErrorDialog.tsx`,
|
||||
`src/api/feedback.ts`, Backend `Endpoints/FeedbackEndpoints.cs` + `Models/Feedback.cs`.
|
||||
- **Verträge**: `Models/SaleContract.cs`, `Endpoints/ContractEndpoints.cs`,
|
||||
`ContractGenerator.cs`, Frontend `src/pages/Vertraege*`; Import: `extract_contracts.py`
|
||||
+ `enrich_from_contracts` in `merge_and_resolve.py`.
|
||||
- **Import-Kernlogik**: `tools/import/merge_and_resolve.py` (Dedup, Eltern-Resolver mit
|
||||
Gender-/Alters-Plausibilität, Litter-/Sibling-Dedup, Provenance, Vertrags-Anreicherung).
|
||||
|
||||
## Verifikations-Checkliste vor „fertig"
|
||||
|
||||
1. `npx tsc --noEmit`, `npx vitest run`, `npx eslint` sauber.
|
||||
2. Betroffene `npx playwright test`-Specs grün.
|
||||
3. `dotnet build` + `dotnet test` grün.
|
||||
4. Bei Import-Änderungen: `python test_*.py` grün + `resolved_import.json` plausibel
|
||||
(Stichprobe), danach App neu starten + `POST /import/ingest-resolved`.
|
||||
|
||||
## Gedächtnis (MemPalace) — IMMER nutzen
|
||||
|
||||
Dieses Projekt nutzt **MemPalace** als KI-Langzeitgedächtnis (lokal, kein API-Key).
|
||||
Die Anbindung ist im Repo persistiert und lädt automatisch:
|
||||
- `.mcp.json` (committet) registriert den MCP-Server `mempalace-mcp` → **19 MCP-Tools**
|
||||
(`mempalace_search`, `mempalace_kg_query/add/invalidate/timeline`, `mempalace_add_drawer`,
|
||||
`mempalace_diary_*` …). Aktiv ab Claude-Neustart (MCP-Server einmal bestätigen).
|
||||
- `.claude/settings.json` (committet) verdrahtet die Hooks
|
||||
`mempalace hook run --hook {session-start,stop,precompact} --harness claude-code`:
|
||||
**SessionStart** lädt relevanten Kontext automatisch, **Stop/PreCompact** persistieren.
|
||||
- Voraussetzung: `mempalace` ist installiert und auf PATH (`~/.local/bin`). Nach `git clone`
|
||||
ggf. MemPalace installieren; `mempalace.yaml` (Raum-Config) ist bewusst **gitignored**
|
||||
(per-project, lokal). Der Palast selbst liegt lokal unter `~/.mempalace` (nicht im Repo).
|
||||
|
||||
**Arbeitsweise pro Session:** zu Aufgabenbeginn relevantes Wissen abrufen
|
||||
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||
durable Erkenntnisse ablegen.
|
||||
|
||||
**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.
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
530
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
530
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
@@ -0,0 +1,530 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink.
|
||||
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
|
||||
/// - Validation: an empty message is rejected with 400.
|
||||
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
|
||||
/// </summary>
|
||||
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_persists_row_with_debug_context()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Der Stammbaum zeigt den falschen Vater.",
|
||||
context = "stammbaum",
|
||||
gerbilId,
|
||||
litterId = (Guid?)null,
|
||||
entityName = "Krümel",
|
||||
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
|
||||
clientTimestamp = "2026-06-22T12:00:00Z",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
|
||||
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
|
||||
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// A freshly created ticket starts Open with no ResolvedAt.
|
||||
Assert.Equal("Open", created.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// GET returns it (newest first)
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."
|
||||
&& f.GetProperty("status").GetString() == "Open");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Gewicht wird falsch gerundet.",
|
||||
context = "gerbil-detail",
|
||||
entityName = "Balu",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
|
||||
// List contains it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id);
|
||||
|
||||
// Edit message
|
||||
var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." });
|
||||
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
|
||||
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString());
|
||||
Assert.Equal("Open", edited.GetProperty("status").GetString());
|
||||
|
||||
// Empty message is rejected
|
||||
var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
// Mark resolved -> ResolvedAt is set
|
||||
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", resolved.GetProperty("status").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Reopen -> ResolvedAt cleared
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" });
|
||||
Assert.Equal(HttpStatusCode.OK, reopen.StatusCode);
|
||||
var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Open", reopened.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// 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]
|
||||
public async Task Post_feedback_rejects_empty_message()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A feedback report referencing the gerbil + litter that the wipe will delete.
|
||||
var feedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = feedbackId,
|
||||
Message = "Bitte prüfen.",
|
||||
Context = "gerbil-detail",
|
||||
GerbilId = fatherId,
|
||||
LitterId = litterId,
|
||||
EntityName = "Papa",
|
||||
Url = "http://localhost/rennmaeuse/papa",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Answered",
|
||||
ResolvedAt = null,
|
||||
Question = "Welches Datum stimmt?",
|
||||
Answer = "Der 2. Juni.",
|
||||
AnsweredAt = DateTimeOffset.UtcNow,
|
||||
FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
|
||||
AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
|
||||
Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
|
||||
});
|
||||
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||
var contactFeedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = contactFeedbackId,
|
||||
Message = "Adresse stimmt nicht.",
|
||||
Context = "contact-detail",
|
||||
ContactId = contactId,
|
||||
EntityName = "Test Breeder",
|
||||
Url = "http://localhost/kontakte/test-breeder",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(litterId, survivor.LitterId);
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe
|
||||
// The new question/answer columns survive the wipe too.
|
||||
Assert.Equal("Welches Datum stimmt?", survivor.Question);
|
||||
Assert.Equal("Der 2. Juni.", survivor.Answer);
|
||||
Assert.NotNull(survivor.AnsweredAt);
|
||||
// The new fix-note / agent-context / thread columns survive the wipe too.
|
||||
Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
|
||||
Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
|
||||
Assert.Contains("Erste Frage?", survivor.Thread);
|
||||
|
||||
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||
Assert.Equal(contactId, contactSurvivor.ContactId);
|
||||
Assert.Equal("contact-detail", contactSurvivor.Context);
|
||||
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
|
||||
Assert.Equal(2, await db.Feedback.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,10 @@ namespace GerbilManager.Tests
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private Guid ContactId { get; set; }
|
||||
private Guid FatherId { get; set; }
|
||||
private Guid ContractId { get; set; }
|
||||
private Guid UnknownGerbilId { get; set; }
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
@@ -22,6 +26,10 @@ namespace GerbilManager.Tests
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
ContactId = contactId;
|
||||
FatherId = fatherId;
|
||||
ContractId = Guid.NewGuid();
|
||||
UnknownGerbilId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -36,7 +44,8 @@ namespace GerbilManager.Tests
|
||||
Email = "test@example.com",
|
||||
Homepage = "",
|
||||
Phone = "",
|
||||
Address = ""
|
||||
Address = "",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||
}
|
||||
},
|
||||
Litters = new[]
|
||||
@@ -54,7 +63,8 @@ namespace GerbilManager.Tests
|
||||
Notes = "Test litter notes",
|
||||
PairingCode = "PC01",
|
||||
ExternalRef = "ext-litter-1",
|
||||
LitterLetter = "A"
|
||||
LitterLetter = "A",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||
}
|
||||
},
|
||||
Gerbils = new[]
|
||||
@@ -84,7 +94,8 @@ namespace GerbilManager.Tests
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
IsResident = true,
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||
},
|
||||
new
|
||||
{
|
||||
@@ -111,7 +122,8 @@ namespace GerbilManager.Tests
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
IsResident = true,
|
||||
Provenance = (string?)null
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
@@ -123,6 +135,20 @@ namespace GerbilManager.Tests
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
},
|
||||
SaleContracts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = ContractId,
|
||||
ContactId = contactId,
|
||||
Price = 35.0m,
|
||||
HandoverDate = "2023-09-29",
|
||||
ContractDate = "2023-09-29",
|
||||
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
||||
// One known animal + one unknown id (must be skipped defensively).
|
||||
Animals = new[] { fatherId, UnknownGerbilId }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,6 +202,60 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(mother.Id, litter.MotherId);
|
||||
Assert.Equal(father.Id, photo.GerbilId);
|
||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||
|
||||
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
||||
Assert.NotNull(father.Provenance);
|
||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||
// The chronological, file-attributed history survives ingest verbatim
|
||||
// (stored as opaque JSON on the text column — no schema for it).
|
||||
Assert.Contains("history", father.Provenance);
|
||||
Assert.Contains("Genotyp aus", father.Provenance);
|
||||
Assert.Null(mother.Provenance);
|
||||
|
||||
// Contact + litter provenance also round-trips through the ingest.
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.NotNull(contact.Provenance);
|
||||
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
||||
|
||||
Assert.NotNull(litter.Provenance);
|
||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||
|
||||
// SaleContracts are created from the payload, and animal links to
|
||||
// unknown gerbils are skipped defensively (only the known father links).
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Equal(ContactId, contract.ContactId);
|
||||
Assert.Equal(35.0m, contract.Price);
|
||||
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
||||
var link = Assert.Single(contract.Animals);
|
||||
Assert.Equal(FatherId, link.GerbilId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
|
||||
// Re-ingest wipes then recreates from the (deterministic) payload —
|
||||
// the contract survives by being part of the payload, no duplicate PK.
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
404
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
404
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
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\"}}}]}");
|
||||
try
|
||||
{
|
||||
var d = Rpro3Decisions.Load(path);
|
||||
Assert.Single(d.Decisions);
|
||||
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
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,
|
||||
};
|
||||
}
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,14 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<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>();
|
||||
|
||||
// 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.
|
||||
@@ -202,6 +210,64 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
|
||||
// Guid columns (no navigation properties → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
|
||||
// feedback rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<Feedback>(e =>
|
||||
{
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId
|
||||
// are plain nullable Guid columns (no navigation properties → EF creates NO foreign
|
||||
// key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or
|
||||
// breaks — acquisition rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<AcquisitionRecord>(e =>
|
||||
{
|
||||
e.Property(a => a.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(a => a.GerbilId);
|
||||
});
|
||||
|
||||
// ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback).
|
||||
// GerbilId/ReservedForContactId are plain Guid columns (no navigation properties →
|
||||
// EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never
|
||||
// cascades into — or breaks — reservation rows. They survive re-ingest by design.
|
||||
modelBuilder.Entity<SaleReservation>(e =>
|
||||
{
|
||||
e.Property(r => r.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
});
|
||||
|
||||
// WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain
|
||||
// nullable Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Contacts never cascades into — or breaks —
|
||||
// waiting-list rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<WaitingListEntry>(e =>
|
||||
{
|
||||
e.HasIndex(w => w.CreatedAt);
|
||||
});
|
||||
|
||||
// RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/
|
||||
// FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt
|
||||
// KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts
|
||||
// diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest.
|
||||
modelBuilder.Entity<ReturnRecord>(e =>
|
||||
{
|
||||
e.Property(r => r.ReturnPrice).HasPrecision(10, 2);
|
||||
e.Property(r => r.OriginalPrice).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
e.HasIndex(r => r.CreatedAt);
|
||||
});
|
||||
|
||||
// EXHIBITION: deliberately relationship-free (same pattern as Feedback). GerbilId is a
|
||||
// plain nullable Guid column (no navigation property → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils never cascades into or breaks exhibition rows.
|
||||
modelBuilder.Entity<ExhibitionResult>(e =>
|
||||
{
|
||||
e.HasIndex(x => x.GerbilId);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -336,25 +402,28 @@ public class ApplicationContext : DbContext
|
||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
|
||||
// --- Hell variants (IDs 38-48) ---
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
|
||||
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
|
||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||
|
||||
20
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);
|
||||
}
|
||||
@@ -32,7 +32,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
bool? IsDeaf,
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated);
|
||||
bool IsCastrated,
|
||||
string? Provenance);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -43,11 +44,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
string? Provenance);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
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);
|
||||
|
||||
@@ -99,7 +104,10 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
public record EnclosureInput(
|
||||
string Name, string? Notes,
|
||||
string? Size, int? Capacity,
|
||||
DateOnly? LastCleanedDate, int? CleaningCycleDays);
|
||||
|
||||
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
string Url,
|
||||
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
|
||||
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
|
||||
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
|
||||
bool HasFile);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
|
||||
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);
|
||||
}
|
||||
93
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
93
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
|
||||
public record FeedbackInput(
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
|
||||
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
|
||||
/// </summary>
|
||||
public record FeedbackThreadEntry(
|
||||
string Role,
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
|
||||
public record FeedbackAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
int Size);
|
||||
|
||||
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||
public record FeedbackAttachmentInput(
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string DataBase64);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp,
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Status,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt,
|
||||
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
|
||||
string? FixNote,
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread,
|
||||
/// <summary>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);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db,
|
||||
IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var root = ContractRoot(config, env);
|
||||
return TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
.ToPagedResultAsync(query, c => ToDto(c, root)));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env)));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
@@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
$"/contracts/{c.Id}/file",
|
||||
// Imported contracts have no staged .docx -> HasFile=false (Word
|
||||
// download 404s gracefully). When no root is given (e.g. POST, where
|
||||
// the file was just written) we assume the file exists.
|
||||
HasFile: contractRoot is null
|
||||
|| (!string.IsNullOrEmpty(c.FileName)
|
||||
&& File.Exists(Path.Combine(contractRoot, c.FileName))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||
Apply(e, input);
|
||||
db.Enclosures.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
||||
@@ -35,10 +36,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.Name = input.Name; e.Notes = input.Notes;
|
||||
Apply(e, input);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// Reinigung dokumentieren: setzt LastCleanedDate auf heute (NextCleaningDate folgt aus dem Zyklus).
|
||||
group.MapPost("/{id:guid}/mark-cleaned", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.LastCleanedDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(e));
|
||||
});
|
||||
|
||||
// 409 if the enclosure still houses gerbils.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -54,6 +66,16 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
|
||||
private static void Apply(Enclosure e, EnclosureInput input)
|
||||
{
|
||||
e.Size = input.Size;
|
||||
e.Capacity = input.Capacity;
|
||||
e.LastCleanedDate = input.LastCleanedDate;
|
||||
e.CleaningCycleDays = input.CleaningCycleDays;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) =>
|
||||
new(e.Id, e.Name, e.Notes, e.Size, e.Capacity,
|
||||
e.LastCleanedDate, e.CleaningCycleDays, e.NextCleaningDate);
|
||||
}
|
||||
}
|
||||
|
||||
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// EXHIBITION: show/exhibition results & awards per animal (RennmausPro `ausz_tb`).
|
||||
/// GET /exhibitions[?gerbilId=…] -> list (optionally filtered to one animal), newest first.
|
||||
/// POST /exhibitions -> create a result, returns 201.
|
||||
/// PUT /exhibitions/{id} -> edit a result (partial), 404 on missing id.
|
||||
/// DELETE /exhibitions/{id} -> remove a result, 404 on missing id.
|
||||
/// Decoupled from gerbils (loose nullable GerbilId, no FK) so rows survive the import re-ingest wipe.
|
||||
/// </summary>
|
||||
public static class ExhibitionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapExhibitionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/exhibitions").WithTags("Exhibitions");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
// Filter in the query; order in memory (SQLite test host cannot ORDER BY a
|
||||
// DateTimeOffset column — mirrors FeedbackEndpoints).
|
||||
var query = db.ExhibitionResults.AsNoTracking();
|
||||
if (gerbilId is { } gid)
|
||||
query = query.Where(x => x.GerbilId == gid);
|
||||
var rows = await query.ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(x => x.Date ?? DateTime.MinValue)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.ExhibitionResults.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<ExhibitionResultDto>, BadRequest<string>>> (
|
||||
ExhibitionResultInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.EventName))
|
||||
return TypedResults.BadRequest("EventName darf nicht leer sein.");
|
||||
|
||||
var entity = new ExhibitionResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
EntityName = Trim(input.EntityName),
|
||||
EventName = input.EventName.Trim(),
|
||||
Date = input.Date,
|
||||
Placement = Trim(input.Placement),
|
||||
Award = Trim(input.Award),
|
||||
Note = Trim(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ExhibitionResults.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/exhibitions/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, ExhibitionResultUpdate input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (input.EventName is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.EventName))
|
||||
return TypedResults.BadRequest("EventName darf nicht leer sein.");
|
||||
entity.EventName = input.EventName.Trim();
|
||||
}
|
||||
if (input.GerbilId is not null) entity.GerbilId = input.GerbilId;
|
||||
if (input.EntityName is not null) entity.EntityName = Trim(input.EntityName);
|
||||
if (input.Date is not null) entity.Date = input.Date;
|
||||
if (input.Placement is not null) entity.Placement = Trim(input.Placement);
|
||||
if (input.Award is not null) entity.Award = Trim(input.Award);
|
||||
if (input.Note is not null) entity.Note = Trim(input.Note);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.ExhibitionResults.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Empty/whitespace optional strings normalise to null.
|
||||
private static string? Trim(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static ExhibitionResultDto ToDto(ExhibitionResult r) =>
|
||||
new(r.Id, r.GerbilId, r.EntityName, r.EventName, r.Date,
|
||||
r.Placement, r.Award, r.Note, r.CreatedAt);
|
||||
}
|
||||
}
|
||||
393
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
393
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets").
|
||||
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||
/// GET /feedback -> list reports, newest first (for the ticket list).
|
||||
/// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage),
|
||||
/// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt.
|
||||
/// DELETE /feedback/{id} -> remove a report. 404 on missing id.
|
||||
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||
/// rows survive the import re-ingest wipe.
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||
private const int TrashRetentionDays = 30;
|
||||
|
||||
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
|
||||
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
|
||||
FeedbackInput input, ApplicationContext db, HttpContext http) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
return TypedResults.BadRequest("Message darf nicht leer sein.");
|
||||
|
||||
var entity = new Feedback
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Message = input.Message.Trim(),
|
||||
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
||||
GerbilId = input.GerbilId,
|
||||
LitterId = input.LitterId,
|
||||
ContactId = input.ContactId,
|
||||
EntityName = input.EntityName,
|
||||
Url = input.Url,
|
||||
ClientTimestamp = input.ClientTimestamp,
|
||||
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Open",
|
||||
ResolvedAt = null,
|
||||
};
|
||||
db.Feedback.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||
// auf DateTimeOffset-Spalten übersetzen.
|
||||
var rows = await db.Feedback.ToListAsync();
|
||||
|
||||
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
db.Feedback.RemoveRange(expired);
|
||||
await db.SaveChangesAsync();
|
||||
rows = rows.Except(expired).ToList();
|
||||
}
|
||||
|
||||
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
|
||||
var attMeta = await db.FeedbackAttachments
|
||||
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
|
||||
.ToListAsync();
|
||||
var byTicket = attMeta
|
||||
.GroupBy(a => a.FeedbackId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<FeedbackAttachmentDto>)g
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToList());
|
||||
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db, 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;
|
||||
}
|
||||
|
||||
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,
|
||||
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>());
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Import.Rpro3;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
@@ -8,6 +11,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
||||
/// against Julian's DB under god-supervision after he approves the dry-run.
|
||||
///
|
||||
/// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"):
|
||||
/// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren;
|
||||
/// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen.
|
||||
/// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs).
|
||||
/// </summary>
|
||||
public static class ImportEndpoints
|
||||
{
|
||||
@@ -36,7 +44,109 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
IReadOnlySet<string>? photoFiles = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoFiles = ListZipImageNames(imgSeekable);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.AnalyzeAsync(data, images is not null, photoFiles);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
group.MapPost("/rpro3/execute", async Task<Results<Ok<Rpro3ExecuteResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
Dictionary<string, string>? photoPaths = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoPaths = ExtractZipImages(imgSeekable, workDir);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren.
|
||||
private static async Task<MemoryStream> ToSeekable(Stream s)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
await s.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
||||
|
||||
private static IReadOnlySet<string> ListZipImageNames(Stream zip)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
set.Add(e.Name);
|
||||
return set;
|
||||
}
|
||||
|
||||
// Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad.
|
||||
private static Dictionary<string, string> ExtractZipImages(Stream zip, string workDir)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imgDir = Path.Combine(workDir, "bilder");
|
||||
Directory.CreateDirectory(imgDir);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
continue;
|
||||
var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name));
|
||||
try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; }
|
||||
catch { /* defektes Archiv-Entry überspringen */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void TryDeleteDir(string dir)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
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="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -22,6 +24,7 @@
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -33,6 +36,12 @@
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
|
||||
damit der Importer sie zur Laufzeit findet. -->
|
||||
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
|
||||
@@ -103,6 +103,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
existingContact.Notes = c.Notes;
|
||||
existingContact.IsBreeder = c.IsBreeder;
|
||||
existingContact.IsReceiver = c.IsReceiver;
|
||||
existingContact.NameSuffix = c.NameSuffix;
|
||||
existingContact.Provenance = c.Provenance;
|
||||
contactsUpdated++;
|
||||
}
|
||||
else if (addedContactIds.Add(c.Id))
|
||||
@@ -130,7 +132,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
Notes = l.Notes,
|
||||
PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef,
|
||||
LitterLetter = l.LitterLetter
|
||||
LitterLetter = l.LitterLetter,
|
||||
Provenance = l.Provenance
|
||||
});
|
||||
}
|
||||
_db.Litters.AddRange(littersToInsert);
|
||||
@@ -160,6 +163,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
ImportSource = g.ImportSource,
|
||||
ExternalRef = g.ExternalRef,
|
||||
RawImportData = g.RawImportData,
|
||||
Provenance = g.Provenance,
|
||||
OriginBreeder = g.OriginBreeder,
|
||||
NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits,
|
||||
@@ -234,7 +238,53 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
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. Insert imported Abgabeverträge (SaleContract + join rows).
|
||||
// The table was wiped above, so these are recreated on every ingest
|
||||
// (they survive only by being part of the payload). FK-safe: contacts
|
||||
// and gerbils are already inserted. We skip rows whose buyer contact
|
||||
// is unknown and skip individual animal links to unknown gerbils
|
||||
// (defensive — the resolver should never emit those).
|
||||
// No .docx is copied: the source files live on the network share and
|
||||
// are not staged into Contracts:RootPath, so the Word download 404s
|
||||
// gracefully (the endpoint already returns NotFound) while the PDF is
|
||||
// regenerated from the data. The DTO exposes HasFile=false for these
|
||||
// so the frontend hides the Word button.
|
||||
int contractsAdded = 0;
|
||||
int contractAnimalsSkipped = 0;
|
||||
if (data.SaleContracts.Count > 0)
|
||||
{
|
||||
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||
var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||
var seenContractIds = new HashSet<Guid>();
|
||||
foreach (var sc in data.SaleContracts)
|
||||
{
|
||||
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
|
||||
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
|
||||
|
||||
var animals = new List<SaleContractAnimal>();
|
||||
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
|
||||
{
|
||||
if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; }
|
||||
animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid });
|
||||
}
|
||||
|
||||
_db.SaleContracts.Add(new SaleContract
|
||||
{
|
||||
Id = sc.Id,
|
||||
ContactId = sc.ContactId,
|
||||
Price = sc.Price,
|
||||
HandoverDate = sc.HandoverDate,
|
||||
ContractDate = sc.ContractDate,
|
||||
FileName = sc.FileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = animals,
|
||||
});
|
||||
contractsAdded++;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +294,22 @@ namespace GerbilManagerWebAPI.Import
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { get; set; } = new();
|
||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||
public List<ResolvedSaleContract> SaleContracts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Eine importierte Abgabevertrag-Zeile aus resolved_import.json.
|
||||
/// <c>Animals</c> sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim
|
||||
/// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der
|
||||
/// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt.</summary>
|
||||
public class ResolvedSaleContract
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ContactId { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public DateOnly HandoverDate { get; set; }
|
||||
public DateOnly ContractDate { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public List<Guid> Animals { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResolvedPhoto
|
||||
|
||||
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
|
||||
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
|
||||
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
|
||||
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
|
||||
///
|
||||
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
|
||||
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
|
||||
/// der ganze Cluster wird mitgezogen).
|
||||
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
|
||||
/// Zusammenlegen über Gruppengrenzen).
|
||||
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
|
||||
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
|
||||
///
|
||||
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
|
||||
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
|
||||
/// verschieben sich, sobald sich die Clusterbildung ändert.
|
||||
/// </summary>
|
||||
public sealed class Rpro3Decisions
|
||||
{
|
||||
public List<Rpro3Decision> Decisions { get; set; } = new();
|
||||
|
||||
public static Rpro3Decisions Empty { get; } = new();
|
||||
|
||||
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
|
||||
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
|
||||
public static Rpro3Decisions Load(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return Empty;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
|
||||
return d ?? Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
|
||||
public IEnumerable<IReadOnlyList<string>> SameGroups()
|
||||
{
|
||||
foreach (var d in Decisions)
|
||||
foreach (var g in d.Same)
|
||||
if (g.Count >= 2) yield return g;
|
||||
}
|
||||
|
||||
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
|
||||
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
|
||||
public Dictionary<string, int> BuildSplitGroups()
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
int gid = 0;
|
||||
foreach (var d in Decisions)
|
||||
{
|
||||
if (d.Different.Count < 2) continue;
|
||||
foreach (var grp in d.Different)
|
||||
{
|
||||
gid++;
|
||||
foreach (var rid in grp)
|
||||
map[rid] = gid;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
|
||||
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
|
||||
{
|
||||
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
|
||||
foreach (var d in Decisions)
|
||||
foreach (var (rid, ov) in d.Fields)
|
||||
map[rid] = ov;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Rpro3Decision
|
||||
{
|
||||
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
|
||||
public string? Ticket { get; set; }
|
||||
public List<List<string>> Same { get; set; } = new();
|
||||
public List<List<string>> Different { get; set; } = new();
|
||||
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class Rpro3FieldOverride
|
||||
{
|
||||
public string? Color { get; set; }
|
||||
public string? Dob { get; set; } // ISO yyyy-MM-dd
|
||||
public string? Origin { get; set; }
|
||||
public bool? Resident { get; set; }
|
||||
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
}
|
||||
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}";
|
||||
}
|
||||
}
|
||||
886
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
886
GerbilManagerWebAPI/Import/Rpro3/Rpro3ImportService.cs
Normal file
@@ -0,0 +1,886 @@
|
||||
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.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) { }
|
||||
}
|
||||
}
|
||||
303
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
303
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"name": "Eiji",
|
||||
"ticket": "6aad4527-0401-43de-bda9-f649042bd1db",
|
||||
"same": [
|
||||
[
|
||||
"u735",
|
||||
"u469"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Momo",
|
||||
"ticket": "7937b283-2056-435d-8821-c68ada8b1bd2",
|
||||
"same": [
|
||||
[
|
||||
"u7453",
|
||||
"u7345"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u1014"
|
||||
],
|
||||
[
|
||||
"u7453"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Samuel",
|
||||
"ticket": "6b3f28c6-47a5-4752-be94-93cda5d6d29c",
|
||||
"same": [
|
||||
[
|
||||
"u2207",
|
||||
"u263"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u2207"
|
||||
],
|
||||
[
|
||||
"u144"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Female",
|
||||
"ticket": "2d77eefe-f3a1-4be4-a8ee-7cb73cee14ab",
|
||||
"different": [
|
||||
[
|
||||
"u649"
|
||||
],
|
||||
[
|
||||
"u683"
|
||||
],
|
||||
[
|
||||
"u647"
|
||||
],
|
||||
[
|
||||
"u685"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kennedy",
|
||||
"ticket": "e5fc2c01-d443-445b-8530-ff5b86be27b1",
|
||||
"same": [
|
||||
[
|
||||
"354",
|
||||
"u6929"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"354": {
|
||||
"color": "Blau, meliert-Starkschecke"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fegur",
|
||||
"ticket": "746a965b-a2e7-4d88-a497-640784b66818",
|
||||
"same": [
|
||||
[
|
||||
"231",
|
||||
"u6395"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tuli",
|
||||
"ticket": "06dd2e3d-5bed-469a-b975-d698712fd8ac",
|
||||
"same": [
|
||||
[
|
||||
"229",
|
||||
"u2255"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bura",
|
||||
"ticket": "7dbeb108-453d-4930-b615-a1054f0df8ad",
|
||||
"same": [
|
||||
[
|
||||
"228",
|
||||
"u2128"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"228": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mister X",
|
||||
"ticket": "ebd80367-a80c-426e-9541-1325274ba206",
|
||||
"same": [
|
||||
[
|
||||
"227",
|
||||
"u7065"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"227": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Zoey",
|
||||
"ticket": "3ad458f7-645f-447f-b217-7b3b7382f380",
|
||||
"different": [
|
||||
[
|
||||
"224"
|
||||
],
|
||||
[
|
||||
"u2887"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"224": {
|
||||
"origin": "Sarah Wörz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Max",
|
||||
"ticket": "625a86ab-51d8-4612-8292-5dbd93be2a5c",
|
||||
"different": [
|
||||
[
|
||||
"133"
|
||||
],
|
||||
[
|
||||
"u7533"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Akiro",
|
||||
"ticket": "9216ab27-407d-4f4c-abb2-51160fa33683",
|
||||
"same": [
|
||||
[
|
||||
"u1227",
|
||||
"u1865"
|
||||
]
|
||||
],
|
||||
"different": [
|
||||
[
|
||||
"u1339"
|
||||
],
|
||||
[
|
||||
"u1227"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u1227": {
|
||||
"dob": "2008-02-05",
|
||||
"color": "Polarfuchs, hell",
|
||||
"note": "Abweichendes Geburtsdatum in RennmausPro: 2008-02-06 (laut Variante C)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Merlin",
|
||||
"ticket": "d748b16a-18b5-48ec-b8a6-a4c8df66ad80",
|
||||
"same": [
|
||||
[
|
||||
"u2859",
|
||||
"u2626"
|
||||
],
|
||||
[
|
||||
"u7537",
|
||||
"u7536"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u7537": {
|
||||
"dob": "2021-02-10",
|
||||
"note": "Abweichendes Geburtsdatum in RennmausPro: 2012-08-07 (laut Variante G)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Snickers",
|
||||
"ticket": "f469352a-644b-43a1-9fb2-884600e4b9d3",
|
||||
"same": [
|
||||
[
|
||||
"384",
|
||||
"u6937"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"384": {
|
||||
"resident": false,
|
||||
"note": "Reiner Zuchtvorfahre (kein eigenes Zuchttier)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Bali",
|
||||
"ticket": "8c7246da-a9c6-406d-b857-8d9fcb9884f1",
|
||||
"same": [
|
||||
[
|
||||
"u3410",
|
||||
"u6997"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aladin",
|
||||
"ticket": "08be9baa-b0c8-4dd8-8fbe-daea1d647344",
|
||||
"different": [
|
||||
[
|
||||
"u480"
|
||||
],
|
||||
[
|
||||
"u1098"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lexi",
|
||||
"ticket": "16b449e8-50b1-4bed-b772-ab2c461a8984",
|
||||
"different": [
|
||||
[
|
||||
"u275"
|
||||
],
|
||||
[
|
||||
"u648"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pinto",
|
||||
"ticket": "08d00bca-70c0-4c14-88c8-0d3181313ba3",
|
||||
"same": [
|
||||
[
|
||||
"385",
|
||||
"u3709"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"385": {
|
||||
"note": "Angaben/Schenkung von Variante A (eigenes Tier) übernehmen."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Jasper",
|
||||
"ticket": "46951886-2e1c-40c8-a567-ca74e2a20de7",
|
||||
"different": [
|
||||
[
|
||||
"351"
|
||||
],
|
||||
[
|
||||
"u1289"
|
||||
],
|
||||
[
|
||||
"u7492"
|
||||
]
|
||||
],
|
||||
"fields": {
|
||||
"u7492": {
|
||||
"origin": "Clan of Colourful furry gerbils"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Nestor",
|
||||
"ticket": "61b31c30-a24e-4dea-8ecb-9daeae5affd6",
|
||||
"same": [
|
||||
[
|
||||
"200",
|
||||
"u3459",
|
||||
"u2634"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedback : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Feedback",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
Context = table.Column<string>(type: "text", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||
Url = table.Column<string>(type: "text", nullable: true),
|
||||
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
UserAgent = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Feedback", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Feedback_CreatedAt",
|
||||
table: "Feedback",
|
||||
column: "CreatedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs
generated
Normal file
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.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 AddGerbilProvenance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackContactId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ContactId",
|
||||
table: "Feedback",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ContactId",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.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 AddContactLitterProvenance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,38 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly?>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("Price")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<Guid?>("SourceContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("AcquisitionRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -451,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 40
|
||||
},
|
||||
@@ -493,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 43
|
||||
},
|
||||
@@ -514,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 46
|
||||
},
|
||||
@@ -528,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 48
|
||||
},
|
||||
@@ -563,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||
Name = "Dilute Blaufuchs",
|
||||
SortOrder = 53
|
||||
},
|
||||
@@ -741,6 +773,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
@@ -752,6 +787,15 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("CleaningCycleDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateOnly?>("LastCleanedDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -759,6 +803,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Size")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Enclosures");
|
||||
@@ -793,6 +840,158 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("EnclosurePhotos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Award")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Date")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Placement")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("ExhibitionResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AgentContext")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Answer")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FixNote")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool?>("Helpful")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReopenedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Thread")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("Feedback");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<Guid>("FeedbackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Size")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeedbackAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -870,6 +1069,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RawImportData")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -1001,6 +1203,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("PairingCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -1230,6 +1435,53 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Requests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("FromContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("GerbilName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("OriginalPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<DateTime?>("OriginalSaleDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ReturnDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal?>("ReturnPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("ReturnRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1281,6 +1533,54 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("SaleContractAnimal");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("AppointmentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("GerbilName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("HandedOverDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("Price")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<Guid?>("ReservedForContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("SaleReservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1308,6 +1608,70 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("RequestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WishColor")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WishGender")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("WaitingListEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebPushSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal file
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// ERWERB: how/when an animal was acquired (purchase date, price, note) — sourced from
|
||||
/// RennmausPro's <c>herktier_tb</c> (_TID/_DATE/_PRICE/_BEM). The seller is already linked
|
||||
/// via Gerbil.OriginContactId; this record adds the date/price/note around that.
|
||||
///
|
||||
/// Decoupled from the rest of the model ON PURPOSE — GerbilId/SourceContactId are plain
|
||||
/// nullable Guid columns (NOT enforced foreign keys), so the import re-ingest wipe
|
||||
/// (IngestResolvedService, which deletes Gerbils/Contacts) never cascades into — or
|
||||
/// breaks — acquisition rows. They survive re-ingest, mirroring the Feedback pattern.
|
||||
/// </summary>
|
||||
public class AcquisitionRecord
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the acquired gerbil.</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the source/seller contact, if known.</summary>
|
||||
public Guid? SourceContactId { get; set; }
|
||||
|
||||
/// <summary>When the animal was acquired (purchase date).</summary>
|
||||
public DateOnly? Date { get; set; }
|
||||
|
||||
/// <summary>Acquisition price (purchase cost), if recorded.</summary>
|
||||
public decimal? Price { get; set; }
|
||||
|
||||
/// <summary>Free-text note about the acquisition.</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,11 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||
/// </summary>
|
||||
public string? NameSuffix { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
|
||||
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
|
||||
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@ namespace GerbilManagerWebAPI.Models
|
||||
public required string Name { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Reinigungszyklus (aus RennmausPro becken_tb: _SIZE/_MENGE/_CLEANED/_CYCLUS).
|
||||
/// <summary>Maße als Freitext (z. B. "120×50 cm").</summary>
|
||||
public string? Size { get; set; }
|
||||
/// <summary>Empfohlene/maximale Tieranzahl.</summary>
|
||||
public int? Capacity { get; set; }
|
||||
/// <summary>Datum der letzten Reinigung.</summary>
|
||||
public DateOnly? LastCleanedDate { get; set; }
|
||||
/// <summary>Reinigungsintervall in Tagen.</summary>
|
||||
public int? CleaningCycleDays { get; set; }
|
||||
|
||||
/// <summary>Nächste fällige Reinigung = LastCleanedDate + CleaningCycleDays (berechnet, nicht persistiert).</summary>
|
||||
public DateOnly? NextCleaningDate =>
|
||||
LastCleanedDate is { } last && CleaningCycleDays is { } cycle and > 0
|
||||
? last.AddDays(cycle)
|
||||
: null;
|
||||
|
||||
public ICollection<Gerbil> Gerbils { get; } = new List<Gerbil>();
|
||||
}
|
||||
}
|
||||
|
||||
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal file
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// EXHIBITION: a show/exhibition result or award for an animal (RennmausPro `ausz_tb` +
|
||||
/// `_AUSZ` flag — present in RPRO3 but unused by this breeder; implemented fully anyway).
|
||||
///
|
||||
/// Deliberately decoupled from the rest of the model, exactly like <see cref="Feedback"/>:
|
||||
/// GerbilId is a plain nullable Guid column (NOT an enforced foreign key, no navigation
|
||||
/// property), so the import re-ingest wipe (IngestResolvedService) can delete/recreate
|
||||
/// gerbils without deleting or breaking exhibition rows. Captured EntityName keeps the
|
||||
/// record human-readable even after the referenced animal is gone.
|
||||
/// </summary>
|
||||
public class ExhibitionResult
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the gerbil this result belongs to, if any.</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Captured name of the referenced animal (survives an ingest wipe).</summary>
|
||||
public string? EntityName { get; set; }
|
||||
|
||||
/// <summary>Name of the show/event (Veranstaltung), e.g. "Nationale Rennmausschau 2026".</summary>
|
||||
public required string EventName { get; set; }
|
||||
|
||||
/// <summary>Date of the event, if known.</summary>
|
||||
public DateTime? Date { get; set; }
|
||||
|
||||
/// <summary>Placement / ranking, e.g. "1. Platz", "BOB".</summary>
|
||||
public string? Placement { get; set; }
|
||||
|
||||
/// <summary>Award / title (Auszeichnung), e.g. "Best in Show", "V1".</summary>
|
||||
public string? Award { get; set; }
|
||||
|
||||
/// <summary>Free-text note (Bewertung / Bemerkung).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
117
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
117
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
|
||||
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
|
||||
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
|
||||
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
|
||||
/// keeps the report human-readable even after the referenced animal is gone.
|
||||
/// </summary>
|
||||
public class Feedback
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The user's free-text description of the problem.</summary>
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
|
||||
public required string Context { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
|
||||
public Guid? LitterId { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
|
||||
public Guid? ContactId { get; set; }
|
||||
|
||||
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
|
||||
public string? EntityName { get; set; }
|
||||
|
||||
/// <summary>The client URL/route the report was filed from.</summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
|
||||
public DateTimeOffset? ClientTimestamp { get; set; }
|
||||
|
||||
/// <summary>Optional browser user-agent for diagnostics.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ticket lifecycle status: "Open" | "NeedsInfo" | "Answered" | "Resolved" (default "Open").
|
||||
/// "NeedsInfo" = a maintainer attached a clarifying question (Rückfrage) and is waiting on
|
||||
/// the breeder; "Answered" = the breeder replied. Plain string, no FK — keeps feedback
|
||||
/// decoupled and ingest-surviving like the rest of the row.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
|
||||
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
|
||||
/// merely had an open question, got marked resolved, and were reopened (those just return
|
||||
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReopenedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
|
||||
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
|
||||
/// (DeletedAt → null) from there. null = not deleted.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optionale Kategorie/Thema (von der KI gesetzt) für Filter + Übersicht, z. B.
|
||||
/// "Genetik", "Import", "Stammbaum", "Daten", "Foto". Frei wählbar (kein FK), null = ohne.
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// War die Lösung hilfreich? (Daumen hoch/runter auf gelösten Tickets.) true = 👍,
|
||||
/// false = 👎 (löst i. d. R. ein Wiederöffnen aus), null = noch keine Rückmeldung.
|
||||
/// </summary>
|
||||
public bool? Helpful { get; set; }
|
||||
|
||||
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||
public string? Question { get; set; }
|
||||
|
||||
/// <summary>The breeder's (Züchterin) reply to the clarifying question; null until answered.</summary>
|
||||
public string? Answer { get; set; }
|
||||
|
||||
/// <summary>When the breeder answered the clarifying question; null until answered.</summary>
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// FIX-NOTE: a breeder-friendly changelog written when the ticket is resolved — plain
|
||||
/// language ("was sich für SIE sichtbar geändert hat"), no technical jargon. Shown in the
|
||||
/// "Geschlossen" view. null while the ticket is open / unresolved.
|
||||
/// </summary>
|
||||
public string? FixNote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AGENT-CONTEXT (INTERNAL): the maintainer/agent's working memory — findings, suspected
|
||||
/// cause, files/data touched, the plan, what it is waiting on. MAY contain ids/filenames.
|
||||
/// Returned by the DTO for tooling, but NEVER rendered in the breeder UI. null if unused.
|
||||
/// </summary>
|
||||
public string? AgentContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// THREAD/HISTORY: a JSON array of past Q&A turns so earlier rounds aren't lost when a new
|
||||
/// question is asked. Each entry: { "role": "maintainer" | "breeder", "text": string,
|
||||
/// "at": ISO-8601 string }. The current open exchange stays in Question/Answer; when a NEW
|
||||
/// question is set while a previous Q&A exists, the previous (question, answer) is appended
|
||||
/// here first. Plain string column (no FK) so it survives the ingest wipe. null/empty = no history.
|
||||
/// </summary>
|
||||
public string? Thread { get; set; }
|
||||
}
|
||||
}
|
||||
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
|
||||
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
|
||||
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
|
||||
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
|
||||
/// </summary>
|
||||
public class FeedbackAttachment
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
|
||||
public Guid FeedbackId { get; set; }
|
||||
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public required string ContentType { get; set; }
|
||||
|
||||
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
|
||||
public int Size { get; set; }
|
||||
|
||||
/// <summary>The raw file bytes.</summary>
|
||||
public required byte[] Data { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,13 @@ namespace GerbilManagerWebAPI.Models
|
||||
public string? ImportSource { get; set; }
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
|
||||
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
|
||||
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
|
||||
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
|
||||
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
||||
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
||||
public string? RawImportData { get; set; }
|
||||
|
||||
@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||
public string? LitterLetter { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
|
||||
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
|
||||
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
|
||||
/// Null = manually-added litter / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHME (RPRO3 getback_tb): ein bereits abgegebenes Tier kommt zur Züchterin
|
||||
/// zurück. Bewusst vom restlichen Modell entkoppelt — GerbilId/FromContactId sind
|
||||
/// einfache nullable Guid-Spalten (KEINE Foreign Keys), genau wie bei <see cref="Feedback"/>.
|
||||
/// Dadurch überleben Rücknahmen den Import-Re-Ingest-Wipe (IngestResolvedService löscht
|
||||
/// Gerbils/Contacts, ohne diese Tabelle anzufassen). Der erfasste GerbilName hält den
|
||||
/// Datensatz auch dann lesbar, wenn das referenzierte Tier neu eingespielt/entfernt wurde.
|
||||
/// </summary>
|
||||
public class ReturnRecord
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf das zurückgekommene Tier (_TID).</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Tiername — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? GerbilName { get; set; }
|
||||
|
||||
/// <summary>Rücknahmedatum (_ZAM).</summary>
|
||||
public DateTime? ReturnDate { get; set; }
|
||||
|
||||
/// <summary>Rücknahmepreis (_ZPREIS) — was die Züchterin bei der Rücknahme zahlte/erstattete.</summary>
|
||||
public decimal? ReturnPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprünglicher Abgabepreis (_PREIS), zur Historie mitgeführt.</summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprüngliches Abgabedatum (_AM), zur Historie mitgeführt.</summary>
|
||||
public DateTime? OriginalSaleDate { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf den Kontakt, von dem das Tier zurückkam (_ABN).</summary>
|
||||
public Guid? FromContactId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Kontaktname — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? FromContactName { get; set; }
|
||||
|
||||
/// <summary>Freitext-Grund/Notiz zur Rücknahme (_BEM).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-seitige Anlagezeit.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal file
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
|
||||
/// pipeline for an animal — verfügbar (available) → reserviert (reserved) → abgegeben
|
||||
/// (handed over). This is the *informal status* that precedes the legal sale contract
|
||||
/// (SaleContract) — it does NOT replace it.
|
||||
///
|
||||
/// Deliberately decoupled from the rest of the model (same pattern as Feedback):
|
||||
/// GerbilId and ReservedForContactId are plain nullable Guid columns, NOT enforced
|
||||
/// foreign keys, so the import re-ingest wipe (IngestResolvedService) can delete and
|
||||
/// recreate gerbils/contacts without deleting or breaking reservation rows. The
|
||||
/// captured GerbilName/ContactName keep the row human-readable after a wipe.
|
||||
/// </summary>
|
||||
public class SaleReservation
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the animal this status is about.</summary>
|
||||
public Guid GerbilId { get; set; }
|
||||
|
||||
/// <summary>Captured animal name (survives an ingest wipe).</summary>
|
||||
public string? GerbilName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Status: "verfuegbar" | "reserviert" | "abgegeben". Plain string, no FK —
|
||||
/// keeps the row decoupled and ingest-surviving.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "verfuegbar";
|
||||
|
||||
/// <summary>Loose reference (no FK) to the reserving/receiving contact (Interessent/Abnehmer), if any.</summary>
|
||||
public Guid? ReservedForContactId { get; set; }
|
||||
|
||||
/// <summary>Captured contact name (survives an ingest wipe).</summary>
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>Planned hand-over appointment (RPRO3 abgeben_tb._TERMIN).</summary>
|
||||
public DateTime? AppointmentDate { get; set; }
|
||||
|
||||
/// <summary>Agreed price (RPRO3 abstat_tb._PRICE).</summary>
|
||||
public decimal? Price { get; set; }
|
||||
|
||||
/// <summary>Free-text note (RPRO3 _BEM).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>When the animal was actually handed over (RPRO3 abstat_tb._AM); null until "abgegeben".</summary>
|
||||
public DateTime? HandedOverDate { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Server-side last-update time.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal file
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// WAITLIST (RennmausPro nachfrage_tb): a prospective buyer's standing request for a
|
||||
/// future animal with wish criteria (colour, gender). Decoupled from the rest of the
|
||||
/// model on purpose — ContactId is a plain nullable Guid column (NOT an enforced
|
||||
/// foreign key), so the import re-ingest wipe (IngestResolvedService) can delete and
|
||||
/// recreate contacts without deleting or breaking waiting-list rows. The captured
|
||||
/// ContactName keeps the entry human-readable even when no contact exists yet (or the
|
||||
/// referenced contact is gone). Same survives-the-wipe pattern as Feedback.
|
||||
/// </summary>
|
||||
public class WaitingListEntry
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the interested contact, if one exists.</summary>
|
||||
public Guid? ContactId { get; set; }
|
||||
|
||||
/// <summary>Free-text name of the interested party (used when no contact is linked).</summary>
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>Wished-for colour variety (free text / catalog name), if any.</summary>
|
||||
public string? WishColor { get; set; }
|
||||
|
||||
/// <summary>Wished-for gender: "male" | "female" | null (no preference).</summary>
|
||||
public string? WishGender { get; set; }
|
||||
|
||||
/// <summary>When the request was made.</summary>
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
|
||||
/// <summary>Workflow status: "offen" | "erfuellt" | "storniert".</summary>
|
||||
public required string Status { get; set; }
|
||||
|
||||
/// <summary>Optional free-text note.</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A browser's Web-Push subscription (PWA-Benachrichtigungen). Single-user app, daher i. d. R.
|
||||
/// nur wenige Einträge (ein Gerät der Züchterin). Endpoint ist eindeutig; veraltete Abos werden
|
||||
/// beim Senden (404/410) automatisch entfernt.
|
||||
/// </summary>
|
||||
public class WebPushSubscription
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Die vom Browser vergebene Push-Endpoint-URL (eindeutig).</summary>
|
||||
public required string Endpoint { get; set; }
|
||||
|
||||
/// <summary>Öffentlicher Client-Schlüssel (keys.p256dh).</summary>
|
||||
public required string P256dh { get; set; }
|
||||
|
||||
/// <summary>Auth-Secret des Clients (keys.auth).</summary>
|
||||
public required string Auth { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user