Compare commits

...

57 Commits

Author SHA1 Message Date
aa4552ff84 docs(triage): Genetik-Vorlauf-Notizen als Referenz für Engine-Fixes
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m8s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m55s
CI / Docker Build & Push (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:34:54 +02:00
18d74ff3f3 docs(triage): Ticket-Triage-Matrix (Lauf 56 Tickets)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:29:12 +02:00
72ad706ca6 fix(import): Eltern-/Herkunft-/Dubletten-Korrekturen — Ticket-Triage Cluster
Akane (Roni=Vater male + Mutter Fumi), Sunny (Bill+Melly), Danielle (Mutter Ella,
motherDob-Disambiguierung), Cherry Berry (female), Black-Forest-Label vereinheitlicht,
namenloser Bock entdoppelt (mergeExternalRefs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:25:43 +02:00
a66739e84d feat(import): „Was wird (nicht) importiert"-Hinweis auf der RPRO3-Import-Seite
Klarstellung für die Züchterin: übernommen werden Tiere/Stammbäume/Genotypen/
Würfe/Kontakte/Gehege+Reinigung/Erwerb/Reservierung/Rücknahmen/Warteliste/
Ausstellungen; (noch) nicht: Finanzen, Termine, Tierarzt-Stammdaten, Tagebuch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:24:59 +02:00
00dba91a5c feat(tickets): Kurz-Verweise (Shortlinks) im Ticket-Text auflösen
Die KI kann im Text `tier:<8hex>` / `kontakt:` / `wurf:` / `gehege:` schreiben;
Frontend löst per POST /refs/resolve (Präfix-Match, DB-agnostisch) zu Name +
internem Link auf. Reine KI-Schreibhilfe — kein Nutzer-Button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:23:03 +02:00
c7b054e909 feat(import): RPRO3-Importer um 6 Feature-Tabellen erweitern
becken→Gehege-Reinigung, herktier→Erwerb, abgeben/abstat→Reservierung,
getback→Rücknahmen, nachfrage→Warteliste, ausz→Ausstellungen.
Deterministische GUIDs, idempotenter Re-Import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:05:01 +02:00
00dc724605 test(e2e): Gewicht-Tab-Button im Tabpanel ansprechen (Erwerb-Sektion-Kollision)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:15:23 +02:00
156ede120e Merge branch 'worktree-agent-ae59040b377a324cb'
# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/pages/GerbilDetailPage.tsx
#	gerbil-manager-web/src/strings/de.ts
2026-06-22 23:10:18 +02:00
db2c29769e Merge branch 'worktree-agent-a02f88e8490625846'
# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/App.tsx
#	gerbil-manager-web/src/components/AppShell.tsx
#	gerbil-manager-web/src/strings/de.ts
2026-06-22 23:07:22 +02:00
ae7d171131 Merge branch 'worktree-agent-a0e6f31173772c798'
# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/strings/de.ts
2026-06-22 23:02:17 +02:00
6a64698601 Merge branch 'worktree-agent-a36d202be311b9289'
# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
2026-06-22 22:57:16 +02:00
1469a7f3dc Merge branch 'worktree-agent-a5f48334696288159'
# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
2026-06-22 22:54:50 +02:00
6123b8ef9e Merge branch 'worktree-agent-a9d8edbd439109a45' 2026-06-22 22:53:33 +02:00
0b0ebd5fad Merge branch 'worktree-agent-a1dfa0392b5ace900'
# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/App.tsx
2026-06-22 22:53:10 +02:00
4ec0f787dd feat(tickets): Markdown in Texten + Editier-Regeln für Rückfragen
- Ticket-Texte (Frage/Antwort/Nachricht/Changelog/Verlauf) rendern eine
  Markdown-Teilmenge: **fett**, `code`, Aufzählungen, Links, Tier-/Ticket-IDs
- Offene Rückfrage (NeedsInfo): nicht bearbeitbar — die Züchterin soll antworten
- Beantwortet (Answered): Antwort nachträglich änderbar, bevor die KI übernimmt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:51:00 +02:00
d380a0c2ef feat(ausstellungen): Ausstellungs-/Auszeichnungsergebnisse je Tier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:19 +02:00
40c92c70d4 feat(abgabe): Reservierungs-/Abgabe-Status (verfügbar/reserviert/abgegeben)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:19 +02:00
4e7cd21b70 feat(gehege): Reinigungszyklus (Maße, Kapazität, letzte/nächste Reinigung)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:19 +02:00
3ad276637c feat(import): RennmausPro-III-Backup-Importer (analyze+execute, Dedup, Fotos)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:19 +02:00
b4dc37de78 feat(ruecknahmen): zurückgenommene Tiere erfassen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:18 +02:00
d25d2ee152 feat(warteliste): Nachfrage/Warteliste für Interessenten
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:18 +02:00
55e79cda51 feat(tier): Erwerb/Kauf je Tier (Datum, Preis, Notiz)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:18 +02:00
23f028123d fix(tickets): beantwortete Rückfragen stehen wieder unter „Offen"
Bisher landeten beantwortete Tickets (Answered) in „Rückfragen". Sobald die
Züchterin geantwortet hat, ist sie fertig → das Ticket gehört in „Offen"
(von der KI zu erledigen). „Rückfragen" zeigt jetzt nur noch NeedsInfo
(wartet auf ihre Antwort).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:42:04 +02:00
4e30dc7fe3 feat(import): RennmausPro-III-Analyse — Abgleich, Dedup, Feature-Gap, Ticket-Generierung
- compare_rpro3.py: RPRO3-SQLite ↔ aktueller Stand (Dedup Name+DOB+Farbe+Herkunft)
- rpro3-vergleich.md / rpro3-feature-gap.md: erzeugte Reports
- rpro3_tickets.py / rpro3_post_tickets.py: 413 Rückfrage-Tickets mit Herkunft + Links

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:38:24 +02:00
c15dd78bc7 feat(pwa): installierbar als App-Icon (Manifest + Icons)
Web-App-Manifest, App-Icons (192/512 + maskable, Rennmaus-Motiv) und
apple-touch-icon/theme-color in index.html → "Zum Startbildschirm hinzufügen".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:38:24 +02:00
dcf0e40f9b feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung
- Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf);
  3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen)
- Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=),
  Markdown-Links; Antwort-Entwurf pro Ticket in localStorage
- "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf)
- entityName als Hyperlink zum referenzierten Datensatz

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:38:23 +02:00
b9021fe577 chore: Postman-Collection entfernt, GerbilERM.drawio nach docs/ verschoben
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:17:04 +02:00
ce1704c3b7 fix(import): Stammbaum-/Eltern-/isResident-Tickets der Züchterin (18)
Import-Logik:
- Externe Gründer (Zooladen/„von Privat"/„from …, <Land>") bekommen keine
  erfundenen Chart-Eltern mehr → Bill, Cooky, Zadar haben korrekt unbekannte
  Eltern (is_external_origin). [#5,#13,#28]
- Hagrid: externe Zucht wird nicht mehr als Bestand markiert (isResident=false)
  + Leerhüllen-Dedup → ein Datensatz mit Eltern Snickers × Milka. [#17,#18,#20a]
- Gender-Index: eindeutiges Geschlecht schlägt unbekanntes Duplikat → Rollen-
  Auflösung repariert (Vance→Mutter Enya, Zac→Vater Vance/Mutter Dorie). [#33,#35]
- „Eltern: X + Y"-Wurfnotizen werden geparst (~46 Würfe); v.d.↔von-den-Namens-
  kanon (Theodore→BlackFire). [#9,#11,#30]

Daten-Overrides (conflict-decisions.json, jetzt auch Gender + exakte Eltern):
- Mozart→weiblich [#2], Yuki=Camaro×Izumi [#23], Gold-Mutter=Chelsea [#36],
  Arya=Vance×Sansa (Geschwisterverpaarung) [#16], Tony→Sammy [#12],
  Odelia [#15], Jamie→Danny [#31], Silver=Taro×Beatrice [#11].

Frontend: GerbilDetailPage blendet für isResident=false Würfe + Charakter aus
(Hinweis nonResidentNote). [#17,#20b]

Tests: test_extract/test_merge_resolve erweitert; vitest 135, playwright tiere 34 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:16:53 +02:00
9c6ca2118a docs(skill): internes agentContext-Feld am Ticket (Züchterin-unsichtbar)
Da Subagenten sitzungsgebunden sind, dient ein internes, in der Züchterin-UI
NICHT sichtbares Feld `agentContext` als Arbeitsgedächtnis: der Agent legt dort
Befunde/Ursache/Plan/„worauf gewartet wird"/erledigte Teilschritte ab und setzt
beim Wiederaufgreifen (Answered) genau dort an — statt neu zu untersuchen.
Trennung: agentContext technisch/intern; Frage/Antwort/fixNote bleiben
laienverständlich. PUT {agentContext} aktualisiert ohne Status-Wechsel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:14:47 +02:00
8bfa2a7911 docs(skill): Mehrrunden-Rückfragen + verständlicher Laien-Changelog
- Mehrrundiger Dialog (Open→NeedsInfo→Answered→…→Resolved): jede weitere Antwort
  bringt das Ticket wieder in Bearbeitung. Kontext-Träger ist das TICKET (voller
  Verlauf + Triage-Eintrag), nicht der Subagent (sitzungsgebunden) — ein
  wiederaufgreifender Agent rekonstruiert denselben Kontext; echte Agent-
  Fortsetzung nur innerhalb einer Session via SendMessage. Datenmodell:
  Frage/Antwort als Thread (mehrere Runden), nicht nur ein Paar.
- fixNote-Changelog: harte Regel „nur für die Züchterin, kein Technik-Geschwafel"
  + Gut/Schlecht-Beispiele (Tiernamen statt IDs, keine Datei-/Feldnamen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:13:51 +02:00
5edd84046d docs(skill): Triage-Subagenten schließen Tickets terminal ab (Changelog/Rückfrage)
Skill präzisiert: jeder bearbeitende Subagent schließt seinen Vorgang selbst ab —
Rückfrage ordentlich stellen (eine konkrete Frage, einfache Sprache, Kontext →
NeedsInfo) ODER nach verifiziertem + eingespieltem Fix mit verständlichem
fixNote-Changelog schließen (Resolved). Abschluss-Regel ergänzt: kein
Open/Answered-Ticket bleibt offen liegen — Endzustand immer NeedsInfo oder Resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:12:03 +02:00
d27a389f0e feat(skill): ticket-triage — Fehlerberichte automatisiert abarbeiten
Skill kapselt den erprobten Ablauf: Tickets aus /feedback holen, nach Status
einteilen (Open/Answered = KI dran, NeedsInfo/Resolved überspringen), je Ticket
ein Triage-Subagent (Workflow-Fan-out) mit strukturierter Empfehlung + Cluster,
Rückfragen via PUT question (→ NeedsInfo) anhängen, beantwortete Tickets wieder
aufgreifen, Fixes gebündelt nach datei-disjunkten Clustern umsetzen, Import neu
einspielen und Tickets MIT fixNote (verständlicher Changelog) auf Resolved setzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:07:04 +02:00
b437d09312 fix(genetics): Genotyp-/Farbschlag-Engine-Bugs aus Ticket-Triage
- uw/Uwuw[d] (dichtes Underwhite) wird vor dem uw→G-Locus-Alias korrekt geparst
  (Vance: nicht mehr „Unbekannter Farbschlag", zeigt G-Locus statt international uw). [#34,#32]
- ee[-] = sichtbarer Fuchs (rezessiv homozygot) → korrekt als Fuchs; bare „e-"
  (rezessiv + unbekannt) ist genetisch unmöglich → genotypeInvalid statt still falsch. [#42]
- aa-Colourpoint-Zweig berücksichtigt jetzt D (Dilute) und E (Fuchs), statt fix
  Zobel/Marder/Siam zu erzwingen. [#3]
- Wurf-Farbprognose: unbekanntes Allel (?) wird nicht mehr über die volle Dominanz
  expandiert → keine unmöglichen Dilute-/Schimmel-/„Unbekannt"-Nachkommen. [#37,#39,#40,#41,#38]

Tests: test_genotype.py erweitert; genetics.test.ts +5 Blöcke (135 vitest grün, tsc/eslint sauber).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:04:05 +02:00
5069c0eb1e feat(tickets): Rückfrage am Ticket + Antwort durch die Züchterin
Tickets können jetzt eine Rückfrage (Question) tragen und die Züchterin kann
in-app antworten. Feedback um FK-freie Felder Question/Answer/AnsweredAt erweitert
(überlebt Ingest-Wipe); Status-Lebenszyklus Open → NeedsInfo (Rückfrage gestellt)
→ Answered (beantwortet) → Resolved. Migration AddFeedbackQuestionAnswer.

PUT /feedback/{id}: question → NeedsInfo, answer → Answered+AnsweredAt; DTO gibt
die Felder zurück. Tickets-Seite (/hilfe/tickets) zeigt die Rückfrage hervorgehoben
und bietet ein Antwort-Feld + „Antworten"; Status-Badges Offen/Rückfrage offen/
Beantwortet/Gelöst.

Tests: FeedbackEndpointTests (Frage→NeedsInfo, Antwort→Answered, übersteht Ingest),
e2e tickets.spec.ts. dotnet/vitest/playwright grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:52:34 +02:00
f1c1069821 docs: Triage aller 42 Fehlerberichte (Empfehlungen + Cluster-Matrix)
Automatische Triage (ein Subagent pro Ticket): pro Ticket Ursache, Empfehlung,
Fix-Ebene, betroffene Dateien, Aufwand, Konfidenz + Überschneidungs-Matrix nach
Cluster und offene Rückfragen an die Züchterin. Grundlage für die gebündelte
Implementierung nach Cluster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:42:50 +02:00
3f31a2efbc chore(memory): MemPalace automatisch einbinden + Memory-Policy in CLAUDE.md
.claude/settings.json (committet) verdrahtet die MemPalace-Hooks
(session-start lädt Kontext, stop/precompact persistieren) → künftige Claude-
Sessions in diesem Ordner nutzen MemPalace automatisch (zusammen mit .mcp.json,
das die 19 MCP-Tools bereitstellt; aktiv nach Claude-Neustart).

CLAUDE.md: Abschnitt „Gedächtnis (MemPalace)" + Memory-Policy (WANN/WIE/WO eine
Memory angelegt wird: durable & nicht-offensichtlich → Palast; kanonisch &
projektweit → zusätzlich CLAUDE.md; Veraltetes per kg_invalidate beenden statt
löschen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:10:34 +02:00
4c0cba854a chore(mcp): MemPalace-MCP-Server im Repo registrieren (.mcp.json)
Bindet den lokal installierten MemPalace-Server (mempalace-mcp) als projekt-
gescopten MCP-Server ein, damit das KI-Gedächtnis ab der nächsten Claude-Session
verbunden ist und die Integration im Repo (geteilt/versioniert) statt nur lokal
liegt. Aktiviert nach Claude-Neustart (Bestätigung des MCP-Servers nötig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:03:11 +02:00
ac06cadb23 feat(hilfe): Eigene Tickets — Fehlerberichte ansehen, bearbeiten, löschen, lösen
Unter Hilfe (/hilfe/tickets) eine Verwaltung der eingereichten „Fehler melden"-
Berichte: Liste (neueste zuerst) mit Status-Badge (Offen / ✓ Gelöst), Kontext-
Label, betroffenem Objekt, Nachricht und Datum. Pro Ticket: als gelöst markieren/
wieder öffnen, Nachricht inline bearbeiten, löschen (mit Bestätigung).

Backend: Feedback um Status ("Open"/"Resolved", Default Open) + nullable ResolvedAt
erweitert (weiterhin FK-frei → übersteht Ingest-Wipe); Migration AddFeedbackStatus.
Endpoints: GET /feedback (neueste zuerst, inkl. Status), PUT /feedback/{id}
(Nachricht/Status; leer→400, fehlt→404; lösen setzt/öffnen löscht ResolvedAt),
DELETE /feedback/{id} (fehlt→404).

Tests: FeedbackEndpointTests (CRUD + Status-Lebenszyklus + übersteht Ingest),
e2e tickets.spec.ts; dotnet 215, vitest 129, playwright grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:52:50 +02:00
e1299a1a63 docs: CLAUDE.md mit Architektur, Import-Workflow, Konventionen & Domänenwissen
Leitfaden für künftige Arbeit: Projekt-/Architektur-Überblick, Start über Aspire
(Ports/Dashboard/persistentes Volume), 3-stufiger Import→DB-Workflow (Ingest
wischt+lädt, Feedback überlebt), Build/Test je Schicht, Konventionen (de.ts,
Migrationen, Provenance-JSON, FK-freie Survivor-Spalten), Domänenwissen
(Lebensspanne/Geschwisterverpaarung/Genotyp/Chart-Layout), Stolperfallen
(Aspire-Container vs. Volume, Migrations-Historie-Mismatch, psql-Quoting) und
eine Feature-Landkarte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:44:18 +02:00
5175c3229a feat(import): Abgabeverträge als SaleContract-Datensätze importieren
Die ~1095 geparsten Verträge werden jetzt als echte SaleContract-Records erzeugt,
sodass die Verträge-Seite gefüllt ist (statt 0). enrich_from_contracts() liefert
zusätzlich eine saleContracts-Liste (deterministische Id aus Dateiname, Käufer-
ContactId, Preis, Abgabe-/Vertragsdatum, FileName, gematchte Tier-Ids); main()
schreibt sie als Top-Level-Key in resolved_import.json. IngestResolvedService legt
SaleContract + SaleContractAnimal an (FK-sicher nach Kontakten/Tieren, unbekannte
Links übersprungen) — Tabelle wird wie gehabt gewischt und aus dem Payload neu
befüllt (idempotent).

Ergebnis: 1033 Verträge (611 mit ≥1 Tier, alle datiert; 54 Dateinamen-Dubletten
zusammengefasst, 7 datumlose + 1 ohne Käufer übersprungen). Kein Schema-Change
(datumlose übersprungen statt Spalten nullable → keine Migration).

DOCX-Download: importierte Verträge haben keine Word-Datei im contract-storage →
DTO.HasFile=false, Frontend blendet den Word-Button aus (Hinweis „Keine Word-
Datei"), PDF wird weiterhin generiert. Download-Endpoint liefert sauber 404 statt
500 bei fehlender Datei.

Tests: dotnet 213, vitest 129, playwright 16 (neuer vertraege.spec.ts), Python grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:33:58 +02:00
5e14124322 feat(import): Abgabeverträge (DOCX) auswerten und Tiere/Kontakte anreichern
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m18s
Neuer Parser extract_contracts.py liest die ~1,4k Abgabevertrags-DOCX
(\truenas\…\Verträge): er extrahiert aus dem Dokument-Body (zuverlässiger als
die Dateinamen) Käufer, Tier(e), Farbschlag, Abgabedatum und Preis — robust
gegen Word-Run-Splits (z. B. „F r au"/„3 0,00"); überspringt Vorlage,
Abstammungsnachweise und als .docx getarnte .doc.

enrich_from_contracts() in merge_and_resolve.py: Käufer werden als Kontakte
(IsReceiver) angelegt/zusammengeführt; Tiere werden KONSERVATIV per Rufname
(+ DOB-Jahr bei Mehrdeutigkeit) auf eigene Bestandstiere gematcht und erhalten
ReceiverContactId, GoHomeDate und Status „abgegeben" — nur wo nicht bereits
gesetzt; Konflikte werden geloggt, nicht überschrieben. Jede Übernahme bekommt
eine Herkunfts-Zeile („Abgabe an … aus Vertrag … übernommen.").

Ergebnis: 1095 Verträge → 783 Tier-Treffer (400 mehrdeutige übersprungen),
274 neue Abnehmer-Kontakte, 153 Tiere mit Abnehmer, 49 mit Abgabedatum,
23 neu „abgegeben". Keine Backend-/Frontend-Änderung nötig (Akte zeigt Abnehmer/
Abgabedatum/Herkunft bereits). SaleContract-Records bewusst nicht erzeugt
(bräuchte Migration + ingest-sichere Id — späterer Schritt).

Tests: test_extract_contracts.py (Dateiname/Body/Run-Split/Skip-Regeln) + alle
bestehenden grün; dotnet 212.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:53:47 +02:00
aa473b764e feat(provenance): verworfene Daten mit Grund + Ersatz im Verlauf zeigen
Der Herkunfts-Verlauf nennt jetzt auch, wenn ein Wert aus einer Quelle VERWORFEN
wurde — mit Grund und was stattdessen verwendet wurde (⚠-Markierung), z. B.:
- „⚠ Vater-Kandidat ‚Danielle' (*04.03.2020) verworfen — falsches Geschlecht für
  die Vaterrolle; ‚Hagrid Rubeus' verwendet." (Molly)
- „⚠ Geschlecht weiblich aus ‚Wurfchronik.docx' verworfen — abweichend; männlich
  aus ‚Alberto Kids.xlsx' verwendet (Mehrheit)." (Solice)
- „⚠ Vater ‚Jasper Jacob' (*2018) verworfen — unplausibel (7 Jahre älter); kein
  Ersatz." / DOB-Remap: „⚠ Geburtsdatum 22.08.2018 verworfen — per manueller
  Entscheidung korrigiert; 14.07.2019 verwendet." (Kazuya)

Import: generischer _format_discard()-Helfer; Verwerfungen aus Mehrheitsentscheid,
pick_parent_ref-Ablehnungen (explain_pick_rejections), Rollen-Normalisierung,
Eltern-Alter-Sanity-Check und DOB-Remaps werden über _discarded/_prov_args an die
Tiere gehängt; Gerbil-Provenance wird in einem finalen Pass gebaut (nachdem alle
Verwerfungen feststehen). Frontend hebt ⚠-Zeilen ab (+ Legende).

Tests grün (python merge/extract, vitest 129, playwright 8, dotnet 212).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:34:33 +02:00
2befe53df9 feat(stammbaum): Desktop-Ansicht passt den ganzen Baum ein + höhere Zeichenfläche
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m3s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m30s
Auf dem Desktop wurde der Baum bei festem Zoom 1 rechts abgeschnitten (man sah
nur 1–2 Generationen, der Rest lag außerhalb) und viel Fläche blieb leer. Die
Standard-Ansicht passt jetzt den gesamten geladenen Baum ein — gedeckelt auf
Zoom 1, damit Karten bei kleinen Bäumen nicht vergrößert/unscharf werden. Mobil
bleibt es bei „Wurzel links + Pan" (sonst würden die Karten winzig). Zeichen-
fläche von 62dvh auf 72dvh erhöht (füllt die Desktop-Höhe besser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:26:50 +02:00
cf672c0933 feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus
welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte.
Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in
‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)."

Import: build_entity_provenance() um history[] erweitert; ein field_source-
Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld
(DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill
und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei-
attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON).

Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline.
Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212,
vitest 129, playwright 17, python merge/extract grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:21:24 +02:00
a31fea17fa feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen
Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:09:47 +02:00
438c816820 feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern
- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:02:11 +02:00
9c98c37d2a feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus
Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:45:15 +02:00
b9e5b031c4 feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)
Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:30:42 +02:00
1845d37476 fix(import): Eltern-Ref-Auswahl geschlechts-bewusst machen (Regression behoben)
Die in 03ad9e6 eingeführte DOB-Bevorzugung war zu grob: ein datierter, aber
geschlechts-falscher Ref (z. B. weibliche „Danielle") gewann den Vater-Slot
gegen einen undatieren männlichen/unbekannten (z. B. „Hagrid Rubeus") — wodurch
Mollys korrekte Mutter Arya Stark in der Rollen-Normalisierung verloren ging.

pick_parent_ref wertet jetzt zuerst das (aus den Tierdaten ermittelte) Geschlecht
für die Rolle: ein klar falsches Geschlecht wird stark abgewertet, erst danach
zählt plausibles DOB > kein DOB > unplausibles DOB. Fixt Molly (Hagrid × Arya)
UND behält Solice (Lui × Molly = Geschwister von Silvain). Keine Abdeckungs-
Regression (0 Selbst-Verpaarungen / 0 Gender-Fehler / 0 Alter-Verletzungen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:53 +02:00
2868aad369 feat(stammbaum): gleiches Geburtsdatum der Eltern als Geschwister-Indiz werten
Ein identisches Geburtsdatum bei Vater und Mutter ist ein starkes Indiz für eine
Vollgeschwister-Verpaarung — ein gemeinsamer Wurf ist praktisch der einzige
Grund dafür, und die Eltern-Würfe sind in den importierten Daten nicht immer
verknüpft (gleiche litterId fehlt dann).

Die Geschwister-Erkennung (Diagramm-Zusammenführung in build.ts + Akte-Chip in
GerbilDetailPage) greift jetzt bei gleicher litterId ODER gleichem, gesetztem
Geburtsdatum beider Eltern (distinkte Tiere vorausgesetzt).

Tests: gleiches DOB bei fehlender/verschiedener litterId → erkannt; unterschied-
liches DOB → nicht erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:02:19 +02:00
03ad9e6e81 fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs
Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:59:34 +02:00
18efab6996 fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben
Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:49:38 +02:00
070a896072 feat(rennmausakte): Hinweis-Chip für Geschwisterverpaarung in der Tierakte
Stammt ein Tier aus einer Vollgeschwister-Verpaarung (Vater und Mutter teilen
dieselbe litterId), zeigt die Rennmausakte jetzt einen Chip
"⚭ Geschwisterverpaarung" in der Fakten-Zeile — neben Geschlecht, Geburtsdatum
und Farbschlag, in Akzentfarbe hervorgehoben (zuchtrelevant).

e2e-Test: Chip erscheint für 'Inzucht Kind' (Eltern aus demselben Wurf),
nicht für 'Krümel' (Eltern aus verschiedenen Würfen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:36 +02:00
3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:02:45 +02:00
cb2ee03a14 fix: Naho Eltern + Foto-Layout-Erkennung + Eltern-Fallback-Lookup
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Failing after 9s
2026-06-21 22:55:50 +02:00
41ff973f9a feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s
2026-06-21 22:23:59 +02:00
d2993418b6 fix(import): remap Kazuya DOB and correct parents using Stammbaum von Kazuya.xlsx
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:18:04 +02:00
172 changed files with 40333 additions and 425 deletions

35
.claude/settings.json Normal file
View 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"
}
]
}
]
}
}

View 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; 13 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
View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp",
"args": []
}
}
}

203
CLAUDE.md Normal file
View File

@@ -0,0 +1,203 @@
# CLAUDE.md — GerbilManager
Leitfaden für die Arbeit an diesem Repo. Bitte vor Änderungen lesen.
## Was ist das?
Verwaltungs-App für eine **Rennmaus-Zucht** („Zucht der kleinen Chaoten"): Tiere,
Würfe, Stammbäume/Ahnentafeln, Genetik (Farbschläge/Genotypen), Gehege, Kontakte
(Züchter/Abnehmer), Abgabeverträge, Statistik und eine öffentliche CMS-Webseite.
Single-User (die Züchterin), Daten größtenteils aus bestehenden Quelldateien
importiert.
## Architektur / Projekte
| Projekt | Zweck |
|---|---|
| `GerbilManager.AppHost` | **.NET Aspire** Orchestrator — startet PostgreSQL (Container) + WebAPI |
| `GerbilManagerWebAPI` | Backend: ASP.NET **Minimal API**, EF Core + Postgres, Endpoints in `Endpoints/`, Entities in `Models/`, DTOs in `Dtos/` |
| `GerbilManager.ServiceDefaults` | Aspire-Defaults (Telemetry/Health) |
| `GerbilManager.Tests` | Backend-Tests (xUnit) |
| `gerbil-manager-web` | Frontend: **React + Vite + TypeScript** (React Router, react-d3-tree für den Stammbaum) |
| `tools/import` | **Python**-Import-Pipeline (zero-dep), wandelt Quelldateien → `resolved_import.json` |
## App starten (IMMER über Aspire, nicht nur die WebAPI)
```bash
dotnet run --project GerbilManager.AppHost
```
- WebAPI: http://localhost:5179 · OpenAPI: `/openapi/v1.json`
- Aspire-Dashboard: https://localhost:17004 (Login-Token steht im AppHost-Log)
- EF-Migrationen werden beim Start automatisch angewandt (`db.Database.Migrate()` in `Program.cs`).
- Postgres läuft als Container (Name wechselt pro Run, z. B. `postgres-xxxx`); die
**Daten liegen in einem persistenten Volume** → überleben Neustarts. DB-Name: `gerbilmanager`.
## Import → DB (3-stufig, WICHTIG)
Die DB wird **nicht** direkt von Python beschrieben. Nach jeder Import-Code-Änderung
neu generieren UND neu einspielen, sonst zeigt die App den alten Stand.
```bash
# 1. Stammbäume + Wurfchronik → animals.json/litters.json (+ Fotos)
python tools/import/extract.py
# 2. Abgabeverträge (DOCX) → contracts.json
python tools/import/extract_contracts.py
# 3. Dedup, Eltern-Resolver, Provenance, Vertrags-Anreicherung → resolved_import.json
python tools/import/merge_and_resolve.py
# 4. In die laufende DB laden (API muss laufen): WISCHT + lädt neu
curl -X POST http://localhost:5179/import/ingest-resolved
```
- **`IngestResolvedService` wischt** Gerbils/Litters/Contacts/Photos/Health/Weights/
**SaleContracts** und lädt aus `resolved_import.json` neu. Importierte SaleContracts
sind Teil des Payloads → werden bei jedem Ingest neu erzeugt (idempotent, IDs deterministisch).
- **Feedback überlebt den Ingest** (wird nicht gewischt) — daher hat `Feedback` nur
**lose, nullable Guid-Spalten ohne FK** (GerbilId/LitterId/ContactId). Dieses Muster
für alles nutzen, was Re-Ingests überleben soll.
- `resolved_import.json` ist **gitignored** (Runtime-Output); `review-report.md` ist getrackt.
- Deterministische GUIDs via `generate_guid("...")` → stabile IDs über Re-Ingests.
### Quelldateien (lokal/Netzwerk)
- Stammbäume: `C:\Users\gulum\dev\Sttammbäume\*.xlsx`
- Wurfchronik: `C:\Users\gulum\dev\Wurfchronik der Kleine Chaoten Teil1.xlsx`
- Abgabeverträge: `\\truenas\Datengrab\Rennmäuse\Verträge\*.docx` (~1,4k; kann zeitweise offline sein)
## Build & Test
**Frontend** (`cd gerbil-manager-web`):
```bash
npx tsc --noEmit # Typecheck (muss sauber sein)
npx vitest run # Unit-Tests
npx eslint <dateien> # Lint
npx playwright test [spec]# e2e (Projekte: desktop + phone), läuft gegen Mock-API
```
- e2e nutzt einen **Mock-Modus** (`e2e/mock-api.ts` + `e2e/mock-data.ts`), kein echtes Backend.
Neue API-Routen/Felder dort spiegeln. `skipUnlessMock()` am Testanfang.
**Backend**:
```bash
dotnet build
dotnet test # GerbilManager.Tests
dotnet ef migrations add <Name> --project GerbilManagerWebAPI # neue Migration
```
- Zum Bauen muss eine laufende WebAPI ggf. gestoppt werden (Datei-Lock auf `bin`).
**Python** (`cd tools/import`, zero-dep, exit 0 = pass):
```bash
python test_extract.py test_extract_docx.py test_genotype.py test_merge_resolve.py test_extract_contracts.py
```
## Konventionen (bitte einhalten)
- **Alle deutschen UI-Texte in `gerbil-manager-web/src/strings/de.ts`** — niemals in
Komponenten hartkodieren.
- **EF-Migration** für jede Schema-Änderung; wird beim Aspire-Start angewandt.
- **Provenance/Datenherkunft** liegt als **JSON-Text-Spalte** (`Provenance`) auf
Gerbil/Contact/Litter → JSON-Feld-Erweiterungen brauchen **keine** Migration.
- **Lose nullable Guid-Spalten ohne FK** für Tabellen, die den Ingest-Wipe überleben.
- Commits: nur wenn der Nutzer es verlangt; auf `main` wird gearbeitet (Remote: `http://truenas:13000/Gulum/GerbilManager.git`). Commit-Messages auf Deutsch, Format `feat(scope): …`.
- Subagenten committen NICHT — Haupt-Loop verifiziert und committet.
## Domänenwissen (Rennmaus-Zucht)
- **Lebensspanne ~6 Jahre** → ein Elternteil kann max. ~6 Jahre älter als sein Kind
sein und muss vorher geboren sein (`parent_age_plausible` im Import; verwirft
unplausible Eltern-Links).
- **Geschwisterverpaarung** (Vollgeschwister als Eltern): erkannt, wenn Vater & Mutter
**dieselbe `litterId`** ODER **dasselbe Geburtsdatum** teilen (gleiches DOB = starkes
Indiz). UI: Diagramm führt den Vorfahren-Ast zusammen (Verweis-Knoten); Akte zeigt
„⚭ Geschwisterverpaarung"-Chip.
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel.
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
geringste Spalten-Fehlausrichtung bestimmt (`extract._attach_photos`).
## Stolperfallen
- **Aspire-Containername wechselt** pro Run, das Daten-Volume bleibt → DB-Inhalt
überlebt Neustarts (auch alte Schema-Stände → Migrationskonflikte möglich, s. u.).
- **Migrations-Historie vs. committete Migration-IDs**: Wird eine Migration angewandt
und danach mit neuem Zeitstempel neu generiert, sieht EF sie als „pending" und der
Start crasht („column … already exists"). Schema ist dann korrekt — nur die
`__EFMigrationsHistory`-IDs müssen an die committeten Dateinamen angeglichen werden.
- **PowerShell + psql**: PascalCase-Identifier (`"__EFMigrationsHistory"`) über
PowerShell→docker→psql zerschießen das Quoting. Stattdessen Bash-Heredoc:
`docker exec -i -e PGPASSWORD=$PW <container> psql -U postgres -d gerbilmanager <<'SQL' … SQL`.
Passwort: `docker exec <container> printenv POSTGRES_PASSWORD`.
- **Direkte DB-Schreibzugriffe** (psql) werden vom Auto-Mode-Classifier blockiert —
vorher beim Nutzer rückversichern.
- Bash-Tool ist **Git-Bash (POSIX)**, das andere Tool ist **PowerShell** — je eigene Syntax.
## Wo liegt was (Feature-Landkarte)
- **Stammbaum-Viewer**: `gerbil-manager-web/src/pages/StammbaumPage.tsx`,
`src/pedigree/*` (build.ts = Baum-Aufbau + Geschwister-Erkennung; types.ts).
Desktop passt den ganzen Baum ein; Touch: Long-Press öffnet das Kontextmenü
(ID kopieren / Fehler melden).
- **Datenherkunft/Nachverfolgung**: `src/components/ProvenanceDialog.tsx`; erzeugt im
Import via `build_entity_provenance`/`_build_*_history` (chronologische, datei-
attribuierte `history[]`, inkl. „⚠ … verworfen — Grund; … verwendet").
- **Fehler melden (Feedback)**: `src/components/ReportErrorDialog.tsx`,
`src/api/feedback.ts`, Backend `Endpoints/FeedbackEndpoints.cs` + `Models/Feedback.cs`.
- **Verträge**: `Models/SaleContract.cs`, `Endpoints/ContractEndpoints.cs`,
`ContractGenerator.cs`, Frontend `src/pages/Vertraege*`; Import: `extract_contracts.py`
+ `enrich_from_contracts` in `merge_and_resolve.py`.
- **Import-Kernlogik**: `tools/import/merge_and_resolve.py` (Dedup, Eltern-Resolver mit
Gender-/Alters-Plausibilität, Litter-/Sibling-Dedup, Provenance, Vertrags-Anreicherung).
## Verifikations-Checkliste vor „fertig"
1. `npx tsc --noEmit`, `npx vitest run`, `npx eslint` sauber.
2. Betroffene `npx playwright test`-Specs grün.
3. `dotnet build` + `dotnet test` grün.
4. Bei Import-Änderungen: `python test_*.py` grün + `resolved_import.json` plausibel
(Stichprobe), danach App neu starten + `POST /import/ingest-resolved`.
## Gedächtnis (MemPalace) — IMMER nutzen
Dieses Projekt nutzt **MemPalace** als KI-Langzeitgedächtnis (lokal, kein API-Key).
Die Anbindung ist im Repo persistiert und lädt automatisch:
- `.mcp.json` (committet) registriert den MCP-Server `mempalace-mcp`**19 MCP-Tools**
(`mempalace_search`, `mempalace_kg_query/add/invalidate/timeline`, `mempalace_add_drawer`,
`mempalace_diary_*` …). Aktiv ab Claude-Neustart (MCP-Server einmal bestätigen).
- `.claude/settings.json` (committet) verdrahtet die Hooks
`mempalace hook run --hook {session-start,stop,precompact} --harness claude-code`:
**SessionStart** lädt relevanten Kontext automatisch, **Stop/PreCompact** persistieren.
- Voraussetzung: `mempalace` ist installiert und auf PATH (`~/.local/bin`). Nach `git clone`
ggf. MemPalace installieren; `mempalace.yaml` (Raum-Config) ist bewusst **gitignored**
(per-project, lokal). Der Palast selbst liegt lokal unter `~/.mempalace` (nicht im Repo).
**Arbeitsweise pro Session:** zu Aufgabenbeginn relevantes Wissen abrufen
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
durable Erkenntnisse ablegen.
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
künftige Sessions braucht — Architektur-/Design-Entscheidungen und ihr *Warum*, Domänenregeln
(z. B. 6-Jahre-Lebensspanne, Geschwisterverpaarung), Konventionen, Stolperfallen, bestätigte
Nutzer-Präferenzen, wiederkehrende Workflows.
**NICHT anlegen**: transienter Task-Status; was Code/Git/CLAUDE.md ohnehin festhalten;
Dinge, die nur für das aktuelle Gespräch zählen.
**WIE** (anlegen/pflegen):
- Fakt mit Gültigkeit → `mempalace_kg_add`. Freitext-Notiz/Erkenntnis → `mempalace_add_drawer`
(vorher `mempalace_check_duplicate`).
- Ändert/erledigt sich ein Fakt → `mempalace_kg_invalidate` (setzt End-Datum, **nicht löschen**
der Graph ist temporal). So bleibt nichts veraltet stehen.
- Repo-Dateien werden über `mempalace mine <dir>` automatisch gefilet; `mempalace sync --apply
--wing gerbilmanager` entfernt Drawers gelöschter/verschobener Dateien.
**WO** (welcher Speicher):
- **MemPalace-Palast** (`~/.mempalace`, lokal): reichhaltiges, durchsuchbares Recall — der Default
für Memories.
- **CLAUDE.md** (Repo, committet): die **kanonischen** Projektfakten/Konventionen, die geteilt und
versioniert sein müssen. Wichtige durable Erkenntnisse hier spiegeln, damit sie im Repo (nicht nur
lokal) liegen.
Faustregel: durable + projektweit kanonisch → **CLAUDE.md** *und* Palast; sonstiges durable Wissen →
Palast; veraltetes → invalidieren, nicht stehen lassen.

View 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,
};
}

View 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);
}
}

View 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 &amp; 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,
};
}

View File

@@ -0,0 +1,407 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
/// - Validation: an empty message is rejected with 400.
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_feedback_persists_row_with_debug_context()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/feedback", new
{
message = "Der Stammbaum zeigt den falschen Vater.",
context = "stammbaum",
gerbilId,
litterId = (Guid?)null,
entityName = "Krümel",
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
clientTimestamp = "2026-06-22T12:00:00Z",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// A freshly created ticket starts Open with no ResolvedAt.
Assert.Equal("Open", created.GetProperty("status").GetString());
Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind);
// GET returns it (newest first)
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("entityName").GetString() == "Krümel"
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."
&& f.GetProperty("status").GetString() == "Open");
}
[Fact]
public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete()
{
var client = _factory.CreateClient();
// Create
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Gewicht wird falsch gerundet.",
context = "gerbil-detail",
entityName = "Balu",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(id));
// List contains it
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id);
// Edit message
var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." });
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString());
Assert.Equal("Open", edited.GetProperty("status").GetString());
// Empty message is rejected
var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " });
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
// Mark resolved -> ResolvedAt is set
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Resolved", resolved.GetProperty("status").GetString());
Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind);
// Reopen -> ResolvedAt cleared
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" });
Assert.Equal(HttpStatusCode.OK, reopen.StatusCode);
var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Open", reopened.GetProperty("status").GetString());
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
// Delete -> 204, then 404 on subsequent edit/delete
var del = await client.DeleteAsync($"/feedback/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var delAgain = await client.DeleteAsync($"/feedback/{id}");
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
}
[Fact]
public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered()
{
var client = _factory.CreateClient();
// Create an open ticket.
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Wurf B hat ein falsches Geburtsdatum.",
context = "litter-detail",
entityName = "Wurf B",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet.
var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" });
Assert.Equal(HttpStatusCode.OK, ask.StatusCode);
var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString());
Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString());
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind);
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind);
// Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained.
var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." });
Assert.Equal(HttpStatusCode.OK, reply.StatusCode);
var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Answered", answered.GetProperty("status").GetString());
Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString());
Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString());
Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind);
}
[Fact]
public async Task Put_question_on_resolved_ticket_keeps_it_resolved()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" });
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
}
[Fact]
public async Task Put_resolve_with_fixNote_stores_breeder_changelog()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Resolve the ticket together with a breeder-friendly fix note (changelog).
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "Resolved",
fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
});
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
dto.GetProperty("fixNote").GetString());
Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind);
// The fix note survives a re-fetch via GET.
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("id").GetString() == id
&& f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch"));
}
[Fact]
public async Task Put_agentContext_round_trips_without_changing_status()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Setting only the internal agent context must NOT change the lifecycle status.
var put = await client.PutAsJsonAsync($"/feedback/{id}", new
{
agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
});
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Open", dto.GetProperty("status").GetString());
Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
dto.GetProperty("agentContext").GetString());
// It is returned by GET as well (for tooling) but the UI never renders it.
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("id").GetString() == id
&& f.GetProperty("agentContext").GetString()!.Contains("rootCause"));
}
[Fact]
public async Task New_question_pushes_prior_QandA_into_thread()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Round 1: maintainer asks, breeder answers.
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" });
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." });
// Round 2: a NEW question is asked while the prior Q&A exists.
var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" });
Assert.Equal(HttpStatusCode.OK, ask2.StatusCode);
var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement;
// Current open exchange = the new question, fresh (no answer yet), status NeedsInfo.
Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString());
Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString());
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind);
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind);
// The prior round was archived into the thread (oldest first): maintainer Q then breeder A.
var thread = dto.GetProperty("thread");
Assert.Equal(JsonValueKind.Array, thread.ValueKind);
Assert.Equal(2, thread.GetArrayLength());
Assert.Equal("maintainer", thread[0].GetProperty("role").GetString());
Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString());
Assert.Equal("breeder", thread[1].GetProperty("role").GetString());
Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString());
// Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds).
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." });
var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" });
var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength());
Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString());
}
[Fact]
public async Task Put_and_delete_unknown_id_return_404()
{
var client = _factory.CreateClient();
var missing = Guid.NewGuid();
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/feedback/{missing}", new { message = "x" })).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode);
}
[Fact]
public async Task Post_feedback_rejects_empty_message()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Feedback_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A feedback report referencing the gerbil + litter that the wipe will delete.
var feedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = feedbackId,
Message = "Bitte prüfen.",
Context = "gerbil-detail",
GerbilId = fatherId,
LitterId = litterId,
EntityName = "Papa",
Url = "http://localhost/rennmaeuse/papa",
CreatedAt = DateTimeOffset.UtcNow,
Status = "Answered",
ResolvedAt = null,
Question = "Welches Datum stimmt?",
Answer = "Der 2. Juni.",
AnsweredAt = DateTimeOffset.UtcNow,
FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
var contactFeedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = contactFeedbackId,
Message = "Adresse stimmt nicht.",
Context = "contact-detail",
ContactId = contactId,
EntityName = "Test Breeder",
Url = "http://localhost/kontakte/test-breeder",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal(litterId, survivor.LitterId);
Assert.Equal("Papa", survivor.EntityName);
Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe
// The new question/answer columns survive the wipe too.
Assert.Equal("Welches Datum stimmt?", survivor.Question);
Assert.Equal("Der 2. Juni.", survivor.Answer);
Assert.NotNull(survivor.AnsweredAt);
// The new fix-note / agent-context / thread columns survive the wipe too.
Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
Assert.Contains("Erste Frage?", survivor.Thread);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
Assert.Equal(contactId, contactSurvivor.ContactId);
Assert.Equal("contact-detail", contactSurvivor.Context);
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
Assert.Equal(2, await db.Feedback.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -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);
}
}
}

View 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);
}
}

View 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,
};
}

View File

@@ -0,0 +1,337 @@
using GerbilManagerWebAPI.Import.Rpro3;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests
{
/// <summary>
/// Tests für den RennmausPro-III-Importer (Reader + Dedup + Execute).
/// Die meisten Tests bauen eine hermetische Mini-SQLite im RPRO3-Schema; ein optionaler
/// Test gegen die echte entpackte DB läuft nur, wenn sie lokal vorhanden ist.
/// </summary>
public class Rpro3ImportTests
{
static Rpro3ImportTests()
{
// CP1252 für die Tests verfügbar machen (die Produktion registriert es im Reader-Ctor).
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
}
// ── Unit: Decode / JDN / Genotyp ──────────────────────────────
[Fact]
public void JdnToDate_converts_known_julian_day_numbers()
{
// Blacky _BIRTH = 2455036.0 → 2009-07-23 (siehe rpro3-vergleich.md).
Assert.Equal(new DateOnly(2009, 7, 23), Rpro3Reader.JdnToDate(2455036.0));
Assert.Null(Rpro3Reader.JdnToDate(0));
Assert.Null(Rpro3Reader.JdnToDate(null));
}
[Fact]
public void SmartDecode_prefers_utf8_then_cp1252()
{
var utf8 = System.Text.Encoding.UTF8.GetBytes("männlich");
Assert.Equal("männlich", Rpro3Reader.SmartDecode(utf8));
var cp1252 = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
// CP1252-Bytes sind kein gültiges UTF-8 → Fallback dekodiert korrekt.
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
Assert.Equal("männlich", Rpro3Reader.SmartDecode(cp1252));
}
[Fact]
public void CleanGenotype_strips_brackets_keeps_dash()
{
Assert.Equal("aa Ccchm DD EE GG Pp spsp", Rpro3Reader_CleanGenotype("aa Cc[chm] DD EE GG Pp spsp"));
Assert.Equal("Kim AA C- Dd", Rpro3Reader_CleanGenotype("Kim AA C- Dd"));
}
private static string? Rpro3Reader_CleanGenotype(string s)
{
// CleanGenotype ist internal in Rpro3ImportService → über InternalsVisibleTo erreichbar.
var m = typeof(Rpro3ImportService).GetMethod("CleanGenotype",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (string?)m!.Invoke(null, new object?[] { s });
}
// ── Unit: Dedup ───────────────────────────────────────────────
[Fact]
public void Dedup_merges_compatible_same_name_records()
{
var animals = new List<Rpro3Animal>
{
Ext("u1", "Alice", new DateOnly(2010, 7, 19), "Schimmel", "Clan A"),
Ext("u2", "Alice", new DateOnly(2010, 7, 19), "Schimmel", ""), // kompatibel → merge
Ext("u3", "Alice", null, "Schimmel", "Clan A"), // kompatibel → merge
Ext("u4", "Alice", new DateOnly(2001, 1, 1), "Schwarz", "Clan B"), // Konflikt → eigener Cluster
};
var r = Rpro3Dedup.Run(animals);
// Mind. ein Merge-Cluster (u1/u2/u3), u4 separat → Name "Alice" mehrdeutig.
Assert.Single(r.MergeClusters);
Assert.Equal(3, r.MergeClusters.Values.First().Count);
Assert.Contains(r.Ambiguous, a => a.Name == "Alice");
}
[Fact]
public void Dedup_does_not_merge_placeholder_names()
{
var animals = new List<Rpro3Animal>
{
Ext("u1", "unbekannt", null, "", ""),
Ext("u2", "unbekannt", null, "", ""),
};
var r = Rpro3Dedup.Run(animals);
Assert.Empty(r.MergeClusters);
Assert.Equal(2, r.Placeholders.Count);
}
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
[Fact]
public async Task Execute_imports_animals_litters_contacts_and_is_idempotent()
{
var dbPath = BuildMiniRpro3();
try
{
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
Assert.Equal(2, data.Animals.Count(a => a.Src == Rpro3Src.Stamm));
Assert.True(data.Animals.Count(a => a.Src == Rpro3Src.Fremd) >= 2);
using var efConn = new SqliteConnection("DataSource=:memory:");
efConn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
using var ctx = new ApplicationContext(opts);
ctx.Database.EnsureCreated();
var config = new ConfigurationBuilder().Build();
var service = new Rpro3ImportService(ctx, config, null);
var analyze = await service.AnalyzeAsync(data, photosProvided: false, availablePhotoFiles: null);
Assert.Equal(2, analyze.Counts.OwnAnimals);
Assert.True(analyze.NewVsCurrentNew >= 2);
var first = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.True(first.GerbilsImported >= 4); // 2 stamm + ≥2 fremd
Assert.Equal(1, first.LittersImported >= 1 ? 1 : 0); // mind. 1 Wurf importiert
var gerbilCount1 = await ctx.Gerbils.CountAsync();
var litterCount1 = await ctx.Litters.CountAsync();
// Re-Run: idempotent — gleiche Zeilenzahl, keine Dubletten.
var second = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(gerbilCount1, await ctx.Gerbils.CountAsync());
Assert.Equal(litterCount1, await ctx.Litters.CountAsync());
// Pedigree: Wurf hat Mutter + Vater verknüpft (resolved gegen importierte Tiere).
var litter = await ctx.Litters.FirstAsync(l => l.Name!.StartsWith("Wurf"));
Assert.NotNull(litter.MotherId);
Assert.NotNull(litter.FatherId);
// Genotyp wurde aus _FCODE übernommen + Klammern entfernt.
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
Assert.Equal("aa Ccchm DD EE GG Pp spsp", blacky.Genotype);
Assert.True(blacky.IsResident);
// Encoding: "männlich" korrekt dekodiert (Latin-1-Quelle).
Assert.Equal(Gender.male, blacky.Gender);
}
finally { try { File.Delete(dbPath); } catch { } }
}
// ── Integration: Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung) ──
[Fact]
public async Task Execute_imports_feature_tables_and_is_idempotent()
{
var dbPath = BuildMiniRpro3();
try
{
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
// Reader hat alle Feature-Tabellen erfasst.
Assert.Equal(2, data.Becken.Count);
Assert.Single(data.Acquisitions);
Assert.Single(data.Abgeben);
Assert.Single(data.Abstat);
Assert.Single(data.Getback);
Assert.Single(data.Nachfrage);
Assert.Single(data.Ausstellungen);
Assert.Equal(1, data.StammBecken["1"]); // Blacky → Becken 1
using var efConn = new SqliteConnection("DataSource=:memory:");
efConn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
using var ctx = new ApplicationContext(opts);
ctx.Database.EnsureCreated();
var service = new Rpro3ImportService(ctx, new ConfigurationBuilder().Build(), null);
var res = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(2, res.EnclosuresImported);
Assert.Equal(1, res.AcquisitionsImported);
Assert.Equal(2, res.ReservationsImported); // abgeben(Blacky, reserviert) + abstat(Kuke, abgegeben)
Assert.Equal(1, res.ReturnsImported);
Assert.Equal(1, res.WaitingListImported);
Assert.Equal(1, res.ExhibitionsImported);
// Becken → Gehege; Blacky ist Becken 1 zugeordnet.
Assert.Equal(2, await ctx.Enclosures.CountAsync());
var kaefig = await ctx.Enclosures.FirstAsync(e => e.Name == "Käfig A");
Assert.Equal("80x35x40", kaefig.Size);
Assert.Equal(3, kaefig.Capacity);
Assert.Equal(60, kaefig.CleaningCycleDays);
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
Assert.Equal(kaefig.Id, blacky.EnclosureId);
// Erwerb verknüpft auf Blacky mit Preis/Datum.
var acq = await ctx.AcquisitionRecords.SingleAsync();
Assert.Equal(blacky.Id, acq.GerbilId);
Assert.Equal(9.5m, acq.Price);
Assert.NotNull(acq.Date);
// Reservierung: Blacky reserviert für Lisa M.
var resv = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == blacky.Id);
Assert.Equal("reserviert", resv.Status);
Assert.Equal("Lisa M.", resv.ContactName);
// Abgabe-Abschluss: Kuke → abgegeben.
var kuke = await ctx.Gerbils.FirstAsync(g => g.Name == "Kuke");
var kukeRes = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == kuke.Id);
Assert.Equal("abgegeben", kukeRes.Status);
Assert.Equal(15.0m, kukeRes.Price);
// Rücknahme: Blacky kam zurück.
var ret = await ctx.ReturnRecords.SingleAsync();
Assert.Equal(blacky.Id, ret.GerbilId);
Assert.Equal(15.0m, ret.OriginalPrice);
Assert.Equal("Lisa M.", ret.FromContactName);
// Warteliste: männlicher Schimmel.
var wl = await ctx.WaitingListEntries.SingleAsync();
Assert.Equal("male", wl.WishGender);
Assert.Equal("Schimmel", wl.WishColor);
Assert.Equal("offen", wl.Status);
// Ausstellung: Blacky, 1. Platz, BOB.
var exh = await ctx.ExhibitionResults.SingleAsync();
Assert.Equal(blacky.Id, exh.GerbilId);
Assert.Equal("Schau 2011", exh.EventName);
Assert.Equal("1. Platz", exh.Placement);
Assert.Equal("BOB", exh.Award);
// Re-Run: idempotent — keine Dubletten in den Feature-Tabellen.
await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(2, await ctx.Enclosures.CountAsync());
Assert.Equal(1, await ctx.AcquisitionRecords.CountAsync());
Assert.Equal(2, await ctx.SaleReservations.CountAsync()); // Blacky + Kuke
Assert.Equal(1, await ctx.ReturnRecords.CountAsync());
Assert.Equal(1, await ctx.WaitingListEntries.CountAsync());
Assert.Equal(1, await ctx.ExhibitionResults.CountAsync());
}
finally { try { File.Delete(dbPath); } catch { } }
}
// ── Helpers ──
private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) =>
new() { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Dob = dob, Farbe = farbe, Origin = origin };
/// <summary>Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten).</summary>
private static string BuildMiniRpro3()
{
var path = Path.Combine(Path.GetTempPath(), "mini-rpro3-" + Guid.NewGuid().ToString("N") + ".db");
using var conn = new SqliteConnection($"DataSource={path}");
conn.Open();
void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); }
Exec("CREATE TABLE herk_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
Exec("CREATE TABLE abn_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
Exec("CREATE TABLE baum_tb (id, _MID, _PID)");
Exec("CREATE TABLE stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE, _BECKEN)");
Exec("CREATE TABLE fremd_tb (id INTEGER, _NAME, _SEX, _BIRTH, _ZB, _HERK, _MID, _PID, _TODAM, _KASTRAT_DATE)");
Exec("CREATE TABLE wurf_tb (id INTEGER, _MID, _PID, _AM, _BEZ, _BEM)");
Exec("CREATE TABLE wurftier_tb (id, _WID, _NAME, _SEX, _BIRTH, _SID, _TODAM, _WARUM, _ABAM, _ABN, _PRICE, _ZB, _FEHLER)");
Exec("CREATE TABLE color_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE wurfcolor_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE fremdcolor_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE tod_tb (tid, _AM, _WARUM)");
Exec("CREATE TABLE waage_tb (_TID, _GRAMM, _DATE, _BEM)");
Exec("CREATE TABLE jungwaage_tb (_TID, _WID, _GRAMM, _DATE, _BEM)");
Exec("CREATE TABLE krank_tb (_TID, _DATE, _BEM, _MED)");
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)");
// Feature-Tabellen.
Exec("CREATE TABLE becken_tb (id INTEGER, _BEZ, _SIZE, _MENGE, _CLEANED, _CYCLUS, _NEXT)");
Exec("CREATE TABLE herktier_tb (id INTEGER, _TID, _DATE, _PRICE, _BEM, _ART)");
Exec("CREATE TABLE abgeben_tb (tid, _RES, _BEM, _TERMIN, _ABN, _BEZ)");
Exec("CREATE TABLE abstat_tb (tid, _IDABN, _AM, _PRICE, _ART, _BEM)");
Exec("CREATE TABLE getback_tb (id INTEGER, _TID, _ZAM, _ZPREIS, _AM, _PREIS, _ABN, _BEM)");
Exec("CREATE TABLE nachfrage_tb (id INTEGER, _COLOR, _SEX, _DATE, _ABN, _CODE, _VOM, _STATUS, _ART)");
Exec("CREATE TABLE ausz_tb (id INTEGER, _TID, _VERANSTALTUNG, _DATE, _ORT, _PLATZ, _AUSZ, _EXTRA, _JUROR, _BEM)");
// Kontakte
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')");
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (61,'M.Knoss GG','Clan X','Gießen','a@b.de')");
// Geschlecht als Latin-1-Bytes einfügen (BLOB), um den smart_decode-Fallback zu testen.
var maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich");
void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb, int becken = 0)
{
using var c = conn.CreateCommand();
c.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_BECKEN) VALUES ($id,$n,$s,$b,$h,$z,3,'',$bk)";
c.Parameters.AddWithValue("$id", id);
c.Parameters.AddWithValue("$n", name);
c.Parameters.AddWithValue("$s", sex);
c.Parameters.AddWithValue("$b", birth);
c.Parameters.AddWithValue("$h", herk);
c.Parameters.AddWithValue("$z", zb);
c.Parameters.AddWithValue("$bk", becken);
c.ExecuteNonQuery();
}
InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009", becken: 1);
InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009");
// Genotyp (Bracket-Notation) für Blacky.
Exec("INSERT INTO color_tb (id,_FARBE,_FCODE) VALUES ('1','Schwarz','aa Cc[chm] DD EE GG Pp spsp')");
// Externe Ahnen (Eltern von Blacky & Kuke via baum_tb).
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (1,'Oma','weiblich',0,1,'','')");
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (2,'Opa','männlich',0,1,'','')");
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('1','u1','u2')"); // Blacky Eltern = Oma×Opa
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('2','u1','u2')"); // Kuke Eltern = Oma×Opa
// Wurf: Mutter Kuke (2) × Vater Blacky (1)
Exec("INSERT INTO wurf_tb (id,_MID,_PID,_AM,_BEZ,_BEM) VALUES (1,'2','1',2455246.0,'Wurf A','Erster Wurf')");
Exec("INSERT INTO wurftier_tb (id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_ABAM,_ABN,_PRICE,_ZB) VALUES ('1j1','1','Alvin','männlich',2455246.0,'','','',NULL,'','21 A')");
// Gewicht + Krankheit für Blacky.
Exec("INSERT INTO waage_tb (_TID,_GRAMM,_DATE,_BEM) VALUES ('1',55.0,2455083.0,'')");
Exec("INSERT INTO krank_tb (_TID,_DATE,_BEM,_MED) VALUES ('1',2455077.0,'Prellung','')");
// Feature-Tabellen-Daten.
// Abnehmer-Kontakt (für Reservierung/Rücknahme/Warteliste).
Exec("INSERT INTO abn_tb (id,_BEZ,_VNAME,_NNAME) VALUES (5,'Lisa M.','Lisa','Müller')");
// Becken 1 (Blacky wohnt dort, _BECKEN=1), Becken 2 ohne Bewohner.
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (1,'Käfig A','80x35x40',3,2455595.0,60,2455655.0)");
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (2,'Terrarium','55x48x110',32,2455638.0,90,2455728.0)");
// Erwerb: Blacky (stamm 1) am JDN für 9.50.
Exec("INSERT INTO herktier_tb (id,_TID,_DATE,_PRICE,_BEM) VALUES (61,'1',2455076.0,9.5,'gekauft')");
// Reservierung: Blacky (stamm 1) reserviert für abn 5.
Exec("INSERT INTO abgeben_tb (tid,_RES,_BEM,_TERMIN,_ABN,_BEZ) VALUES ('1',1,'',2455730.0,5,0)");
// Abgabe-Abschluss: Kuke (stamm 2) abgegeben an abn 5 für 15.
Exec("INSERT INTO abstat_tb (tid,_IDABN,_AM,_PRICE,_BEM) VALUES ('2',5,2455716.0,15.0,'Zog aus.')");
// Rücknahme: Blacky kam zurück von abn 5.
Exec("INSERT INTO getback_tb (id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM) VALUES (2,'1',2455992.0,0.0,2455798.0,15.0,5,'kam zurück')");
// Warteliste: Wunsch nach männlichem Schimmel von abn 5.
Exec("INSERT INTO nachfrage_tb (id,_COLOR,_SEX,_DATE,_ABN,_CODE,_VOM,_STATUS) VALUES (1,'Schimmel','männlich',0.0,5,'egal',2456172.0,'')");
// Ausstellung: ein Ergebnis für Blacky (in echten Backups oft leer).
Exec("INSERT INTO ausz_tb (id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM) VALUES (1,'1','Schau 2011',2455700.0,'Halle A','1. Platz','BOB','Frau X','schön')");
conn.Close();
SqliteConnection.ClearAllPools();
return path;
}
}
}

View 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,
};
}

View 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,
};
}

View File

@@ -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"
}
]
}

View File

@@ -24,6 +24,12 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -202,6 +208,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)
{

View 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);
}

View File

@@ -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);

View File

@@ -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,

View 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);
}

View File

@@ -0,0 +1,60 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
public record FeedbackInput(
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
/// <summary>
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
/// </summary>
public record FeedbackThreadEntry(
string Role,
string Text,
DateTimeOffset? At);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt,
string Status,
DateTimeOffset? ResolvedAt,
string? Question,
string? Answer,
DateTimeOffset? AnsweredAt,
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
string? FixNote,
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
string? AgentContext,
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
IReadOnlyList<FeedbackThreadEntry> Thread);
/// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
/// attach a clarifying question (Rückfrage), submit the breeder's answer, set the
/// breeder-friendly fix note (changelog), or update the internal agent context.
/// </summary>
public record FeedbackUpdate(
string? Message,
string? Status,
string? Question,
string? Answer,
string? FixNote,
string? AgentContext);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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))));
}
}

View File

@@ -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);
}
}

View 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 &amp; 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);
}
}

View File

@@ -0,0 +1,225 @@
using System.Text.Json;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets").
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
/// GET /feedback -> list reports, newest first (for the ticket list).
/// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage),
/// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt.
/// DELETE /feedback/{id} -> remove a report. 404 on missing id.
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
/// rows survive the import re-ingest wipe.
/// </summary>
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
FeedbackInput input, ApplicationContext db, HttpContext http) =>
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
var entity = new Feedback
{
Id = Guid.NewGuid(),
Message = input.Message.Trim(),
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
GerbilId = input.GerbilId,
LitterId = input.LitterId,
ContactId = input.ContactId,
EntityName = input.EntityName,
Url = input.Url,
ClientTimestamp = input.ClientTimestamp,
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
CreatedAt = DateTimeOffset.UtcNow,
Status = "Open",
ResolvedAt = null,
};
db.Feedback.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.ToList());
});
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackUpdate input, ApplicationContext db) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
if (input.Message is not null)
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
entity.Message = input.Message.Trim();
}
// Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to
// NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace
// question clears it. If a PREVIOUS Q&A exchange already exists, archive it into the
// thread (oldest first) before overwriting — so earlier rounds aren't lost — and clear
// the current Answer so the new question starts a fresh open exchange.
if (input.Question is not null)
{
var q = input.Question.Trim();
if (q.Length > 0)
{
ArchiveCurrentExchange(entity);
entity.Question = q;
if (!entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase))
{
entity.Status = "NeedsInfo";
entity.ResolvedAt = null;
}
}
else
{
entity.Question = null;
}
}
// The breeder's answer. A non-empty answer stamps AnsweredAt and moves to Answered.
if (input.Answer is not null)
{
var a = input.Answer.Trim();
if (a.Length == 0)
{
entity.Answer = null;
entity.AnsweredAt = null;
}
else
{
entity.Answer = a;
entity.AnsweredAt = DateTimeOffset.UtcNow;
entity.Status = "Answered";
entity.ResolvedAt = null;
}
}
if (input.Status is not null)
{
// Normalize to a known lifecycle state. Resolving stamps ResolvedAt; any other
// state clears it. Open/NeedsInfo/Answered/Resolved are accepted (case-insensitive),
// anything else falls back to Open.
var status = input.Status.Trim();
var resolved = status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
var normalized = status switch
{
_ when status.Equals("Resolved", StringComparison.OrdinalIgnoreCase) => "Resolved",
_ when status.Equals("NeedsInfo", StringComparison.OrdinalIgnoreCase) => "NeedsInfo",
_ when status.Equals("Answered", StringComparison.OrdinalIgnoreCase) => "Answered",
_ => "Open",
};
entity.Status = normalized;
entity.ResolvedAt = resolved
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
: null;
}
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
// A blank/whitespace value clears it.
if (input.FixNote is not null)
{
var note = input.FixNote.Trim();
entity.FixNote = note.Length == 0 ? null : note;
}
// AGENT-CONTEXT (internal working memory): updated without changing the status.
// A blank/whitespace value clears it.
if (input.AgentContext is not null)
{
var c = input.AgentContext.Trim();
entity.AgentContext = c.Length == 0 ? null : c;
}
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
db.Feedback.Remove(entity);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
/// <summary>
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
/// before it gets overwritten by a new round, then clear the current Answer. Only the
/// non-empty parts are archived (a question with no answer yet is still preserved).
/// </summary>
private static void ArchiveCurrentExchange(Feedback entity)
{
var history = DeserializeThread(entity.Thread).ToList();
var archivedAny = false;
if (!string.IsNullOrWhiteSpace(entity.Question))
{
history.Add(new FeedbackThreadEntry("maintainer", entity.Question!, entity.CreatedAt));
archivedAny = true;
}
if (!string.IsNullOrWhiteSpace(entity.Answer))
{
history.Add(new FeedbackThreadEntry("breeder", entity.Answer!, entity.AnsweredAt));
archivedAny = true;
}
if (archivedAny)
entity.Thread = JsonSerializer.Serialize(history, ThreadJson);
// The new question starts a fresh open exchange — clear the prior answer.
entity.Answer = null;
entity.AnsweredAt = null;
}
private static IReadOnlyList<FeedbackThreadEntry> DeserializeThread(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<FeedbackThreadEntry>();
try
{
return JsonSerializer.Deserialize<List<FeedbackThreadEntry>>(json, ThreadJson)
?? new List<FeedbackThreadEntry>();
}
catch (JsonException)
{
return Array.Empty<FeedbackThreadEntry>();
}
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread));
}
}

View File

@@ -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);
}
}

View File

@@ -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 */ }
}
}
}

View File

@@ -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>

View 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:&lt;code&gt;</c>, <c>kontakt:&lt;code&gt;</c>,
/// <c>wurf:&lt;code&gt;</c>, <c>gehege:&lt;code&gt;</c> — <c>&lt;code&gt;</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());
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -0,0 +1,197 @@
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// Port von tools/import/compare_rpro3.py `dedup()`. RennmausPro erkennt beim Import vorhandene
/// Tiere NICHT → dasselbe Tier (v. a. die 7427 externen) kommt mehrfach vor.
///
/// Union-Find INNERHALB gleicher (normalisierter) Namen:
/// merge(a,b) wenn DOB/Farbe/Herkunft kompatibel (gleich ODER eine Seite unbekannt)
/// UND mindestens ein bekanntes Feld positiv übereinstimmt. Konflikt in einem bekannten
/// Feld → NICHT mergen (unsicher → mehrdeutig).
/// Platzhalter-Namen ("unbekannt","-","","?" …) werden nie dedupt.
/// </summary>
public static class Rpro3Dedup
{
private static readonly HashSet<string> PlaceholderNames = new(StringComparer.Ordinal)
{ "", "-", "n", "unbekannt", "unbekannt?", "?", "nn", "n.n.", "na", "namenlos", "." };
private static readonly HashSet<string> UnknownValues = new(StringComparer.Ordinal)
{ "", "unbekannt", "unbek.", "unbek", "unbekannte zucht", "?", "n.n.",
"unbekannter farbschlag", "keine angabe", "k.a.", "na", "unbekannte farbe" };
public sealed class DedupResult
{
/// <summary>Cluster (mehr als ein Datensatz) → die zusammengelegten Tiere. Key = Wurzel-Rid.</summary>
public Dictionary<string, List<Rpro3Animal>> MergeClusters { get; } = new();
/// <summary>Repräsentant-Rid je Cluster (auch Singletons) → Cluster-Wurzel.</summary>
public Dictionary<string, string> RidToRoot { get; } = new();
/// <summary>Mehrdeutige Namen: Name → Varianten (manuelle Entscheidung nötig).</summary>
public List<AmbiguousName> Ambiguous { get; } = new();
/// <summary>Platzhalter-Datensätze (nicht dedupbar) gruppiert nach Name.</summary>
public List<Rpro3Animal> Placeholders { get; } = new();
public int CollapsedRecords { get; set; } // Summe der Datensätze in Merge-Clustern
public int DuplicatesRemoved { get; set; } // CollapsedRecords - Anzahl Cluster
}
public sealed class AmbiguousName
{
public required string Name { get; init; }
public List<Variant> Variants { get; } = new();
public int BareCount { get; set; } // merkmalslose Varianten (nur Name)
}
public sealed class Variant
{
public int Count { get; set; }
public List<string> Dob { get; } = new();
public List<string> Farbe { get; } = new();
public List<string> Origin { get; } = new();
public bool IsOwn { get; set; }
}
public static string NormName(string? s)
{
if (s is null) return "";
var n = s.Normalize(NormalizationForm.FormKC).Trim().ToLowerInvariant();
return string.Join(' ', n.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}
public static string NormValue(string? s)
{
var v = NormName(s);
return UnknownValues.Contains(v) ? "" : v;
}
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
{
var result = new DedupResult();
foreach (var a in animals)
{
a.NameKey = NormName(a.Name);
a.FarbeKey = NormValue(a.Farbe);
a.OriginKey = NormValue(a.Origin);
}
var byName = new Dictionary<string, List<Rpro3Animal>>();
foreach (var a in animals)
{
if (PlaceholderNames.Contains(a.NameKey)) { result.Placeholders.Add(a); continue; }
if (!byName.TryGetValue(a.NameKey, out var list)) byName[a.NameKey] = list = new();
list.Add(a);
}
var parent = new Dictionary<string, string>();
string Find(string x)
{
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x;
}
void Union(string x, string y)
{
parent.TryAdd(x, x); parent.TryAdd(y, y);
parent[Find(x)] = Find(y);
}
static int Positive(Rpro3Animal a, Rpro3Animal b)
{
int agree = 0;
if (a.Dob is not null && b.Dob is not null && a.Dob == b.Dob) agree++;
if (a.FarbeKey.Length > 0 && a.FarbeKey == b.FarbeKey) agree++;
if (a.OriginKey.Length > 0 && a.OriginKey == b.OriginKey) agree++;
return agree;
}
static int Conflict(Rpro3Animal a, Rpro3Animal b)
{
int c = 0;
if (a.Dob is not null && b.Dob is not null && a.Dob != b.Dob) c++;
if (a.FarbeKey.Length > 0 && b.FarbeKey.Length > 0 && a.FarbeKey != b.FarbeKey) c++;
if (a.OriginKey.Length > 0 && b.OriginKey.Length > 0 && a.OriginKey != b.OriginKey) c++;
return c;
}
static bool Compat(string? x, string? y) =>
string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y) || x == y;
static bool CompatDob(DateOnly? x, DateOnly? y) => x is null || y is null || x == y;
foreach (var (_, group) in byName)
{
foreach (var a in group) parent.TryAdd(a.Rid, a.Rid);
for (int i = 0; i < group.Count; i++)
for (int j = i + 1; j < group.Count; j++)
{
var a = group[i]; var b = group[j];
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
Union(a.Rid, b.Rid);
}
}
// Cluster sammeln
var clusters = new Dictionary<string, List<Rpro3Animal>>();
foreach (var a in animals)
{
if (PlaceholderNames.Contains(a.NameKey)) continue;
var root = Find(a.Rid);
result.RidToRoot[a.Rid] = root;
if (!clusters.TryGetValue(root, out var list)) clusters[root] = list = new();
list.Add(a);
}
foreach (var (k, v) in clusters)
if (v.Count > 1)
{
result.MergeClusters[k] = v;
result.CollapsedRecords += v.Count;
}
result.DuplicatesRemoved = result.CollapsedRecords - result.MergeClusters.Count;
// Mehrdeutige Namen: Name löst sich nach sicherem Merge in >1 Cluster auf
var nameToRoots = new Dictionary<string, HashSet<string>>();
var disp = new Dictionary<string, string>();
foreach (var a in animals)
{
if (PlaceholderNames.Contains(a.NameKey)) continue;
if (!nameToRoots.TryGetValue(a.NameKey, out var set)) nameToRoots[a.NameKey] = set = new();
set.Add(Find(a.Rid));
disp.TryAdd(a.NameKey, a.Name ?? a.NameKey);
}
foreach (var (nm, roots) in nameToRoots)
{
if (roots.Count <= 1) continue;
var amb = new AmbiguousName { Name = disp.GetValueOrDefault(nm, nm) };
foreach (var root in roots)
{
var recs = clusters[root];
var dob = recs.Where(r => r.Dob is not null).Select(r => r.Dob!.Value.ToString("yyyy-MM-dd")).Distinct().OrderBy(x => x).ToList();
var farbe = recs.Where(r => !string.IsNullOrEmpty(r.Farbe)).Select(r => r.Farbe!).Distinct().OrderBy(x => x).ToList();
var origin = recs.Where(r => NormValue(r.Origin).Length > 0).Select(r => r.Origin!).Distinct().OrderBy(x => x).ToList();
bool bare = dob.Count == 0 && farbe.Count == 0 && origin.Count == 0;
if (bare) { amb.BareCount += recs.Count; continue; }
var v = new Variant { Count = recs.Count, IsOwn = recs.Any(r => r.Src == Rpro3Src.Stamm) };
v.Dob.AddRange(dob); v.Farbe.AddRange(farbe); v.Origin.AddRange(origin);
amb.Variants.Add(v);
}
amb.Variants.Sort((x, y) => y.Count.CompareTo(x.Count));
// Nur Namen mit mind. einer informativen Variante interessieren die Züchterin.
if (amb.Variants.Count >= 1) result.Ambiguous.Add(amb);
}
result.Ambiguous.Sort((x, y) => y.Variants.Count.CompareTo(x.Variants.Count));
return result;
}
/// <summary>Repräsentant eines Clusters: bevorzugt ein stamm-Tier, sonst das mit den meisten
/// bekannten Feldern (DOB/Farbe/Origin), Tiebreak per Rid (stabil).</summary>
public static Rpro3Animal Representative(List<Rpro3Animal> cluster)
{
return cluster
.OrderByDescending(a => a.Src == Rpro3Src.Stamm)
.ThenByDescending(a => (a.Dob is not null ? 1 : 0) + (a.FarbeKey.Length > 0 ? 1 : 0) + (a.OriginKey.Length > 0 ? 1 : 0))
.ThenBy(a => a.Rid, StringComparer.Ordinal)
.First();
}
}
}

View 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}";
}
}

View File

@@ -0,0 +1,861 @@
using System.Text.Json;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// „First class" RennmausPro-III-Backup-Importer. Parst die `.backup` (SQLite), dedupliziert
/// RPRO3-interne Dubletten (Port von compare_rpro3.py) und überführt die Daten in unser Modell.
///
/// Zwei Operationen:
/// AnalyzeAsync — schreibt NICHTS; liefert Zählungen, Top-Merges, mehrdeutige Namen, neu/vorhanden.
/// ExecuteAsync — idempotenter Import (deterministische GUIDs + ExternalRef). Re-Run aktualisiert
/// dieselben Zeilen; vorherige RPRO3-Importe (ImportSource="RennmausPro III")
/// werden vor dem Neuladen entfernt. Spreadsheet-Importe bleiben unangetastet.
/// </summary>
public sealed class Rpro3ImportService
{
private readonly ApplicationContext _db;
private readonly string _photoRoot;
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
}
// ───────────────────────── ANALYZE ─────────────────────────
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var plan = BuildPlan(data, dedup);
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
var existing = await _db.Gerbils.AsNoTracking()
.Select(g => new { g.NameSearch, g.DateOfBirth, g.ExternalRef })
.ToListAsync();
var existingRefs = existing.Where(e => e.ExternalRef is not null)
.Select(e => e.ExternalRef!).ToHashSet(StringComparer.Ordinal);
var existingByName = existing
.GroupBy(e => e.NameSearch ?? "")
.ToDictionary(g => g.Key, g => g.Select(e => e.DateOfBirth).ToList());
int newCount = 0, existingCount = 0;
foreach (var gp in plan.Gerbils.Values)
{
if (existingRefs.Contains(gp.ExternalRef)) { existingCount++; continue; }
var key = GerbilSearch.Normalize(gp.Name);
if (existingByName.TryGetValue(key, out var dobs) && DobMatch(gp.Dob, dobs)) existingCount++;
else newCount++;
}
var counts = new Rpro3Counts(
OwnAnimals: data.Animals.Count(a => a.Src == Rpro3Src.Stamm),
ExternalRaw: data.Animals.Count(a => a.Src == Rpro3Src.Fremd),
ExternalAfterDedup: plan.Gerbils.Values.Count(g => !g.IsResident),
Litters: data.Litters.Count,
Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
Genotypes: data.ColorStammCount + data.ColorExtCount,
DuplicatesMerged: dedup.DuplicatesRemoved,
MergeClusters: dedup.MergeClusters.Count,
Enclosures: plan.Enclosures.Count,
Acquisitions: plan.Acquisitions.Count,
Reservations: plan.Reservations.Count,
Returns: plan.Returns.Count,
WaitingList: plan.WaitingList.Count,
Exhibitions: plan.Exhibitions.Count);
var topMerges = dedup.MergeClusters.Values
.OrderByDescending(v => v.Count)
.Take(25)
.Select(v =>
{
var rep = Rpro3Dedup.Representative(v);
return new Rpro3MergeSample(
rep.Name ?? "—",
v.Count,
string.Join(", ", v.Where(x => x.Dob is not null).Select(x => x.Dob!.Value.ToString("yyyy-MM-dd")).Distinct()),
Truncate(string.Join(", ", v.Where(x => !string.IsNullOrEmpty(x.Farbe)).Select(x => x.Farbe!).Distinct()), 60),
Truncate(string.Join(", ", v.Where(x => Rpro3Dedup.NormValue(x.Origin).Length > 0).Select(x => x.Origin!).Distinct()), 60));
})
.ToList();
var ambiguous = dedup.Ambiguous
.Take(80)
.Select(a => new Rpro3AmbiguousName(
a.Name,
a.Variants.Select(v => new Rpro3AmbiguousVariant(
v.Count,
v.Dob.Count > 0 ? string.Join(", ", v.Dob) : "—",
v.Farbe.Count > 0 ? Truncate(string.Join(", ", v.Farbe), 40) : "—",
v.Origin.Count > 0 ? Truncate(string.Join(", ", v.Origin), 40) : "—",
v.IsOwn)).ToList(),
a.BareCount))
.ToList();
return new Rpro3AnalyzeResult(
counts, newCount, existingCount, topMerges, ambiguous,
photosProvided, availablePhotoFiles?.Count ?? 0);
}
private static bool DobMatch(DateOnly? rpDob, List<DateOnly?> existing)
{
// Match, wenn ein Bestandstier denselben (oder unbekannten) Geburtstag hat (±14 Tage).
if (rpDob is null) return true;
foreach (var d in existing)
{
if (d is null) return true;
if (Math.Abs((rpDob.Value.ToDateTime(TimeOnly.MinValue) - d.Value.ToDateTime(TimeOnly.MinValue)).TotalDays) <= 14)
return true;
}
return false;
}
// ───────────────────────── EXECUTE ─────────────────────────
public async Task<Rpro3ExecuteResult> ExecuteAsync(
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var plan = BuildPlan(data, dedup);
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
// ExecuteAsync auf derselben DbContext-Instanz würden sonst alte Tracking-Einträge
// mit den neu eingefügten (gleiche deterministische IDs) kollidieren.
_db.ChangeTracker.Clear();
// 1. Vorherigen RPRO3-Import idempotent entfernen (deterministische IDs → Re-Run ok).
// Health/Weight zuerst (FK auf Gerbil), dann FK-Links lösen, dann Gerbils/Litters.
var priorGerbilIds = await _db.Gerbils
.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
.Select(g => g.Id).ToListAsync();
var priorLitterRefs = plan.Litters.Values.Select(l => l.ExternalRef).ToList();
if (priorGerbilIds.Count > 0)
{
await _db.HealthRecords.Where(h => priorGerbilIds.Contains(h.GerbilId)).ExecuteDeleteAsync();
await _db.WeightRecords.Where(w => priorGerbilIds.Contains(w.GerbilId)).ExecuteDeleteAsync();
await _db.GerbilPhotos.Where(p => priorGerbilIds.Contains(p.GerbilId)).ExecuteDeleteAsync();
}
// FK-Schleifen lösen: Litter.Father/Mother + Gerbil.Litter, die auf RPRO3-Tiere zeigen.
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef))
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FatherId, (Guid?)null).SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
.ExecuteUpdateAsync(s => s.SetProperty(g => g.LitterId, (Guid?)null));
await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync();
await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync();
// 1b. Feature-Tabellen idempotent leeren. Diese Entities haben KEINE ImportSource-Spalte,
// daher löschen wir gezielt die deterministischen RPRO3-IDs, die dieser Import erzeugt
// (Re-Import desselben Backups → keine Dubletten). Manuell angelegte Einträge mit
// anderen IDs bleiben unangetastet. Enclosures werden NICHT aus Gerbils gelöscht,
// nur die EnclosureId-Verknüpfung gelöschter Tiere fällt mit den Tieren weg.
var acqIds = plan.Acquisitions.Select(a => a.Id).ToList();
var resIds = plan.Reservations.Select(r => r.Id).ToList();
var retIds = plan.Returns.Select(r => r.Id).ToList();
var wlIds = plan.WaitingList.Select(w => w.Id).ToList();
var exhIds = plan.Exhibitions.Select(e => e.Id).ToList();
var encIds = plan.Enclosures.Keys.ToList();
if (acqIds.Count > 0) await _db.AcquisitionRecords.Where(x => acqIds.Contains(x.Id)).ExecuteDeleteAsync();
if (resIds.Count > 0) await _db.SaleReservations.Where(x => resIds.Contains(x.Id)).ExecuteDeleteAsync();
if (retIds.Count > 0) await _db.ReturnRecords.Where(x => retIds.Contains(x.Id)).ExecuteDeleteAsync();
if (wlIds.Count > 0) await _db.WaitingListEntries.Where(x => wlIds.Contains(x.Id)).ExecuteDeleteAsync();
if (exhIds.Count > 0) await _db.ExhibitionResults.Where(x => exhIds.Contains(x.Id)).ExecuteDeleteAsync();
// 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände).
int contactsImported = 0;
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
foreach (var c in plan.Contacts.Values)
{
if (existingContacts.TryGetValue(c.Id, out var ex))
{
ex.Name = c.Name; ex.Email = c.Email; ex.Phone = c.Phone; ex.Address = c.Address;
ex.Notes = c.Notes; ex.IsBreeder = c.IsBreeder; ex.IsReceiver = c.IsReceiver;
ex.NameSuffix = c.NameSuffix; ex.Provenance = c.Provenance;
}
else { _db.Contacts.Add(c); }
contactsImported++;
}
await _db.SaveChangesAsync();
// 2b. Enclosures upserten (deterministische IDs). MUSS vor den Gerbils laufen, da
// Gerbil.EnclosureId ein echter FK ist. Re-Import aktualisiert dieselben Becken.
var existingEnclosures = await _db.Enclosures
.Where(e => plan.Enclosures.Keys.Contains(e.Id)).ToDictionaryAsync(e => e.Id);
foreach (var en in plan.Enclosures.Values)
{
if (existingEnclosures.TryGetValue(en.Id, out var ex))
{
ex.Name = en.Name; ex.Size = en.Size; ex.Capacity = en.Capacity;
ex.LastCleanedDate = en.LastCleanedDate; ex.CleaningCycleDays = en.CleaningCycleDays;
}
else { _db.Enclosures.Add(en); }
}
await _db.SaveChangesAsync();
// 3. Litters (Pass 1: ohne Eltern-FKs).
foreach (var l in plan.Litters.Values)
_db.Litters.Add(new Litter
{
Id = l.Id, Name = l.Name, Date = l.Date, ExternalRef = l.ExternalRef,
Notes = l.Notes, Provenance = l.Provenance, FatherId = null, MotherId = null,
});
await _db.SaveChangesAsync();
// 4. Gerbils (Pass 1: ohne LitterId).
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in plan.Gerbils.Values)
{
var gerbil = new Gerbil
{
Id = g.Id, Name = g.Name, Gender = g.Gender,
DateOfBirth = g.Dob, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
Genotype = g.Genotype, ColorVarietyId = g.ColorVarietyId, OriginBreeder = g.OriginBreeder,
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
Provenance = g.Provenance, LitterId = null,
EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null,
};
GerbilStatusService.Apply(gerbil, today);
_db.Gerbils.Add(gerbil);
}
await _db.SaveChangesAsync();
// 5. Pass 2: FK-Beziehungen setzen (Litter-Eltern + Gerbil-Geburtswurf).
var littersInDb = await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ToDictionaryAsync(l => l.Id);
foreach (var l in plan.Litters.Values)
if (littersInDb.TryGetValue(l.Id, out var dbl))
{
dbl.MotherId = l.MotherId; dbl.FatherId = l.FatherId;
}
var gerbilsInDb = await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ToDictionaryAsync(g => g.Id);
foreach (var g in plan.Gerbils.Values)
if (g.LitterId is not null && gerbilsInDb.TryGetValue(g.Id, out var dbg))
dbg.LitterId = g.LitterId;
await _db.SaveChangesAsync();
// 6. Gesundheits- und Gewichtseinträge.
foreach (var h in plan.Health) _db.HealthRecords.Add(h);
foreach (var w in plan.Weights) _db.WeightRecords.Add(w);
await _db.SaveChangesAsync();
// 7. Fotos (optional, aus bilder.zip).
int photosImported = 0;
if (photosProvided && photoSourcePaths is not null)
{
Directory.CreateDirectory(_photoRoot);
foreach (var (gerbilId, fileNames) in plan.PhotoFiles)
{
int sort = 0;
foreach (var fn in fileNames)
{
if (!photoSourcePaths.TryGetValue(fn.ToLowerInvariant(), out var src) || !File.Exists(src)) continue;
var ext = Path.GetExtension(fn);
var storedName = $"{Guid.NewGuid():N}{ext}";
try { File.Copy(src, Path.Combine(_photoRoot, storedName), overwrite: true); }
catch { continue; }
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(), GerbilId = gerbilId, FileName = storedName,
SortOrder = sort++, CreatedAt = DateTimeOffset.UtcNow,
});
photosImported++;
}
}
await _db.SaveChangesAsync();
}
// 8. Feature-Tabellen einfügen (Enclosures schon in 2b erledigt).
foreach (var a in plan.Acquisitions) _db.AcquisitionRecords.Add(a);
foreach (var r in plan.Reservations) _db.SaleReservations.Add(r);
foreach (var r in plan.Returns) _db.ReturnRecords.Add(r);
foreach (var w in plan.WaitingList) _db.WaitingListEntries.Add(w);
foreach (var e in plan.Exhibitions) _db.ExhibitionResults.Add(e);
await _db.SaveChangesAsync();
return new Rpro3ExecuteResult(
plan.Gerbils.Count, plan.Litters.Count, contactsImported,
plan.Health.Count, plan.Weights.Count, photosImported,
plan.Enclosures.Count, plan.Acquisitions.Count, plan.Reservations.Count,
plan.Returns.Count, plan.WaitingList.Count, plan.Exhibitions.Count,
$"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " +
$"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " +
$"{photosImported} Fotos, {plan.Enclosures.Count} Gehege, {plan.Acquisitions.Count} Erwerbe, " +
$"{plan.Reservations.Count} Reservierungen, {plan.Returns.Count} Rücknahmen, " +
$"{plan.WaitingList.Count} Wartelisten-Einträge, {plan.Exhibitions.Count} Ausstellungen.");
}
// ───────────────────────── PLAN-AUFBAU ─────────────────────────
private sealed class GerbilPlan
{
public required Guid Id { get; init; }
public required string ExternalRef { get; init; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public DateOnly? Dob { get; set; }
public DateOnly? DateOfDeath { get; set; }
public string? CauseOfDeath { get; set; }
public string? Genotype { get; set; }
public Guid? ColorVarietyId { get; set; }
public string? OriginBreeder { get; set; }
public Guid? OriginContactId { get; set; }
public Guid? ReceiverContactId { get; set; }
public DateOnly? GoHomeDate { get; set; }
public bool IsResident { get; set; }
public bool IsCastrated { get; set; }
public string? Notes { get; set; }
public string? Provenance { get; set; }
public Guid? LitterId { get; set; }
public string? MotherRid { get; set; }
public string? FatherRid { get; set; }
}
private sealed class LitterPlan
{
public required Guid Id { get; init; }
public required string ExternalRef { get; init; }
public string Name { get; set; } = "Wurf";
public DateOnly? Date { get; set; }
public Guid? MotherId { get; set; }
public Guid? FatherId { get; set; }
public string? Notes { get; set; }
public string? Provenance { get; set; }
}
private sealed class ImportPlan
{
public Dictionary<Guid, GerbilPlan> Gerbils { get; } = new();
public Dictionary<Guid, LitterPlan> Litters { get; } = new();
public Dictionary<Guid, Contact> Contacts { get; } = new();
public List<HealthRecord> Health { get; } = new();
public List<WeightRecord> Weights { get; } = new();
public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();
// Feature-Tabellen.
public Dictionary<Guid, Enclosure> Enclosures { get; } = new();
public Dictionary<Guid, Guid> GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId
public List<AcquisitionRecord> Acquisitions { get; } = new();
public List<SaleReservation> Reservations { get; } = new();
public List<ReturnRecord> Returns { get; } = new();
public List<WaitingListEntry> WaitingList { get; } = new();
public List<ExhibitionResult> Exhibitions { get; } = new();
}
private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup)
{
_lastDedup = dedup;
var plan = new ImportPlan();
// 0) Farbschlag-Katalog (Name → Id) für ColorVariety-Verknüpfung.
var colorByName = _db.ColorVarieties.AsNoTracking()
.ToDictionary(c => Rpro3Dedup.NormName(c.Name), c => c.Id);
// 1) Kontakte (herk = Züchter/Herkunft, abn = Abnehmer).
Guid? HerkContactId(int? herkId)
{
if (herkId is null || herkId == 1) return null; // 1 = eigene Zucht → kein Kontakt
if (!data.HerkContacts.TryGetValue(herkId.Value.ToString(), out var c)) return null;
return EnsureContact(plan, c, isBreeder: true, isReceiver: false);
}
Guid? AbnContactId(int? abnId)
{
if (abnId is null) return null;
if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return null;
return EnsureContact(plan, c, isBreeder: false, isReceiver: true);
}
// 2) Rid → Cluster-Root → Gerbil-GUID. Platzhalter-Tiere bekommen ihren eigenen Root.
string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
Guid GerbilIdOfRid(string rid) => Rpro3Guid.Gerbil(RootOf(rid));
// 3) Pup-Index: SID(stamm-id) → pup, und stamm-id → Welpen-Daten (Abgabe/Preis/Tod).
var pupBySid = new Dictionary<string, Rpro3Pup>(StringComparer.Ordinal);
foreach (var p in data.Pups.Values)
if (!string.IsNullOrWhiteSpace(p.Sid) && p.Sid != "0")
pupBySid[p.Sid] = p;
// 4) Ein Gerbil je Cluster (Repräsentant führt; stamm-Tiere machen den Cluster resident).
// Singletons (RidToRoot == self) ebenfalls einbeziehen.
var clusterMembers = new Dictionary<string, List<Rpro3Animal>>(StringComparer.Ordinal);
foreach (var a in data.Animals)
{
var root = RootOf(a.Rid);
if (!clusterMembers.TryGetValue(root, out var list)) clusterMembers[root] = list = new();
list.Add(a);
}
foreach (var (root, members) in clusterMembers)
{
var rep = Rpro3Dedup.Representative(members);
bool resident = members.Any(m => m.Src == Rpro3Src.Stamm);
var id = Rpro3Guid.Gerbil(root);
if (plan.Gerbils.ContainsKey(id)) continue;
// Bestes bekanntes Feld aus allen Cluster-Mitgliedern wählen.
var dob = members.Select(m => m.Dob).FirstOrDefault(d => d is not null) ?? rep.Dob;
var farbe = members.Select(m => m.Farbe).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Farbe;
var fcode = members.Select(m => m.Fcode).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Fcode;
var death = members.FirstOrDefault(m => m.DateOfDeath is not null);
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
var gp = new GerbilPlan
{
Id = id,
ExternalRef = Rpro3Guid.GerbilRef(root),
Name = string.IsNullOrWhiteSpace(rep.Name) ? "Unbenannt" : rep.Name!,
Gender = members.Select(m => m.Gender).FirstOrDefault(g => g != Gender.unknown),
Dob = dob,
DateOfDeath = death?.DateOfDeath,
CauseOfDeath = death?.CauseOfDeath,
Genotype = CleanGenotype(fcode),
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
OriginContactId = HerkContactId(herkId),
IsResident = resident,
IsCastrated = members.Any(m => m.IsCastrated),
MotherRid = rep.MidRaw,
FatherRid = rep.PidRaw,
};
if (!string.IsNullOrWhiteSpace(farbe) && colorByName.TryGetValue(Rpro3Dedup.NormName(farbe), out var cvId))
gp.ColorVarietyId = cvId;
// Welpen-Zusatzdaten (Abgabe/Preis) für residente Tiere, die ein wurftier sind.
foreach (var m in members.Where(m => m.Src == Rpro3Src.Stamm))
if (pupBySid.TryGetValue(m.Rid, out var pup))
{
gp.GoHomeDate = pup.AbgabeDate;
gp.ReceiverContactId = AbnContactId(pup.AbnId);
if (pup.DateOfDeath is not null && gp.DateOfDeath is null)
{
gp.DateOfDeath = pup.DateOfDeath; gp.CauseOfDeath = pup.CauseOfDeath;
}
}
gp.Notes = BuildNotes(members);
gp.Provenance = BuildProvenance(data, members, dedup);
plan.Gerbils[id] = gp;
}
// 5) Würfe aus wurf_tb (autoritativ). Eltern → Cluster-GUID.
foreach (var (wid, w) in data.Litters)
{
var id = Rpro3Guid.Litter(wid);
var lp = new LitterPlan
{
Id = id,
ExternalRef = Rpro3Guid.LitterRef(wid),
Name = string.IsNullOrWhiteSpace(w.Name) ? $"Wurf {wid}" : w.Name!,
Date = w.Date,
Notes = w.Notes,
MotherId = ResolveParentGuid(w.MotherRid, plan),
FatherId = ResolveParentGuid(w.FatherRid, plan),
Provenance = JsonProvenance("RennmausPro III", new() { ["wurfId"] = wid, ["quelle"] = "wurf_tb" }),
};
plan.Litters[id] = lp;
}
// 6) Welpen → Geburtswurf verknüpfen (pup._SID → resultierendes Gerbil).
foreach (var p in data.Pups.Values)
{
if (string.IsNullOrWhiteSpace(p.Sid) || p.Sid == "0") continue;
var gid = GerbilIdOfRid(p.Sid);
if (!plan.Gerbils.TryGetValue(gid, out var gp)) continue;
var litterId = Rpro3Guid.Litter(p.WurfId);
if (plan.Litters.ContainsKey(litterId)) gp.LitterId = litterId;
}
// 7) Synthetische „Pedigree-Würfe" für Tiere mit baum_tb-Eltern, aber ohne wurf_tb-Geburtswurf.
// So traversiert der Stammbaum (Litter → Father/Mother) auch externe Ahnen-Linien.
foreach (var gp in plan.Gerbils.Values)
{
if (gp.LitterId is not null) continue;
var motherId = ResolveParentGuid(gp.MotherRid, plan);
var fatherId = ResolveParentGuid(gp.FatherRid, plan);
if (motherId is null && fatherId is null) continue;
var key = $"ped:{motherId}:{fatherId}";
var litterId = Rpro3Guid.For("litter", key);
if (!plan.Litters.TryGetValue(litterId, out var lp))
{
lp = new LitterPlan
{
Id = litterId,
ExternalRef = $"rpro3-ped:{motherId}:{fatherId}",
Name = "Abstammung (RennmausPro)",
MotherId = motherId,
FatherId = fatherId,
Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = "baum_tb", ["synthetisch"] = true }),
};
plan.Litters[litterId] = lp;
}
gp.LitterId = litterId;
}
// 8) Gesundheit/Gewicht/Tagebuch → stamm-Tiere (tid = stamm-id).
BuildHealthAndWeights(data, plan, GerbilIdOfRid);
// 9) Fotos (photo_tb id = stamm-id).
foreach (var (tid, set) in data.Photos)
{
var gid = GerbilIdOfRid(tid);
if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames;
}
// 10) Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung).
BuildFeatureTables(data, plan, dedup, pupBySid);
return plan;
}
/// <summary>
/// Baut die 6 Feature-Entities aus den RPRO3-Feature-Tabellen. Alle Referenzen werden über
/// die bereits aufgebauten Gerbil-/Contact-GUIDs aufgelöst; tids können stamm ("N") oder
/// wurftier ("XjY") sein — Welpen-tids werden über _SID auf das resultierende Tier abgebildet.
/// </summary>
private void BuildFeatureTables(
Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary<string, Rpro3Pup> pupBySid)
{
string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
// tid ("N" oder "XjY") → (GerbilId?, erfasster Name?). Welpen ohne kept-_SID liefern null-Id.
(Guid? id, string? name) ResolveGerbil(string? tid)
{
if (string.IsNullOrWhiteSpace(tid)) return (null, null);
string rid = tid;
if (tid.Contains('j'))
{
// Welpe: über _SID auf das behaltene stamm-Tier abbilden, falls vorhanden.
if (data.Pups.TryGetValue(tid, out var pup) && !string.IsNullOrWhiteSpace(pup.Sid) && pup.Sid != "0")
rid = pup.Sid!;
else
{
var pupName = data.Pups.TryGetValue(tid, out var p) ? p.Name : null;
return (null, pupName);
}
}
var gid = Rpro3Guid.Gerbil(RootOf(rid));
return plan.Gerbils.TryGetValue(gid, out var gp) ? (gid, gp.Name) : (null, data.ResolveName(rid));
}
// abn-id → (ContactId?, erfasster Name?). Legt den Kontakt bei Bedarf an (als Abnehmer).
(Guid? id, string? name) ResolveAbn(int? abnId)
{
if (abnId is null) return (null, null);
if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return (null, null);
var id = EnsureContact(plan, c, isBreeder: false, isReceiver: true);
return (id, c.DisplayName);
}
var now = DateTimeOffset.UtcNow;
static DateTime? AsDt(DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
// ── Becken → Enclosure (+ stamm._BECKEN → Gerbil-Zuordnung) ──
var beckenById = new Dictionary<int, Guid>();
foreach (var b in data.Becken)
{
var id = Rpro3Guid.Enclosure(b.Id);
beckenById[b.Id] = id;
plan.Enclosures[id] = new Enclosure
{
Id = id,
Name = string.IsNullOrWhiteSpace(b.Name) ? $"Becken {b.Id}" : b.Name!,
Size = string.IsNullOrWhiteSpace(b.Size) ? null : b.Size,
Capacity = b.Capacity is > 0 ? b.Capacity : null,
LastCleanedDate = b.LastCleaned,
CleaningCycleDays = b.CycleDays is > 0 ? b.CycleDays : null,
};
}
foreach (var (rid, beckenId) in data.StammBecken)
{
if (!beckenById.TryGetValue(beckenId, out var encId)) continue;
var gid = Rpro3Guid.Gerbil(RootOf(rid));
if (plan.Gerbils.ContainsKey(gid)) plan.GerbilEnclosure[gid] = encId;
}
// ── herktier_tb → AcquisitionRecord ──
foreach (var a in data.Acquisitions)
{
var (gid, _) = ResolveGerbil(a.Tid);
if (gid is null) continue; // kein Tier → kein Erwerb
// Herkunfts-Kontakt steht bereits auf Gerbil.OriginContactId; hier Datum/Preis/Notiz.
Guid? sourceContactId = plan.Gerbils.TryGetValue(gid.Value, out var gp) ? gp.OriginContactId : null;
plan.Acquisitions.Add(new AcquisitionRecord
{
Id = Rpro3Guid.Acquisition(a.Id),
GerbilId = gid,
SourceContactId = sourceContactId,
Date = a.Date,
Price = a.Price is > 0 ? a.Price : null,
Note = NullIfEmpty(a.Note),
CreatedAt = now,
});
}
// ── abgeben_tb + abstat_tb → SaleReservation (eine Zeile je Tier) ──
// abgeben = Vormerkung/Reservierung (oft Welpen-tid), abstat = Abgabe-Abschluss (stamm-tid).
// Beide werden über das Tier zusammengeführt; ist beides vorhanden, gewinnt „abgegeben".
var reservationByGerbil = new Dictionary<Guid, SaleReservation>();
SaleReservation Res(Guid gid, string? name, string tidKey)
{
if (reservationByGerbil.TryGetValue(gid, out var ex)) return ex;
var res = new SaleReservation
{
Id = Rpro3Guid.Reservation(tidKey),
GerbilId = gid,
GerbilName = name,
Status = "verfuegbar",
CreatedAt = now,
UpdatedAt = now,
};
reservationByGerbil[gid] = res;
plan.Reservations.Add(res);
return res;
}
foreach (var ab in data.Abgeben)
{
var (gid, name) = ResolveGerbil(ab.Tid);
if (gid is null) continue;
var res = Res(gid.Value, name, ab.Tid);
var (cid, cname) = ResolveAbn(ab.AbnId);
if (res.Status != "abgegeben") res.Status = ab.Reserved ? "reserviert" : res.Status;
res.ReservedForContactId ??= cid;
res.ContactName ??= cname;
res.AppointmentDate ??= AsDt(ab.Appointment);
res.Note ??= NullIfEmpty(ab.Note);
}
foreach (var st in data.Abstat)
{
var (gid, name) = ResolveGerbil(st.Tid);
if (gid is null) continue;
var res = Res(gid.Value, name, st.Tid);
var (cid, cname) = ResolveAbn(st.AbnId);
res.Status = "abgegeben";
res.ReservedForContactId ??= cid;
res.ContactName ??= cname;
res.HandedOverDate ??= AsDt(st.HandedOver);
if (st.Price is > 0) res.Price ??= st.Price;
res.Note ??= NullIfEmpty(st.Note);
res.UpdatedAt = now;
}
// ── getback_tb → ReturnRecord ──
foreach (var gb in data.Getback)
{
var (gid, name) = ResolveGerbil(gb.Tid);
var (cid, cname) = ResolveAbn(gb.AbnId);
plan.Returns.Add(new ReturnRecord
{
Id = Rpro3Guid.Return(gb.Id),
GerbilId = gid,
GerbilName = name,
ReturnDate = AsDt(gb.ReturnDate),
ReturnPrice = gb.ReturnPrice is > 0 ? gb.ReturnPrice : null,
OriginalPrice = gb.OriginalPrice is > 0 ? gb.OriginalPrice : null,
OriginalSaleDate = AsDt(gb.OriginalSaleDate),
FromContactId = cid,
FromContactName = cname,
Note = NullIfEmpty(gb.Note),
CreatedAt = now,
});
}
// ── nachfrage_tb → WaitingListEntry ──
foreach (var nf in data.Nachfrage)
{
var (cid, cname) = ResolveAbn(nf.AbnId);
plan.WaitingList.Add(new WaitingListEntry
{
Id = Rpro3Guid.WaitingList(nf.Id),
ContactId = cid,
ContactName = cname,
WishColor = NullIfEmpty(nf.WishColor),
WishGender = nf.WishGender switch
{
Gender.male => "male",
Gender.female => "female",
_ => null,
},
RequestedAt = AsDt(nf.RequestedAt),
Status = MapWaitlistStatus(nf.Status),
Note = NullIfEmpty(nf.Note),
CreatedAt = now,
});
}
// ── ausz_tb → ExhibitionResult (in vielen Backups leer; trotzdem voll verdrahtet) ──
foreach (var ex in data.Ausstellungen)
{
var (gid, name) = ResolveGerbil(ex.Tid);
var noteParts = new[] { ex.Place, ex.Juror is { Length: > 0 } ? $"Juror: {ex.Juror}" : null, ex.Note }
.Where(s => !string.IsNullOrWhiteSpace(s));
var note = string.Join(" · ", noteParts);
plan.Exhibitions.Add(new ExhibitionResult
{
Id = Rpro3Guid.Exhibition(ex.Id),
GerbilId = gid,
EntityName = name,
EventName = string.IsNullOrWhiteSpace(ex.EventName) ? "Ausstellung" : ex.EventName!,
Date = AsDt(ex.Date),
Placement = NullIfEmpty(ex.Placement),
Award = NullIfEmpty(ex.Award),
Note = string.IsNullOrWhiteSpace(note) ? null : note,
CreatedAt = now,
});
}
}
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert).</summary>
private static string MapWaitlistStatus(string? raw)
{
var s = (raw ?? "").Trim().ToLowerInvariant();
return s switch
{
"" or "offen" or "0" => "offen",
"erfuellt" or "erfüllt" or "1" => "erfuellt",
"storniert" or "2" => "storniert",
_ => "offen",
};
}
private Guid? ResolveParentGuid(string? rid, ImportPlan plan)
{
if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
var gid = Rpro3Guid.Gerbil(_lastDedup!.RidToRoot.TryGetValue(rid, out var r) ? r : rid);
return plan.Gerbils.ContainsKey(gid) ? gid : null;
}
// Kontext-Hack: ResolveParentGuid braucht die RidToRoot-Map des aktuellen Plans.
private Rpro3Dedup.DedupResult? _lastDedup;
private void BuildHealthAndWeights(Rpro3Data data, ImportPlan plan, Func<string, Guid> gerbilIdOfRid)
{
int seq = 0;
foreach (var h in data.Health)
{
var gid = gerbilIdOfRid(h.Tid);
if (!plan.Gerbils.ContainsKey(gid) || h.Date is null) continue;
var desc = string.IsNullOrWhiteSpace(h.Description) ? "Krankheitseintrag" : h.Description!;
if (!string.IsNullOrWhiteSpace(h.Medication)) desc += $" (Medikation: {h.Medication})";
plan.Health.Add(new HealthRecord
{
Id = Rpro3Guid.Health(h.Tid, desc, seq++),
GerbilId = gid, Date = h.Date.Value, Type = HealthRecordType.Treatment,
Description = desc, CreatedAt = DateTimeOffset.UtcNow,
});
}
// Tagebuch als "Other"-Gesundheitseinträge (kein eigenes Tagebuch-Modell vorhanden).
foreach (var d in data.Diary)
{
var gid = gerbilIdOfRid(d.Tid);
if (!plan.Gerbils.ContainsKey(gid) || d.Date is null || string.IsNullOrWhiteSpace(d.Description)) continue;
var desc = (string.IsNullOrWhiteSpace(d.Title) ? "" : d.Title + ": ") + d.Description;
plan.Health.Add(new HealthRecord
{
Id = Rpro3Guid.Health(d.Tid, "diary:" + desc, seq++),
GerbilId = gid, Date = d.Date.Value, Type = HealthRecordType.Other,
Description = "Tagebuch: " + desc, CreatedAt = DateTimeOffset.UtcNow,
});
}
int wseq = 0;
var seen = new HashSet<Guid>();
foreach (var w in data.Weights)
{
var gid = gerbilIdOfRid(w.Tid);
if (!plan.Gerbils.ContainsKey(gid) || w.Date is null || w.Grams <= 0) continue;
var id = Rpro3Guid.Weight(w.Tid + ":" + w.Date.Value.DayNumber, wseq++);
if (!seen.Add(id)) continue;
plan.Weights.Add(new WeightRecord
{
Id = id, GerbilId = gid, Date = w.Date.Value, WeightGrams = w.Grams, Notes = w.Notes,
});
}
}
private Guid EnsureContact(ImportPlan plan, Rpro3Contact c, bool isBreeder, bool isReceiver)
{
var id = Rpro3Guid.Contact(c.Namespace, c.Id);
if (plan.Contacts.TryGetValue(id, out var ex))
{
ex.IsBreeder |= isBreeder; ex.IsReceiver |= isReceiver;
return id;
}
var address = string.Join(", ",
new[] { c.Str, $"{c.Plz} {c.Ort}".Trim() }.Where(s => !string.IsNullOrWhiteSpace(s)));
var notes = string.Join("\n",
new[] { c.Memo, c.Bem, c.Http }.Where(s => !string.IsNullOrWhiteSpace(s)));
plan.Contacts[id] = new Contact
{
Id = id,
Name = c.DisplayName,
Email = string.IsNullOrWhiteSpace(c.Mail) ? null : c.Mail,
Phone = string.IsNullOrWhiteSpace(c.Telp) ? c.Teld : c.Telp,
Address = string.IsNullOrWhiteSpace(address) ? null : address,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
IsBreeder = isBreeder,
IsReceiver = isReceiver,
NameSuffix = string.IsNullOrWhiteSpace(c.Clan) ? null : c.Clan,
Provenance = JsonProvenance("RennmausPro III",
new() { ["quelle"] = c.Namespace == "herk" ? "herk_tb" : "abn_tb", ["rpro3Id"] = c.Id }),
};
return id;
}
// ───────────────────────── Helpers ─────────────────────────
/// <summary>RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt
/// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten.</summary>
internal static string? CleanGenotype(string? fcode)
{
if (string.IsNullOrWhiteSpace(fcode)) return null;
var sb = new System.Text.StringBuilder(fcode.Length);
foreach (var ch in fcode)
if (ch != '[' && ch != ']') sb.Append(ch);
var cleaned = sb.ToString().Trim();
return cleaned.Length == 0 ? null : cleaned;
}
private static string? BuildNotes(List<Rpro3Animal> members)
{
var defects = members.Select(m => m.Defects).Where(d => !string.IsNullOrWhiteSpace(d)).Distinct().ToList();
var zb = members.Select(m => m.Zb).FirstOrDefault(z => !string.IsNullOrWhiteSpace(z));
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(zb)) parts.Add($"Zuchtbuch-Nr.: {zb}");
if (defects.Count > 0) parts.Add("Defekte: " + string.Join("; ", defects));
return parts.Count > 0 ? string.Join("\n", parts) : null;
}
private static string BuildProvenance(Rpro3Data data, List<Rpro3Animal> members, Rpro3Dedup.DedupResult dedup)
{
var rep = Rpro3Dedup.Representative(members);
var obj = new Dictionary<string, object?>
{
["importSource"] = "RennmausPro III",
["mergedRecordCount"] = members.Count,
["rpro3Ids"] = members.Select(m => m.Rid).Take(8).ToList(),
["mother"] = data.ResolveName(rep.MidRaw),
["father"] = data.ResolveName(rep.PidRaw),
};
if (members.Count > 1)
obj["note"] = $"{members.Count} RennmausPro-Datensätze zusammengelegt (Name + Geburtsdatum + Farbe + Herkunft).";
return JsonSerializer.Serialize(obj);
}
private static string JsonProvenance(string source, Dictionary<string, object?> extra)
{
extra["importSource"] = source;
return JsonSerializer.Serialize(extra);
}
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
}

View 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;
}
}

View 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) { }
}
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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")
@@ -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,114 @@ 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<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<string>("EntityName")
.HasColumnType("text");
b.Property<string>("FixNote")
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Question")
.HasColumnType("text");
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.Gerbil", b =>
{
b.Property<Guid>("Id")
@@ -870,6 +1025,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<string>("RawImportData")
.HasColumnType("text");
@@ -1001,6 +1159,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
@@ -1230,6 +1391,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 +1489,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 +1564,44 @@ 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.WeightRecord", b =>
{
b.Property<Guid>("Id")

View 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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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>();
}
}

View 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; }
}
}

View File

@@ -0,0 +1,90 @@
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>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; }
}
}

View File

@@ -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; }

View File

@@ -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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View File

@@ -126,6 +126,13 @@ app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.MapReturnRecordEndpoints();
app.MapExhibitionEndpoints();
app.MapRefsEndpoints();
app.Run();

View File

@@ -1,105 +0,0 @@
# HUMANQUESTION.md — offene Fragen & benötigte Daten
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
> das, was **noch offen** ist. Nichts geht verloren.
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
Feinschliff** — siehe unten._
---
## A. Zugänge & Technik (für Julian)
| # | Was gebraucht wird | Schaltet frei |
|---|---|---|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft |
|---|---|---|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---|---|
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
---
## F. Was hängt woran (Kurzübersicht)
| Funktion | Status | Wartet auf |
|---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
---
## ✅ Erledigt (Archiv — nichts geht verloren)
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
- **C1C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
- **D1D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*

130
docs/ticket-triage.md Normal file
View File

@@ -0,0 +1,130 @@
# Ticket-Triage — Lauf (56 Tickets)
**Ergebnis:** {'needs-implementation': 24, 'rueckfrage': 8, 'resolved-alreadyfixed': 24}
## Umzusetzen (needs-implementation) — nach Cluster
### Cluster: non-resident (3)
- **09bcac78-d125-4f3d-9070-809980d2c44e** [import-logic] — Bentley/Alexandria faelschlich als Zuchttiere Residenz-Regel ueber Nachzucht-Herkunft
- Empfehlung: import-logic in tools/import/merge_and_resolve.py: Residenz-Erstmarkierung umbauen. Statt ein Tier resident zu setzen, weil es SELBST den Clan-Namen traegt (Z.1568-1571 is_clan_zucht auf a.zucht), ein Tier nur dann resident markieren, wenn es Nachzucht hat, deren Herkunft/zucht is_clan_zucht erfuellt (Clan kleine Chaoten, nicht 'extern'). Eltern-Propagation (Z.1573-1586) mit is_external_cattery-Ausschluss beibehalten. Dadurch fallen reine Vorfahren-Boxen (Bentley, Alexandria, Bugatti) auf isResident=false, echte Zuchttiere mit Kleine-Chaoten-Nachzucht bleiben true. Wurfchronik-Importpfad (Z.2052ff, IsResident default True) separat pruefen. Danach test_merge_resolve.py gruen halten (#17/#20 Hagrid weiter false), merge_and_resolve + POST /import/ingest-resolved. Gilt fuer ALLE Tiere ('Mach das bitte fuer alle Tiere').
- **45cc501b-be83-4b2c-9aa3-e8fdc229b367** [import-logic] — Alexandria — externer Vorfahre fälschlich als eigenes Zuchttier angezeigt
- Empfehlung: (1) import-logic: in tools/import/merge_and_resolve.py ~Z.1532-1536 die Residency-Propagation so einschränken, dass reine Vorfahren/externe Ahnen NICHT isResident=true (und nicht originBreeder="Clan kleine Chaoten") erben; danach Re-Ingest via POST /import/ingest-resolved. (2) frontend: in gerbil-manager-web/src/pages/GerbilDetailPage.tsx Bestands-spezifische Abschnitte (Würfe ~Z.81-82/432-455, Charakter/Eigenschaften) für isResident=false ausblenden, sodass für reine Vorfahren nur die Stammbaum-relevanten Basisdaten erscheinen. Mit Cluster non-resident-display bündeln (gemeinsamer Fix mit Hagrid).
- **955e39e6-299c-4f46-b6b7-0556aa9c2673** [import-logic] — Wurf-Jungtiere ohne Stammbaum nicht automatisch als Bestandstier (Zuchttier) anzeigen
### Cluster: wrong-parents (1)
- **88389f8e-8b8b-44c3-940b-93aa9e571c29** [import-data] — Akane: Roni ist Vater (nicht Mutter), Mutter = Fumi von den Kleinen Chaoten
- Empfehlung: In tools/import/conflict-decisions.json: (a) gender-Override fuer Roni (*27.01.2022) auf male (wie Mozart-Eintrag), (b) 'Fumi von den Kleinen Chaoten' (weiblich) als neuen Mutter-Datensatz anlegen (analog #22 Louis/#31 Danny/#27 Hanau), (c) parent-Override fuer Akane/Wurf Z21: father='Roni', mother='Fumi von den Kleinen Chaoten' (father/mother-Mechanik wie #16/#23/#36). merge_and_resolve.py muss gender-Override beruecksichtigen (assign_parent_roles/pick_parent_ref ~Z.640/679) und benannte fehlende Eltern anlegen koennen. Zusaetzlich pruefen: Roni ist Mutter aller drei Wuerfe T21 (3d510e7c) und 22-Wurf (947c6852) -> dort ebenfalls Vater-Rolle setzen; deren Mutter ist aber unbekannt (ggf. spaeter rueckfragen). Danach extract + merge_and_resolve + POST /import/ingest-resolved.
### Cluster: parents (3)
- **36a3fcde-d14a-46e1-936f-2263863f8207** [import-data] — Akane: Lebenslauf (Zuchtaustausch Black Forest, Tod bei Ulrike Neu) + Wurf Merle/Fanella/Pete fehlt
- Empfehlung: Manuelle Daten-Korrektur/Override für Akane (e087fb7d-7d13-5733-856e-e8cf4277fc7c): status=Deceased, isResident=false, dateOfDeath/causeOfDeath setzen (verstorben bei Ulrike Neu, Kontakt 33130df1-e164-54ae-be74-a9b7d2ced11b), receiverContactId=Ulrike Neu; Abgabe-Historie: 20.04.23 als Zuchttier (0EUR, Austausch) an Clan of Black Forest (a13f62c9-d506-50a6-99f8-65e85e955f96), Rückkehr 01.04.24, dann 02.04.24 an Ulrike Neu. Gewicht 79,1g (01.04.24) für Akane anlegen. Neuen Wurf bei Clan of Black Forest mit Mutter=Akane anlegen: Töchter Merle (62,1g) und Fanella (CP-Agouti Punktschnecke, 62,7g) sowie Sohn Pete (Farbe wie Merle); Fanella ging mit Akane an Ulrike Neu. Da Quelle nur Freitext (Z21-Wurf-Notiz) ist, als manuellen Override/Import-Patch umsetzen, nicht aus bestehender Importquelle ableitbar.
- **0ba551a3-3be7-4042-8ab7-669a270602f1** [import-logic] — Sunny von PZ Karl — falscher Vater (Hiro statt Bill von Privat)
- Empfehlung: In tools/import/conflict-decisions.json eine resolution ergänzen: {"name":"Sunny von PZ Karl","dob":"10.04.2014","father":"Bill von Privat","mother":"Melly von Privat","decision":"Eltern aus Renner-Pro-3/Yurikas-Stammbaum","source":"Züchterin 2026-06-22 Ticket 0ba551a3"}. apply_conflict_decisions (tools/import/extract.py:1149-1167) setzt daraus parentRefs mit method=decision/high und überschreibt den falschen chart-position-Vater Hiro. Danach Pipeline neu: python tools/import/extract.py → merge_and_resolve.py → POST http://localhost:5179/import/ingest-resolved. Virtueller Wurf 8f1bb1d2 wird nur von Sunny genutzt, Änderung ist isoliert. Beim Schließen Changelog: "Bei Sunny von PZ Karl war der Vater falsch (stand Hiro of Golden Lights). Korrekt sind jetzt Vater Bill von Privat und Mutter Melly von Privat. Bitte den Stammbaum neu laden."
- **f77aa4a6-ecb5-4e29-960e-bdd22d327e19** [import-data] — Danielle: Louis+Roswitha sind Großeltern, echte Mutter ist Ella (Geschwisterverpaarung)
- Empfehlung: conflict-decisions.json-Eintrag für name='Danielle von den Kleinen Chaoten', dob='04.03.2020', mother='Ella' — disambiguiert per Ellas dob 10.06.2019 (Tochter von Louis+Roswitha, ebc87c8c), NICHT die zweite Ella *13.04.2023. Vater nicht setzen (unbekannter Bruder). Da zwei gleichnamige Ella existieren, muss der Eltern-Override in merge_and_resolve.py die richtige per Geburtsdatum/ID wählen (ggf. motherDob/motherId-Feld ergänzen). Danach merge_and_resolve.py + POST /import/ingest-resolved. Roswitha rückt damit auf Großeltern-Ebene. Schwester-Ticket #21 (4692fd5c) betrifft denselben Wurf — gebündelt mit demselben Override schließen.
### Cluster: genetics-schecke-spsp (1)
- **e09d6f22-2658-435e-8ef3-7453e366ee65** [import-logic] — Namenloses Tier (*13.08.2025): faelschlich als Schecke berechnet, soll spsp sein
- Empfehlung: Sp-Locus dieses Tieres auf spsp korrigieren (Soll-Genotyp "aa Cc[chm] D- ee[f] Gg Pp spsp", Anzeige ohne "Schecke"). Mechanik: merge_and_resolve.py:471-485 schreibt bei is_schecke den Genotyp auf Spsp um; entweder clean_color_name()-Schecke-Erkennung fuer diese Zeile korrigieren ODER den Quell-Genotyp-Sp-Locus nicht durch das Farblabel ueberschreiben. Wichtig: Standard-Override conflict-decisions.json matcht per name+dob (extract.py:1088-1133), aber das Tier ist NAMENLOS (externalRef stammbaum-unbekannt-13082025-3) -> name+dob nicht eindeutig; daher Override-Schluessel um externalRef erweitern oder die Heuristik fixen. Danach extract -> merge_and_resolve -> POST /import/ingest-resolved.
### Cluster: genetics-farbschlag (3)
- **5826e8e2-bc83-4572-a09d-9b51ac6e59e9** [genetics-engine] — Genetik: ef/e (Fuchsschimmel) wird als Rotaugenschimmel statt Goldfuchsschimmel errechnet
- Empfehlung: Katalog-Matching in catalog.ts E-Locus-zygotie-bewusst machen: ef/ef -> Schimmel-Eintraege (Rotaugenschimmel/Silberschimmel/Orangeschimmel), ef/e -> Fuchsschimmel-Eintraege (Goldfuchsschimmel etc.). Konkret ein Fuchsschimmel-Token (oder eFamily-Nutzung) einfuehren, sodass Schimmel-Basiseintraege bei ef/e nicht mehr greifen. Tests in genetics.test.ts erweitern: efef pp -> Rotaugenschimmel bleibt; ef/e (ee[f]) + A_ + pp -> Goldfuchsschimmel. Backend-Farbschlag-Mirror und tools/import/genotype.py mitziehen. Separat (import-data): gespeichertes colorVarietyId='Gold' ist ebenfalls falsch importiert.
- **1aac054f-3798-45af-868d-8f905bc90ca1** [import-data] — Namenloses Weibchen (*13.08.2025): gespeicherter Farbschlag "Gold" falsch — korrekt ist berechneter "Kohlfuchsschimmel Schecke"
- Empfehlung: Gespeicherten Farbschlag des Tieres 8bc0445e von 'Gold' auf den berechneten Wert 'Kohlfuchsschimmel' korrigieren (Schecke ist Modifier). Quelle des Fehlers: Import las 'Farbschlag' aus 'Stammbaum von Martin.xlsx'. Mechanik: conflict-decisions.json hat ein farbschlag-Override (Match per name+dob), aber das Tier ist namenlos — Override-Key fuer namenlose Tiere pruefen (ggf. ueber externalRef 'stammbaum-unbekannt-13082025-2'). Sauberere Alternative: colorVarietyId bei vorhandenem Genotyp aus genotypeToFarbschlag denormalisiert ableiten. Betroffen: tools/import/merge_and_resolve.py, tools/import/conflict-decisions.json, Referenz gerbil-manager-web/src/genetics/catalog.ts.
- **998087e2-1265-41a3-a7d5-13ab11576325** [import-logic] — Farbschlag falsch (Agouti statt Dilute Agouti) bei namenlosem dd-Weibchen
- Empfehlung: resolve_color_and_genotype (tools/import/merge_and_resolve.py:455-486) leitet colorVarietyId namensbasiert aus der Quell-Farbschlag-Zelle ab (clean_color_name 400-453, Mapping "a"->"agouti"), nicht aus dem Genotyp - deshalb wird das dd (Dilute) ignoriert. Empfehlung: Wenn ein Genotyp vorhanden ist, colorVarietyId aus dem genotyp-berechneten Farbschlag ableiten (Port der TS-Logik aus gerbil-manager-web/src/genetics/catalog.ts genotypeToFarbschlag) und bei Konflikt Genotyp gewinnen lassen + Provenance-Hinweis. Mindestens fuer dieses Tier colorVarietyId auf 00000000-0000-0000-0000-000000000029 (Dilute Agouti) setzen. Danach merge_and_resolve.py neu laufen + POST /import/ingest-resolved. Gleiche Klasse wie Cluster genetics-farbschlag (#3, #24).
### Cluster: color-scheme (3)
- **3f5942a2-60c4-4353-809a-77ee6071dd03** [import-logic] — Goldfuchs (ee) falsch als "Gold" (EE) klassifiziert
- Empfehlung: In tools/import/merge_and_resolve.py, resolve_color_and_genotype() (Z.464-468): Substring-Fallback nicht beim ersten Treffer abbrechen. Stattdessen exakte Treffer priorisieren und unter Substring-Kandidaten den längsten/spezifischsten wählen (z.B. Kandidaten sammeln, nach Länge absteigend sortieren). So wird "Goldfuchs" statt "Gold" gematcht. Danach extract.py + merge_and_resolve.py neu laufen lassen und POST /import/ingest-resolved. Erwartet korrigiert: Jill (235c6a7fedde), 2e7efb127308, bf6f4507 (98f77953d2d1), Banjo of Fiomi (b0e0081ce25d), Christine/Chrissi (c3995ebd0d15) — alle Fuchs-Genotyp ee, aktuell Farbschlag Gold/0012, sollen Goldfuchs/0014 (bzw. passende Fuchs-Variante) werden.
- **06217eb3-7521-4533-ab3d-4bb425f08aa5** [import-logic] — Dilute Anthrazit (ohne Namen, w) — gespeicherter Farbschlag "Anthrazit" statt Dilute Anthrazit
- Empfehlung: Punktfix: colorVarietyId des Tiers aa7978ed von 00000000-0000-0000-0000-000000000009 (Anthrazit) auf 00000000-0000-0000-0000-000000000032 (Dilute Anthrazit) setzen. Systemisch: resolve_color_and_genotype in tools/import/merge_and_resolve.py so erweitern, dass bei vorhandenem Genotyp der genotyp-abgeleitete Farbschlag Vorrang/Plausibilitaetspruefung gegenueber dem reinen Quell-Textlabel hat (insb. dd=Dilute vs. nicht-dilute Label), dann Import neu generieren + POST /import/ingest-resolved. Engine (catalog.ts/phenotype.ts) ist korrekt und nicht zu aendern.
- **3deab547-ece1-4de5-8989-f90c79580b7e** [genetics-engine] — Farbschlag: Dilute CP-Blaufuchs wird als "Unbekannt"/"blau" angezeigt
- Empfehlung: In gerbil-manager-web/src/genetics/catalog.ts den Eintrag 'Dilute Blaufuchs' (Zeile 116) von tokens.P:'p' auf tokens.P:'P' aendern (Dilution dd ist unabhaengig vom Augenpigment-Locus P; das Basis-Blaufuchs Z.75 ist bereits P). Danach resolved die Engine den gespeicherten Genotyp aa c[chm]c[chm] dd ee gg P- korrekt zu 'Dilute CP-Blaufuchs (Schecke)' statt 'Unbekannter Farbschlag'. Zusaetzlich pruefen, ob weitere Dilute-*-Eintraege faelschlich P:'p' tragen (z.B. 'Dilute Topas' Z.115) und ggf. mit ihren Nicht-Dilute-Basisfarben abgleichen. Verifiziert per vitest-Patch.
### Cluster: genetics-farbschlag-schimmel-klammer (1)
- **e22764aa-9801-4e02-83c7-5b763652973a** [import-logic] — Blaufuchsschimmel statt Blaufuchs (Klammer-Schimmel = "moeglich, unbekannt")
- Empfehlung: In tools/import/merge_and_resolve.py clean_color_name (Z.412-415) die parenthetische Variante "(schimmel)" NICHT mehr zu definitivem "schimmel" anhaengen, sondern strippen und als "Schimmel moeglich/unbestimmt" fuehren -> Farbschlag auf Blaufuchs (Variety ...020) aufloesen statt Blaufuchsschimmel (...037); den Moeglich-Schimmel-Hinweis separat (Provenance/Flag) ablegen, damit die App "Blaufuchs (Schimmel moeglich, klaert sich mit ~2 J)" zeigen kann. Konsistent mit Genotyp ee[-] (Fuchs, Schimmel-Modifier unbekannt). Betrifft nur dieses 1 Tier (einziges mit (schimmel)-Klammer im Quelldatensatz). Danach extract/merge_and_resolve neu + POST /import/ingest-resolved. Abgrenzung: NICHT Teil von #42 (Engine ee[-]->ee) oder #37-41 (Punnett-Wildcard) - hier Import-Farbnamen-Aufloesung.
### Cluster: genotype-engine (4)
- **cc9ea3fe-db44-46e3-85b6-ab9f34c4433f** [genetics-engine] — Mamta Mini: E-Locus Ee[-] statt Ee (Vererbung vom ee-Vater Geely)
- Empfehlung: Eltern-basierte Allel-Inferenz/Validierung in der Genetik-Engine ergaenzen: pro Locus pruefen, ob ein Elternteil homozygot ist und damit ein Kind-Allel erzwingt; unbekannte Allele ([-]/?) entsprechend aufloesen. Konkret Mamta Mini (da911047) E-Locus 'Ee[-]' -> 'Ee', weil Vater Geely (b71c0fda) homozygot 'ee' ist und nur 'e' vererben kann. Implementieren in gerbil-manager-web/src/genetics (genotype.ts/breed.ts/punnett.ts/warnings.ts) als resolveFromParents/validateAgainstParents mit Hinweis-Chip in der Tier-Akte; optional zusaetzlich im Import (tools/import/genotype.py, merge_and_resolve.py) den gespeicherten Genotyp vervollstaendigen. Vorsicht: Mamta Minis Wurf 23-Wurf (b4b6ffcf) hat derzeit keine verlinkten Eltern in der DB - Inferenz braucht die korrekte Eltern-Verknuepfung. Identisch zu #38 (1a508c04), gebuendelt umsetzen.
- **efa2b232-71f9-4fac-aba6-9100a3d6037e** [genetics-engine] — Farbschlag-Berechnung (Genetik-Engine): aa cchm dd ee gg → Dilute CP-Blaufuchs
- Empfehlung: Genetik-Engine/-Katalog (gerbil-manager-web/src/genetics/catalog.ts): (1) Im aa-Colourpoint-Zweig von colourpointName bzw. in BASE_COLORS eine aa-dd-ee-gg-Fuchs-Basis fuer schwarzaeugige Tiere (P) ergaenzen, sodass aa cchm dd ee gg → "Dilute CP-Blaufuchs" statt "Dilute CP-Anthrazit" erzeugt wird. (2) Unbekanntes E-Allel (ee[-]) so behandeln, dass die Fuchs-Familie nicht durch Default-auf-dominantes-E verdeckt wird (phenotype.ts/loci.ts). Zielwert "Dilute CP-Blaufuchs" stammt aus der Antwort der Zuechterin im Schwester-Ticket 3deab547 — keine neue Rueckfrage noetig.
- **1a508c04-018b-4b93-bf7f-f820fbe9433e** [genetics-engine] — Mamta Mini: E-Locus Ee[-] muss per Vererbung zu Ee aufgeloest werden
- Empfehlung: Eltern-basierte Allel-Inferenz/Validierung in der Genetics-Engine ergaenzen (z.B. resolveFromParents/validateAgainstParents): pro Locus pruefen, ob ein Elternteil homozygot ist und damit ein Kind-Allel erzwingt; unbekannte Allele ([-]/-) entsprechend aufloesen (Ee[-] -> Ee, da Geely homozygot ee). In der Tier-Akte das aufgeloeste Allel + Hinweis-Chip anzeigen. Eltern-Genotypen ueber den Stammbaum/Pedigree beziehen (Mamta Minis gespeicherte litter b4b6ffcf hat keine father/motherId; Geely+Gaida kommen aus der chart-position-Heuristik). Optional schon im Import (tools/import/genotype.py, merge_and_resolve.py) anwenden. Betroffen: gerbil-manager-web/src/genetics/genotype.ts (Ee[-]-Notation, Z.190-275), breed.ts, punnett.ts, warnings.ts. SEPARAT halten von den Wildcard-Expansions-Tickets #37/#39/#41 (punnett.ts parentAlleleWeights, erwartete Wurf-Farbschlaege) - hier geht es um Rueck-Inferenz eines GESPEICHERTEN Genotyps.
- **3e643ef1-6998-45c2-a3e1-45dff666a7c5** [genetics-engine] — Genetik-Engine: Wildcard-Allel (D-/E-) erzeugt unmoegliche Dilute-/Unbekannt-Farbschlaege im Wurf
- Empfehlung: In punnett.ts parentAlleleWeights: ein unbekanntes Allel '?' (aus '-') neben einem bekannten Allel als KOPIE des bekannten dominanten Allels behandeln (A-=AA, D-=DD, E-=EE), statt es ueber unknownPartnerOptions auf gleich-/rezessivere Allele zu streuen. Nur wenn beide Allele unbekannt sind -> Locus voll unbestimmt (dominantestes Allel, konsistent mit phenotype.ts/catalog.ts resolvedPair). Damit fallen Dilute (dd), efef und 'Unbekannter Farbschlag' aus dieser Verpaarung weg. Tests in gerbil-manager-web/src/genetics/__tests__ anpassen. Betroffen: punnett.ts (parentAlleleWeights, unknownPartnerOptions), breed.ts. Loest Sibling-Tickets 3c46d0b4 (#41), 1e7b66e6 (#40), c8ce27e2 (#37) mit.
### Cluster: gender-override (1)
- **94892100-3849-4ed8-a2e9-cfcc1a2df982** [import-data] — Cherry Berry's Quqquluuruu — Geschlecht (Weibchen-Icon fehlt, Fragezeichen)
- Empfehlung: In tools/import/conflict-decisions.json eine gender-Override-Resolution ergaenzen (Praezedenzfall: Mozart of Lennylengo, Z.270-275): {"name":"Cherry Berry's Quqquluuruu","dob":"","decision":"gender = female (Box-Farbe weiss nicht als Geschlecht erkannt; Vorfahre ohne Geburtsdatum)","gender":"female","source":"Zuechterin 2026-06-22 — Ticket Weibchen-Icon fehlt"}. dob MUSS leer ("") sein, da das Tier kein Geburtsdatum hat und der Override-Key (canon_name + norm_dob) sonst nicht matcht. Verbraucht in extract.py _apply_conflict_decisions Z.1100-1148. Danach extract.py -> merge_and_resolve.py neu laufen lassen, App neu starten, POST /import/ingest-resolved, anschliessend Ticket schliessen.
### Cluster: rpro3-pedigree (1)
- **f3ad5ec9-7ba5-4e9f-a503-f0afab42d27c** [import-logic] — Hiro of Golden Lights — Stammbaum fehlt (Eltern nur in RennmausPro)
- Empfehlung: Import-Pipeline um eine RennmausPro-Ahnen-Quelle erweitern: neuer Extractor liest _rpro3.db (stamm_tb=eigene Tiere via Name+DOB-Match, baum_tb=Eltern-Links id->_MID/_PID, fremd_tb=externe Ahnen mit u-Präfix); JDN-Datumskonvertierung + smart_decode aus tools/import/compare_rpro3.py wiederverwenden. In merge_and_resolve.py externe Ahnen als nicht-residente Gerbils (isResident=false, deterministische generate_guid auf stabilem rpro-Key) anlegen, Eltern über Litter (fatherId/motherId) verknüpfen, parent_age_plausible anwenden, Provenance-Eintrag 'aus RennmausPro III ergänzt'. Danach resolved_import.json neu generieren + POST /import/ingest-resolved. Betrifft potenziell viele Tiere -> als Cluster behandeln. Betroffene Dateien: tools/import/extract.py (bzw. neuer extract_rpro3.py), tools/import/merge_and_resolve.py.
### Cluster: origin-label (1)
- **b1629f97-3e06-4ef1-9605-95d7492eb352** [import-logic] — Hagrid Rubeus of Black Forest — Herkunft/Zuchtname sollte "Clan of Black Forest" sein
- Empfehlung: In tools/import/merge_and_resolve.py Zeile 204 den Wert von "blackforestgv" von "Black Forest" auf "Clan of Black Forest" ändern. Danach `python tools/import/merge_and_resolve.py` neu generieren und `POST http://localhost:5179/import/ingest-resolved` ausführen. Effekt: der separate Kontakt "Black Forest" (id 2f08f4ef-bfce-5323-aaa6-f1eedfb4c0f7) verschmilzt mit dem bereits existierenden Kontakt "Clan of Black Forest" (id a13f62c9-d506-50a6-99f8-65e85e955f96); die 31 Tiere mit Suffix "of Black Forest" zeigen dann originBreeder="Clan of Black Forest" und verlinken auf den einen Kontakt. Prüfen, dass keine Tiere zurückbleiben, die noch auf den alten "Black Forest"-Kontakt zeigen. Hinweis: Ticket-gerbilId 73759a6a (Hagrid Rubeus) existiert nach Re-Import nicht mehr; Verifikation erfolgte über Namens- und Kontakt-Abgleich.
### Cluster: duplicate-merge (1)
- **f618dcc3-31c5-4d41-9517-8e1cf422562b** [import-logic] — Namenloser Bock *15.02.2024 (Kind von Inochi gen. Picu) doppelt aufgeführt
- Empfehlung: Namenlose Tiere werden in merge_and_resolve.py (Z.2518-2532) als Placeholder bewusst NIE gemerged, und name-basierte conflict-decisions greifen ohne Namen nicht. Daher einen externalRef-basierten Merge-Override einführen (neues Feld z.B. 'mergeExternalRefs' in conflict-decisions.json), der den Placeholder-Ausschluss für explizit gepaarte Records überbrückt: (a) Kind 13944d01 in 5b5af108 mergen (primary = Picus-Son-Record); (b) Mutter 1ddfceaf in 6864eaef mergen (oder DOB-Remap 13.11.2022->13.11.2023 ebenfalls externalRef-basiert); (c) Folge: die zwei virtuellen Würfe 48bb7715 und 6430018f kollabieren zu einem (gleicher Vater+Mutter+Datum). Betroffen: tools/import/merge_and_resolve.py (Placeholder/Sub-Group Z.2504-2590), tools/import/extract.py (Override-Lader Z.1051/1088), tools/import/conflict-decisions.json. Danach extract.py + merge_and_resolve.py neu laufen lassen + POST /import/ingest-resolved. Schließ-fixNote-Vorschlag (Laiensprache): "Der namenlose Bock (geboren am 15.02.2024, Kind von Inochi gen. Picu) war versehentlich doppelt eingetragen — jetzt gibt es nur noch einen Eintrag und einen Wurf. Bitte die Seite neu laden."
### Cluster: litter-expected-colors (1)
- **c8ce27e2-8ebe-4662-a816-7899be9c6a82** [genetics-engine] — Wurf-Detail: erwartete Farbschläge falsch (unbekannte Allele erzeugen Phantom-Farben)
- Empfehlung: Erwartete-Farbschlag-Liste in der Genetik-Engine bereinigen: (1) winzige (<1%) spekulative Wildcard-Artefakte, die nur aus unbekannten Eltern-Allelen (D-, P-) entstehen, nicht prominent als 'erwartet' anzeigen, sondern aggregieren oder unter 'unsicher' zusammenfassen; (2) 'Unbekannter Farbschlag' und technische Pseudo-Farben wie 'C-Separator' aus der erwarteten-Liste herausfiltern (Catalog-Gap #39); (3) optional Hinweis 'Eltern tragen unbekannte Allele (D-/P-), erwartete Farben daher unsicher'. Kern: parentAlleleWeights/unknownPartnerOptions in punnett.ts laesst das unbekannte Partner-Allel an D-/P- rezessiv werden (rank(d)>=rank(D)); da beide Eltern D-/P- tragen, entstehen dd/pp -> Dilute/Blau/Platin-Phantome. Betroffen: gerbil-manager-web/src/genetics/punnett.ts, breed.ts (byFarbschlag-Aggregation/Filter), catalog.ts (Unbekannter-Farbschlag-Gap), pages/WurfDetailPage.tsx + components/BreedingResultView.tsx.
## Rückfragen (NeedsInfo, warten auf Züchterin)
- b8f8b6fb-5d68-4c15-bd58-daee382ea9af — Falscher Name "1.) Butter2.) Trixi" — Vergesellschaftungs-Partner statt Tiername
- 8fe0e93e-45f2-41e7-b27d-aa3cd0bb15e3 — Sunny von PZ Karl — falscher Vater (chart-position geraten)
- d99563fa-a5cf-43da-aaa6-78bd4b677cb2 — Falsches Foto bei Odelia von den Kleinen Chaoten
- 38c26381-ad17-4255-b480-b2ebee3f2133 — Odelia „Falsche Eltern!!" (Cash × Elena)
- 4692fd5c-5929-435b-b7b4-75ccb7ba3e08 — Danielle falsche Mutter, Vater fehlt (Stammbaum)
- ba63325a-4c23-4c5b-b52a-c0dc091bc59b — Gaida: falsche Eltern - echte Eltern (Kinder von Nisha & Zenon) fehlen in den Daten
- 7bbc045c-a23b-4d1d-905f-18b087fe90aa — Catelyn Stark — Eltern (Vater fehlt, Mutter vermutlich falsch)
- 1e7b66e6-e46b-49ff-acab-579e9088f859 — Erwartete Farbschläge/Gencodes am Wurf Mamta Mini + Gold
## Bereits behoben (geschlossen mit Changelog)
- f92a9650-eb2b-4298-87aa-0a230ee0aeff — Stich von Privatzucht Gießen — Mutter bekannt
- e9790985-306d-4f32-94f1-a02a9469e3ad — Arya Stark — Abgabe an Clan of Black Forest / Bestandsanzeige
- 50b17699-7113-4348-9d46-95abe2d2b239 — Bill von Privat — Eltern unbekannt (Stammbaum)
- bee684ed-ced6-408b-825c-4248f3bf17cf — Melly von Privat — Eltern unbekannt
- aa530d1b-1811-469d-990a-d834a9cb65b7 — Beatrice von den kleinen Chaoten — "Eltern fehlen" im Stammbaum
- 671f8b5d-e5ce-4b47-aafa-db96fa180e37 — Silver: Vater Taro + Mutter Beatrice-Stammbaum (missing-parents)
- e09bf10e-1a72-494a-92c0-6824129eebf0 — Tony v.d. Kleinen Chaoten — Vater Sammy
- b46d5807-fe23-4922-b09e-dffaf411177c — Cooky vom Zooladen (OBI): Eltern unbekannt (Zooladen-Gruendertier)
- 32de49fe-db33-4b14-bb1e-55ba19b2ed1c — Arya Stark — falsche Mutter (Eltern Sansa Stark + Vance, Geschwisterverpaarung)
- dfaae8fb-6f16-45d9-bf30-3b3480325188 — Hagrid Rubeus of Black Forest — externer Ahne zeigte fälschlich Würfe
- d7189414-5028-4e20-838d-8e6fcf09f8a0 — Hagrid Rubeus of Black Forest — Eltern (Snickers + Milka)
- b3908b69-eddc-4cb1-984c-37fe18e19f17 — Hagrid Rubeus of Black Forest — Charakter/Eigenschaften bei externem Vorfahren ausblenden
- 4493fd8a-1136-4c1c-a6a6-9a7de45a5a8c — Yuki von den Kleinen Chaoten — falscher Vater, Eltern Camaro & Izumi
- ce404b2d-77a9-44bf-80fc-9750b6312fce — Belica gen. Emi - Mutter Hana
- 684f5b7d-8d20-4159-80b2-16b3387b78da — Zadar from Zeko i ptica, Croatia Eltern unbekannt (Stammbaum)
- 1a51dc7a-05bf-4eac-bfbc-0b6b53e619fc — Theodore von den Kleinen Chaoten — Vater BlackFire
- 864f0c55-5a84-4a99-8314-dfa3496dfee0 — Jamie: Vater = Danny von Privatzucht Maintal
- 473dc345-a610-42b7-b897-121716b36cf4 — Vance: "unbekannter Farbschlag" statt Kohlfuchs
- 46c635ba-53da-4196-a486-106a2adce1e4 — Vance: fehlende Mutter (sollte Enya sein)
- 5151ab20-147c-4e44-b57e-7a28fe962d85 — Vance: Genotyp zeigte "uw" statt G-Locus
- 81a3126b-0414-49a0-ae91-1ba775b2766f — Zac gen. Action — fehlende Mutter (Dorie of Black Forest)
- 2cd5c28f-a353-4477-ac15-9ee78f8c73a9 — Gold v.d. Kleinen Chaoten — Muttertier (Chelsea)
- 3c46d0b4-3cfc-4e7c-ad93-36f45b288410 — E-Locus: Schimmel (efef) faellt bei Mamta Mini x Gold
- b034ddd2-9df7-4d36-8c79-957f30cc19f7 — Algierfuchs / E-Locus e- Validierung (ee[-])

View File

@@ -0,0 +1,49 @@
/** EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier — Tab in der Rennmausakte. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.tierTabs.exhibitions
const ta = de.pages.tierTabs.actions
const tabs = de.pages.gerbils.detail.tabs
test('Rennmausakte: Ausstellungsergebnis anlegen, sehen und löschen', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Auf den "Ausstellungen"-Tab wechseln.
await page.getByRole('tab', { name: tabs.exhibitions }).click()
// Leerzustand sichtbar.
await expect(page.getByText(t.empty)).toBeVisible()
// Neues Ergebnis erfassen.
await page.getByRole('button', { name: new RegExp(t.newButton) }).click()
await page.getByLabel(new RegExp(t.fields.eventName)).fill('Nationale Rennmausschau 2026')
await page.getByLabel(t.fields.placement).fill('1. Platz')
await page.getByLabel(t.fields.award).fill('Best in Show')
await page.getByRole('button', { name: ta.save, exact: true }).click()
// Ergebnis erscheint in der Liste.
await expect(page.getByText('Nationale Rennmausschau 2026')).toBeVisible()
await expect(page.getByText('1. Platz')).toBeVisible()
// Wurde im Mock mit Tier-Bezug gespeichert.
expect(mockDb).not.toBeNull()
const row = mockDb!.exhibitions.find((r) => r.eventName === 'Nationale Rennmausschau 2026')
expect(row).toMatchObject({ gerbilId: 'kruemel', placement: '1. Platz', award: 'Best in Show' })
// Löschen (window.confirm automatisch annehmen).
page.once('dialog', (d) => void d.accept())
await page.getByRole('button', { name: ta.delete }).click()
await expect(page.getByText(t.empty)).toBeVisible()
})
test('Rennmausakte: leere Veranstaltung wird abgelehnt (Validierung)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
await page.getByRole('tab', { name: tabs.exhibitions }).click()
await page.getByRole('button', { name: new RegExp(t.newButton) }).click()
// Ohne Veranstaltung speichern → Validierungsfehler, kein Eintrag.
await page.getByRole('button', { name: ta.save, exact: true }).click()
await expect(page.getByText(t.validation.eventNameRequired)).toBeVisible()
})

View File

@@ -25,6 +25,35 @@ test.describe('Becken', () => {
await expect(page.getByText(tb.delete.conflict)).toBeVisible()
})
test('Reinigungszyklus: überfälliges Becken zeigt Hinweis + Als gereinigt markieren', async ({ page }) => {
skipUnlessMock()
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
// Maße + Kapazität werden angezeigt
await expect(page.getByText('120×50 cm')).toBeVisible()
// Reinigung ist überfällig (nextCleaningDate in der Vergangenheit) -> Warn-Hinweis
await expect(page.getByText(/Reinigung fällig/)).toBeVisible()
// Als gereinigt markieren -> letzte Reinigung = heute, Hinweis verschwindet
await page.getByRole('button', { name: tb.cleaning.markCleaned }).click()
await expect(page.getByText(/Reinigung fällig/)).toBeHidden()
})
test('Reinigungszyklus: Becken mit Zyklus anlegen zeigt nächste Reinigung', async ({ page }) => {
await gotoSection(page, de.nav.enclosures)
await page.getByRole('link', { name: tb.newButton }).click()
const name = uniqueName('Zyklusbecken')
await page.getByLabel(`${tb.fields.name} *`).fill(name)
await page.getByLabel(tb.fields.size).fill('60×40 cm')
await page.getByLabel(tb.fields.capacity).fill('4')
await page.getByLabel(tb.fields.lastCleanedDate).fill('2026-06-01')
await page.getByLabel(tb.fields.cleaningCycleDays).fill('14')
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
// nächste Reinigung = 2026-06-01 + 14 Tage = 15.06.2026 (in der def-list-Zeile)
await expect(page.getByText('15.06.2026', { exact: true })).toBeVisible()
})
test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => {
await gotoSection(page, de.nav.enclosures)
await page.getByRole('link', { name: tb.newButton }).click()

View File

@@ -0,0 +1,45 @@
/** ERWERB: Erwerb/Kauf je Tier — Sektion in der Rennmausakte (anlegen / bearbeiten / löschen). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.tierTabs.acquisition
test('Tierakte: Erwerb erfassen, bearbeiten und löschen', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
const section = page.locator('section.ak-card', { hasText: t.sectionTitle })
await expect(section).toBeVisible()
await expect(section).toContainText(t.empty)
// Anlegen: Formular öffnen, Felder füllen, speichern.
await section.getByRole('button', { name: t.addButton }).click()
await section.getByLabel(t.fields.date).fill('2025-03-14')
await section.getByLabel(t.fields.price).fill('25.50')
await section.getByLabel(t.fields.note).fill('Auf der Börse gekauft.')
await section.getByRole('button', { name: t.addButton }).click()
// Eintrag erscheint in der Liste, im Mock gespeichert.
await expect(section).toContainText('25,50')
await expect(section).toContainText('Auf der Börse gekauft.')
expect(mockDb).not.toBeNull()
expect(mockDb!.acquisitions.length).toBe(1)
expect(mockDb!.acquisitions[0]).toMatchObject({
gerbilId: 'kruemel',
date: '2025-03-14',
price: 25.5,
note: 'Auf der Börse gekauft.',
})
// Bearbeiten: Preis ändern.
await section.getByRole('button', { name: t.edit }).click()
await section.getByLabel(t.fields.price).fill('30')
await section.getByRole('button', { name: t.saveButton }).click()
await expect(section).toContainText('30,00')
expect(mockDb!.acquisitions[0]).toMatchObject({ price: 30 })
// Löschen (window.confirm bestätigen).
page.once('dialog', (d) => d.accept())
await section.getByRole('button', { name: t.delete }).click()
await expect(section).toContainText(t.empty)
expect(mockDb!.acquisitions.length).toBe(0)
})

View File

@@ -0,0 +1,150 @@
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const f = de.feedback
const st = de.pages.stammbaum
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
// Debug-Kontext ist sichtbar (automatisch erfasst).
await expect(dialog).toContainText(f.debugTitle)
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
// Beschreibung eintragen + senden.
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
// Erfolgs-Toast + Dialog schließt.
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert (zuletzt eingereichter Eintrag;
// die Mock-DB ist mit Beispiel-Tickets vorbefüllt).
expect(mockDb).not.toBeNull()
const report = mockDb!.feedback.find((r) => r.message === 'Der Farbschlag stimmt nicht.')
expect(report).toMatchObject({
message: 'Der Farbschlag stimmt nicht.',
context: 'gerbil-detail',
gerbilId: 'kruemel',
})
expect(report!.url).toContain('/rennmaeuse/kruemel')
expect(typeof report!.clientTimestamp).toBe('string')
})
test('Entwurf bleibt nach Schließen + Navigation + Wiederöffnen erhalten', async ({ page }) => {
skipUnlessMock()
// Auf einer Tier-Seite anfangen zu schreiben …
await page.goto('/rennmaeuse/kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await dialog.getByLabel(f.label).fill('Halb fertiger Text, will kurz was nachschauen …')
// … Fenster (versehentlich) schließen …
await dialog.getByRole('button', { name: f.close }).click()
await expect(dialog).not.toBeVisible()
// … in der App woanders hin navigieren (anderer Kontext) …
await page.goto('/wuerfe/w-kruemel')
// … „Fehler melden" erneut öffnen: der Text ist noch da.
await page.getByRole('button', { name: f.button }).click()
const dialog2 = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog2.getByLabel(f.label)).toHaveValue('Halb fertiger Text, will kurz was nachschauen …')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['litter-detail'])
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const report = mockDb!.feedback.find((r) => r.message === 'Die Wurfstärke ist falsch.')
expect(report).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['contact-detail'])
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const report = mockDb!.feedback.find((r) => r.message === 'Die Adresse stimmt nicht.')
expect(report).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Rechtsklick auf die Wurzelkarte (Krümel).
await page.locator('.pedigree-card--root').click({ button: 'right' })
const menu = page.locator('.stammbaum-context-menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts.stammbaum)
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const report = mockDb!.feedback.find((r) => r.message === 'Stammbaum-Bug.')
expect(report).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
skipUnlessMock()
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
// beschränkt (validiert dieselbe Logik).
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
await page.goto('/rennmaeuse/kruemel/stammbaum')
const card = page.locator('.pedigree-card--root')
await expect(card).toBeVisible()
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
const dispatched = await card.evaluate((el) => {
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
const r = el.getBoundingClientRect()
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
el.dispatchEvent(
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
)
return true
})
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
await expect(
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
).toBeVisible()
})

View File

@@ -261,6 +261,22 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
// Gehege-Reinigungszyklus: nächste fällige Reinigung = letzte Reinigung + Zyklus (Tage).
const nextCleaning = (lastCleaned?: string | null, cycleDays?: number | null): string | null => {
if (!lastCleaned || cycleDays == null || cycleDays <= 0) return null
const d = new Date(`${lastCleaned}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + cycleDays)
return d.toISOString().slice(0, 10)
}
// "Als gereinigt markieren": setzt letzte Reinigung auf heute (wie das echte Backend).
m = path.match(/^\/enclosures\/([^/]+)\/mark-cleaned$/)
if (m && method === 'POST') {
const enc = db.enclosures.find((x) => x.id === m![1])
if (!enc) return json(route, 404, { title: 'Not Found' })
enc.lastCleanedDate = new Date().toISOString().slice(0, 10)
enc.nextCleaningDate = nextCleaning(enc.lastCleanedDate, enc.cleaningCycleDays)
return json(route, 200, enc)
}
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
if (m) {
const isKruemel = m[1] === 'kruemel'
@@ -363,6 +379,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
fileName: `vertrag-${id}.docx`,
createdAt: new Date().toISOString(),
url: `/contracts/${id}/file`,
hasFile: true,
}
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
// Tiere auf GivenAway setzen (wie das echte Backend)
@@ -397,6 +414,415 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result)
}
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
status: 'Open',
resolvedAt: null,
question: null,
answer: null,
answeredAt: null,
fixNote: null,
agentContext: null,
thread: [],
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
const fm = path.match(/^\/feedback\/([^/]+)$/)
if (fm) {
const fid = decodeURIComponent(fm[1])
const idx = db.feedback.findIndex((f) => f.id === fid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as {
message?: string
status?: string
question?: string
answer?: string
fixNote?: string
agentContext?: string
}
const row = db.feedback[idx]
if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim()
}
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene
// Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben.
if (typeof body.question === 'string') {
const q = body.question.trim()
if (q.length > 0) {
const thread = (row.thread as Record<string, unknown>[] | undefined) ?? []
if (typeof row.question === 'string' && row.question)
thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null })
if (typeof row.answer === 'string' && row.answer)
thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null })
row.thread = thread
row.answer = null
row.answeredAt = null
row.question = q
if (row.status !== 'Resolved') {
row.status = 'NeedsInfo'
row.resolvedAt = null
}
} else {
row.question = null
}
}
// Antwort der Züchterin → Answered + answeredAt.
if (typeof body.answer === 'string') {
const a = body.answer.trim()
if (a.length === 0) {
row.answer = null
row.answeredAt = null
} else {
row.answer = a
row.answeredAt = new Date().toISOString()
row.status = 'Answered'
row.resolvedAt = null
}
}
if (typeof body.status === 'string') {
const s = body.status
const resolved = s.toLowerCase() === 'resolved'
row.status = resolved
? 'Resolved'
: s === 'NeedsInfo' || s === 'Answered'
? s
: 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
}
// Changelog (fixNote) — laienverständlich, sichtbar.
if (typeof body.fixNote === 'string') {
const note = body.fixNote.trim()
row.fixNote = note.length === 0 ? null : note
}
// Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht.
if (typeof body.agentContext === 'string') {
const c = body.agentContext.trim()
row.agentContext = c.length === 0 ? null : c
}
return json(route, 200, row)
}
if (method === 'DELETE') {
db.feedback.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// RPRO3: RennmausPro-III-Backup-Import (multipart-Upload). Mock liefert eine feste
// Auswertung bzw. ein Import-Ergebnis — keine echte Datei-Verarbeitung.
if (path === '/import/rpro3/analyze' && method === 'POST') {
return json(route, 200, db.rpro3.analyze)
}
if (path === '/import/rpro3/execute' && method === 'POST') {
return json(route, 200, db.rpro3.execute)
}
// ERWERB: Erwerb/Kauf je Tier — GET ?gerbilId= (Array, kein Gridify-Paging),
// POST/PUT/DELETE. Vor den generischen Kollektionen, weil GET kein {items}-Objekt
// liefert und nach gerbilId statt Gridify-filter selektiert.
const acqMatch = path.match(/^\/acquisitions(?:\/([^/]+))?$/)
if (acqMatch) {
const acqId = acqMatch[1] ? decodeURIComponent(acqMatch[1]) : null
if (!acqId) {
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = db.acquisitions.filter((a) => !gid || a.gerbilId === gid)
return json(route, 200, [...rows].reverse())
}
if (method === 'POST') {
const created = {
id: newId('acq'),
sourceContactId: null,
date: null,
price: null,
note: null,
...(request.postDataJSON() as Row),
createdAt: new Date().toISOString(),
}
db.acquisitions.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const ai = db.acquisitions.findIndex((a) => a.id === acqId)
if (method === 'GET') {
return ai >= 0 ? json(route, 200, db.acquisitions[ai]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(db.acquisitions[ai], request.postDataJSON() as Row)
return json(route, 204)
}
if (method === 'DELETE') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
db.acquisitions.splice(ai, 1)
return json(route, 204)
}
return json(route, 405)
}
// ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben).
if (path === '/reservations') {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.')
const nowIso = new Date().toISOString()
const created = {
id: newId('reservation'),
gerbilId: body.gerbilId,
gerbilName: body.gerbilName ?? null,
status: normStatus(body.status),
reservedForContactId: body.reservedForContactId ?? null,
contactName: body.contactName ?? null,
appointmentDate: body.appointmentDate ?? null,
price: body.price ?? null,
note: body.note ?? null,
handedOverDate: body.handedOverDate ?? null,
createdAt: nowIso,
updatedAt: nowIso,
}
db.reservations.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations]
return json(route, 200, rows.slice().reverse())
}
return json(route, 405)
}
const resm = path.match(/^\/reservations\/([^/]+)$/)
if (resm) {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
const rid = decodeURIComponent(resm[1])
const idx = db.reservations.findIndex((r) => r.id === rid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as Row
const row = db.reservations[idx]
if (typeof body.status === 'string') row.status = normStatus(body.status)
if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null
if ('contactName' in body) row.contactName = body.contactName ?? null
if ('note' in body) row.note = body.note ?? null
if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null
if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null
if ('price' in body) row.price = body.price ?? null
if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null
row.updatedAt = new Date().toISOString()
return json(route, 200, row)
}
if (method === 'DELETE') {
db.reservations.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope),
// POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil
// die GET-Antwort kein paginiertes Envelope ist.
const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/)
if (wlm) {
const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null
const ALLOWED = ['offen', 'erfuellt', 'storniert']
const normStatus = (s: unknown): string | null => {
if (s === undefined || s === null || String(s).trim() === '') return 'offen'
const v = String(s).trim()
return ALLOWED.includes(v) ? v : null
}
if (!wlId) {
if (method === 'GET') {
// Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc).
const sorted = [...db.waitingList].sort((a, b) => {
const ar = String(a.requestedAt ?? '')
const br = String(b.requestedAt ?? '')
if (ar !== br) return ar < br ? 1 : -1
return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1
})
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
const created = {
id: newId('wl'),
contactId: body.contactId ?? null,
contactName: body.contactName ?? null,
wishColor: body.wishColor ?? null,
wishGender: body.wishGender ?? null,
requestedAt: body.requestedAt ?? null,
status,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
db.waitingList.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const wlIdx = db.waitingList.findIndex((r) => r.id === wlId)
if (method === 'GET') {
return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
Object.assign(db.waitingList[wlIdx], { ...body, status })
return json(route, 200, db.waitingList[wlIdx])
}
if (method === 'DELETE') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
db.waitingList.splice(wlIdx, 1)
return json(route, 204)
}
return json(route, 405)
}
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns, GET optional ?gerbilId=).
// Eigene Route (kein Gridify-Paging — die API liefert ein flaches Array).
const ret = path.match(/^\/returns(?:\/([^/]+))?$/)
if (ret) {
const retId = ret[1] ? decodeURIComponent(ret[1]) : null
if (!retId) {
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.returns.filter((r) => r.gerbilId === gid) : db.returns
return json(route, 200, [...rows].reverse())
}
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId && !body.gerbilName) {
return json(route, 400, 'Es muss ein Tier angegeben werden.')
}
const created = { id: newId('return'), ...body, createdAt: new Date().toISOString() }
db.returns.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const idx = db.returns.findIndex((r) => r.id === retId)
if (method === 'PUT') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(db.returns[idx], request.postDataJSON() as Row)
return json(route, 200, db.returns[idx])
}
if (method === 'DELETE') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
db.returns.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST/GET ?gerbilId=, PUT, DELETE).
// Plain-Liste (kein Gridify), gefiltert per ?gerbilId, neueste zuerst.
if (path === '/exhibitions') {
if (method === 'GET') {
const gerbilId = url.searchParams.get('gerbilId')
const rows = (db.exhibitions as Row[]).filter(
(r) => !gerbilId || r.gerbilId === gerbilId,
)
const sorted = [...rows].sort((a, b) =>
String(b.date ?? '').localeCompare(String(a.date ?? '')),
)
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
const created = {
id: newId('exhibition'),
gerbilId: body.gerbilId ?? null,
entityName: body.entityName ?? null,
eventName: String(body.eventName).trim(),
date: body.date ?? null,
placement: body.placement ?? null,
award: body.award ?? null,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
;(db.exhibitions as Row[]).push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const em = path.match(/^\/exhibitions\/([^/]+)$/)
if (em) {
const eid = decodeURIComponent(em[1])
const idx = (db.exhibitions as Row[]).findIndex((r) => r.id === eid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'GET') return json(route, 200, db.exhibitions[idx])
if (method === 'PUT') {
const body = request.postDataJSON() as Row
if ('eventName' in body && !String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
Object.assign(db.exhibitions[idx], body)
return json(route, 200, db.exhibitions[idx])
}
if (method === 'DELETE') {
;(db.exhibitions as Row[]).splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend:
// Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id;
// mehrdeutig/unbekannt => id/name null.
if (path === '/refs/resolve' && method === 'POST') {
const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] }
const tableFor: Record<string, Row[]> = {
tier: db.gerbils as unknown as Row[],
kontakt: db.contacts as unknown as Row[],
wurf: db.litters as unknown as Row[],
gehege: db.enclosures as unknown as Row[],
}
const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '')
const results = (body.refs ?? []).map((r) => {
const type = (r.type ?? '').toLowerCase()
const prefix = normCode(r.code ?? '')
const rows = tableFor[type]
if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null }
const matches = rows.filter((row) =>
String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix),
)
if (matches.length === 1) {
return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') }
}
return { type: r.type, code: r.code, id: null, name: null }
})
return json(route, 200, results)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined
@@ -408,6 +834,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = { id: newId(col.idPrefix), ...body }
// Gehege: berechnetes Feld nextCleaningDate wie das echte Backend ableiten.
if (m[1] === 'enclosures') {
created.nextCleaningDate = nextCleaning(
created.lastCleanedDate as string | null,
created.cleaningCycleDays as number | null,
)
}
col.rows.push(created)
return json(route, 201, created)
}
@@ -421,6 +854,12 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(col.rows[idx], request.postDataJSON() as Row)
if (m[1] === 'enclosures') {
col.rows[idx].nextCleaningDate = nextCleaning(
col.rows[idx].lastCleanedDate as string | null,
col.rows[idx].cleaningCycleDays as number | null,
)
}
return json(route, 200, col.rows[idx])
}
if (method === 'DELETE') {

View File

@@ -55,6 +55,8 @@ export interface MockContract {
createdAt: string
gerbilIds: string[]
url: string
/** Ob die Word-Datei vorliegt. Importierte Verträge: false (kein Word-Button). */
hasFile: boolean
}
export interface MockDb {
@@ -76,6 +78,23 @@ export interface MockDb {
saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
// RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs.
rpro3: {
analyze: Record<string, unknown>
execute: Record<string, unknown>
}
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions
acquisitions: Record<string, unknown>[]
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations)
reservations: Record<string, unknown>[]
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
waitingList: Record<string, unknown>[]
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns)
returns: Record<string, unknown>[]
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST /exhibitions)
exhibitions: Record<string, unknown>[]
}
function gerbil(
@@ -120,6 +139,27 @@ export function seedDb(): MockDb {
enclosureId: 'enc-gross',
originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
mergedRecordCount: 2,
fromWurfchronik: true,
parentMethod: 'chart-position',
parentConfidence: 'medium',
notes: ['aus 2 Datensätzen zusammengeführt'],
history: [
'In „Stammbaum von Krümel.xlsx“ gefunden.',
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
// VERWORFEN: abweichender Wert aus einer Quelle wurde verworfen (Grund + Ersatz).
'⚠ Geburtsdatum 14.06.2015 aus „Wurfchronik-Detail.docx“ verworfen — abweichend; 12.03.2025 aus „Stammbaum von Krümel.xlsx“ verwendet (Mehrheit).',
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
// VERWORFEN: unplausibler Elternteil verworfen, kein Ersatz verwendet.
'⚠ Vater „Jayjay“ (*19.06.2013) verworfen — unplausibel (9 Jahre älter als das Kind); kein Ersatz.',
'Angaben aus der Wurfchronik übernommen.',
],
}),
},
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
@@ -171,10 +211,34 @@ export function seedDb(): MockDb {
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
]
const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
history: [
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
'Geschwister-Würfe zusammengeführt.',
],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -185,13 +249,47 @@ export function seedDb(): MockDb {
]
const enclosures: Enclosure[] = [
{ id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' },
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
// Reinigung überfällig: letzte Reinigung lange her + Zyklus -> nextCleaningDate in der Vergangenheit.
{
id: 'enc-gross',
name: 'Großbecken',
notes: null,
size: '120×50 cm',
capacity: 6,
lastCleanedDate: '2020-01-01',
cleaningCycleDays: 14,
nextCleaningDate: '2020-01-15',
},
// Kein Reinigungszyklus hinterlegt.
{
id: 'enc-leer',
name: 'Quarantänebecken',
notes: null,
size: null,
capacity: null,
lastCleanedDate: null,
cleaningCycleDays: null,
nextCleaningDate: null,
},
]
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
{
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
history: [
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
'Sowohl als Züchter als auch als Abnehmer geführt.',
],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
@@ -311,8 +409,219 @@ export function seedDb(): MockDb {
requests,
aiConfigured: true,
mailConfigured: true,
contracts: [],
// Ein importierter Vertrag (hasFile:false) prüft die Word-Button-Ausblendung.
contracts: [
{
id: 'contract-import-1',
contactId: 'con-meier',
price: 30,
handoverDate: '2023-09-29',
contractDate: '2023-09-29',
fileName: 'Zucht der kleinen Chaoten _ (Krümel) - Zoohandlung Meier_.docx',
createdAt: '2023-09-29T00:00:00Z',
gerbilIds: ['kruemel'],
url: '/contracts/contract-import-1/file',
hasFile: false,
},
],
saleAdConfigured: true,
namesConfigured: true,
// FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst →
// db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben).
feedback: [
{
id: 'feedback-resolved',
message: 'Der Stammbaum zeigt den falschen Vater bei Krümel.',
context: 'stammbaum',
gerbilId: 'kruemel',
litterId: null,
contactId: null,
entityName: 'Krümel',
url: 'http://localhost:5173/rennmaeuse/kruemel/stammbaum',
clientTimestamp: '2026-06-10T09:00:00Z',
userAgent: null,
createdAt: '2026-06-10T09:00:00Z',
status: 'Resolved',
resolvedAt: '2026-06-12T14:00:00Z',
question: null,
answer: null,
answeredAt: null,
// Changelog (laienverständlich) für die geschlossene Ansicht.
fixNote: 'Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.',
// INTERN: darf NIE in der Züchterin-UI erscheinen.
agentContext: 'GEHEIM-AGENT-CONTEXT: motherId via conflict-decisions.json gesetzt, re-ingest.',
thread: [],
},
{
// Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können.
// Hat bereits eine frühere, beantwortete Runde im Verlauf (thread).
id: 'feedback-needsinfo',
message: 'Der Wurf B hat ein falsches Geburtsdatum.',
context: 'litter-detail',
gerbilId: null,
litterId: 'wurf-b',
contactId: null,
entityName: 'Wurf B',
url: 'http://localhost:5173/wuerfe/wurf-b',
clientTimestamp: '2026-06-14T08:00:00Z',
userAgent: null,
createdAt: '2026-06-14T08:00:00Z',
status: 'NeedsInfo',
resolvedAt: null,
question:
'Welches Geburtsdatum ist korrekt — der **1.** oder der **2. Juni**?\n\nBitte wähle:\n- 1. Juni\n- 2. Juni',
answer: null,
answeredAt: null,
fixNote: null,
agentContext: 'GEHEIM-AGENT-CONTEXT: warte auf DOB-Bestätigung für Wurf B.',
// Frühere, abgeschlossene Runde (älteste zuerst) — bleibt im Verlauf erhalten.
thread: [
{ role: 'maintainer', text: 'Wurde der Wurf im Mai oder Juni geboren?', at: '2026-06-13T08:00:00Z' },
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
],
},
{
id: 'feedback-open',
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
context: 'gerbil-detail',
gerbilId: 'fridolin',
litterId: null,
contactId: null,
entityName: 'Fridolin',
url: 'http://localhost:5173/rennmaeuse/fridolin',
clientTimestamp: '2026-06-15T11:30:00Z',
userAgent: null,
createdAt: '2026-06-15T11:30:00Z',
status: 'Open',
resolvedAt: null,
question: null,
answer: null,
answeredAt: null,
fixNote: null,
agentContext: null,
thread: [],
},
],
rpro3: {
analyze: {
counts: {
ownAnimals: 303,
externalRaw: 7427,
externalAfterDedup: 3391,
litters: 127,
contacts: 750,
genotypes: 7732,
duplicatesMerged: 4036,
mergeClusters: 782,
},
newVsCurrentNew: 280,
newVsCurrentExisting: 23,
topMerges: [
{ name: 'Alice', recordCount: 30, dob: '2010-07-19', farbe: 'Schimmel', origin: 'Clan of Kleine Füchse' },
{ name: 'BlackEye', recordCount: 28, dob: '', farbe: 'Silberschimmel', origin: 'Clan of Desert' },
],
ambiguousNames: [
{
name: 'Merlin',
bareCount: 0,
variants: [
{ count: 4, dob: '2007-08-22', farbe: 'Blaufuchs', origin: 'Clan of the Yankees', isOwn: false },
{ count: 3, dob: '—', farbe: 'Platin-Schecke', origin: '—', isOwn: false },
],
},
{
name: 'Bella',
bareCount: 0,
variants: [
{ count: 2, dob: '2012-06-21', farbe: 'Blaufuchs', origin: 'Clan of Sunset Glow', isOwn: false },
{ count: 1, dob: '2013-03-20', farbe: 'Algierfuchs, hell', origin: 'eigene Zucht', isOwn: true },
],
},
],
photosProvided: false,
photoFilesAvailable: 0,
},
execute: {
gerbilsImported: 3694,
littersImported: 1678,
contactsImported: 508,
healthRecordsImported: 39,
weightRecordsImported: 229,
photosImported: 0,
message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.',
},
},
acquisitions: [],
// ABGABE-STATUS: eine reservierte + eine abgegebene + eine verfügbare Vormerkung.
reservations: [
{
id: 'res-reserviert',
gerbilId: 'sale-balu',
gerbilName: 'Balu Abgabe',
status: 'reserviert',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: '2026-07-01T00:00:00Z',
price: 25,
note: 'Käfig wird mitgebracht.',
handedOverDate: null,
createdAt: '2026-06-15T09:00:00Z',
updatedAt: '2026-06-15T09:00:00Z',
},
{
id: 'res-abgegeben',
gerbilId: 'pup-abgabe',
gerbilName: 'Pippa',
status: 'abgegeben',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: null,
price: 20,
note: null,
handedOverDate: '2025-05-01T00:00:00Z',
createdAt: '2025-04-20T09:00:00Z',
updatedAt: '2025-05-01T09:00:00Z',
},
{
id: 'res-verfuegbar',
gerbilId: 'sale-benny',
gerbilName: 'Benny Abgabe',
status: 'verfuegbar',
reservedForContactId: null,
contactName: null,
appointmentDate: null,
price: null,
note: null,
handedOverDate: null,
createdAt: '2026-06-16T09:00:00Z',
updatedAt: '2026-06-16T09:00:00Z',
},
],
waitingList: [
{
id: 'wl-seed-1',
contactId: contacts[0]?.id ?? null,
contactName: contacts[0]?.name ?? 'Familie Sonntag',
wishColor: 'Schwarz',
wishGender: 'female',
requestedAt: '2026-05-01T00:00:00Z',
status: 'offen',
note: 'möchte zwei Weibchen',
createdAt: '2026-05-01T00:00:00Z',
},
{
id: 'wl-seed-2',
contactId: null,
contactName: 'Herr Maier',
wishColor: null,
wishGender: 'male',
requestedAt: '2026-04-10T00:00:00Z',
status: 'erfuellt',
note: null,
createdAt: '2026-04-10T00:00:00Z',
},
],
returns: [],
exhibitions: [],
}
}

View File

@@ -0,0 +1,112 @@
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const p = de.provenance
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
await expect(dialog).toContainText(
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
)
// VERWORFEN: eine Quelle wurde verworfen — mit Grund und verwendetem Ersatz.
await expect(dialog).toContainText(p.discardLegend)
await expect(dialog).toContainText(
'⚠ Geburtsdatum 14.06.2015 aus „Wurfchronik-Detail.docx“ verworfen — abweichend; 12.03.2025 aus „Stammbaum von Krümel.xlsx“ verwendet (Mehrheit).',
)
await expect(dialog).toContainText(
'⚠ Vater „Jayjay“ (*19.06.2013) verworfen — unplausibel (9 Jahre älter als das Kind); kein Ersatz.',
)
// Die Verwerfen-Zeile ist visuell als solche markiert (eigene CSS-Klasse).
await expect(dialog.locator('.provenance__history-step--discard').first()).toBeVisible()
// Quelldateien werden angezeigt.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
// Zusammenführungs-Info + Wurfchronik-Hinweis.
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
await expect(dialog).toContainText(p.methods['chart-position'])
await expect(dialog).toContainText(p.confidences.medium)
// Hinweis aus dem Import.
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
skipUnlessMock()
// Luna hat keine provenance im Mock → Leer-Zustand.
await page.goto('/rennmaeuse/luna')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.empty)
})
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Kontakts.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('als Züchter erkannt')
await expect(dialog).toContainText('als Abnehmer erkannt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Wurfs.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('aus Wurfchronik')
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})

View File

@@ -0,0 +1,49 @@
/** RPRO3: RennmausPro-III-Import-Rubrik unter „Hilfe" — Auswerten → Import durchführen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.rpro3Import
const h = de.hilfe
test('Hilfe verlinkt auf den RennmausPro-Import', async ({ page }) => {
skipUnlessMock()
await page.goto('/hilfe')
// Den Abschnitt aufklappen und dem Link folgen.
await page.getByText(h.sections.rpro3Import).click()
await page.getByRole('link', { name: new RegExp(h.rpro3Link.button) }).click()
await expect(page).toHaveURL(/\/hilfe\/rennmauspro-import$/)
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
})
test('Auswerten zeigt die Übersicht, danach importiert "Import durchführen"', async ({ page }) => {
skipUnlessMock()
await page.goto('/hilfe/rennmauspro-import')
// Auswerten ohne Datei ist deaktiviert.
const analyzeBtn = page.getByRole('button', { name: t.analyzeButton })
await expect(analyzeBtn).toBeDisabled()
// Eine (Pseudo-).backup-Datei auswählen.
await page.getByLabel(t.backupLabel).setInputFiles({
name: 'RennmausPro.backup',
mimeType: 'application/octet-stream',
buffer: Buffer.from('PK fake-zip'),
})
await expect(analyzeBtn).toBeEnabled()
await analyzeBtn.click()
// Auswertung erscheint (Zählungen + Abgleich).
await expect(page.getByRole('heading', { name: t.step2Title })).toBeVisible()
await expect(page.getByText(t.counts.ownAnimals)).toBeVisible()
await expect(page.getByText('303')).toBeVisible()
await expect(page.getByText(t.newCount(280))).toBeVisible()
// Mehrdeutige Namen sind aufklappbar.
await page.getByText(t.ambiguousTitle).click()
await expect(page.getByText('Merlin')).toBeVisible()
// Import durchführen.
await page.getByRole('button', { name: t.executeButton }).click()
await expect(page.getByRole('heading', { name: t.successTitle })).toBeVisible()
await expect(page.getByText('3694')).toBeVisible()
await expect(page.getByText(t.resultCounts.gerbils)).toBeVisible()
})

View File

@@ -0,0 +1,78 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite (verfügbar → reserviert → abgegeben).
* Erreichbar über das Menü („Reservierungen"). Liste mit Status-Chips, Tier-/Kontakt-Links,
* Anlegen, Status-Wechsel und Löschen.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tr = de.pages.reservierungen
test.describe('Reservierungen', () => {
test.beforeEach(() => skipUnlessMock())
test('über das Menü erreichbar, zeigt die Seed-Reservierungen', async ({ page }) => {
await gotoSection(page, de.nav.reservations)
await expect(page.getByRole('heading', { name: tr.title })).toBeVisible()
// Reservierte + abgegebene + verfügbare Vormerkung sichtbar.
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toBeVisible()
})
test('Status-Filter filtern korrekt (Anzahl-Badges stimmen)', async ({ page }) => {
await page.goto('/reservierungen')
const filters = page.locator('.reservierungen-filter')
await filters.filter({ hasText: tr.filters.reserviert }).click()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toHaveCount(0)
await filters.filter({ hasText: tr.filters.abgegeben }).click()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toHaveCount(0)
})
test('Tier- und Kontakt-Bezüge sind verlinkt', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Balu Abgabe' })
await expect(card.getByRole('link', { name: 'Balu Abgabe' })).toHaveAttribute('href', /\/rennmaeuse\/sale-balu/)
await expect(card.getByRole('link', { name: 'Familie Huber' })).toHaveAttribute('href', /\/kontakte\/con-huber/)
})
test('Status wechseln: verfügbares Tier reservieren', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card.locator('.reservation-badge--verfuegbar')).toBeVisible()
await card.getByRole('button', { name: tr.actions.markReserved }).click()
await expect(
page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }).locator('.reservation-badge--reserviert'),
).toBeVisible()
})
test('neue Reservierung anlegen', async ({ page }) => {
await page.goto('/reservierungen')
await page.getByRole('button', { name: tr.newButton }).click()
const form = page.locator('.reservation-form')
await expect(form).toBeVisible()
// Ein Tier auswählen (Krümel ist in den Seed-Daten vorhanden).
await form.getByLabel(tr.form.animal).selectOption({ label: 'Krümel' })
await form.getByRole('button', { name: tr.form.save }).click()
// Die neue Reservierung erscheint in der Liste.
await expect(page.locator('.reservation-card').filter({ hasText: 'Krümel' })).toBeVisible()
})
test('Reservierung löschen', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card).toBeVisible()
acceptNextDialog(page)
await card.getByRole('button', { name: tr.actions.delete }).click()
await expect(page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })).toHaveCount(0)
})
})

View File

@@ -0,0 +1,59 @@
/** RÜCKNAHMEN: Verwaltungsseite für zurückgekommene Tiere (/ruecknahmen). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.ruecknahmen
test('Rücknahmen: Liste ist anfangs leer und das Formular lässt sich öffnen', async ({ page }) => {
skipUnlessMock()
await page.goto('/ruecknahmen')
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
await expect(page.getByText(t.empty)).toBeVisible()
await page.getByRole('button', { name: t.newButton }).click()
await expect(page.getByRole('heading', { name: t.formTitle })).toBeVisible()
})
test('Rücknahmen: erfassen über Formular speichert und zeigt den Eintrag', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/ruecknahmen')
await page.getByRole('button', { name: t.newButton }).click()
// Tier + Kontakt aus den Dropdowns wählen, Datum + Grund eintragen.
await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' })
await page.getByRole('textbox', { name: t.fields.returnDate, exact: true }).fill('2026-05-01')
await page.getByRole('combobox', { name: t.fields.fromContact, exact: true }).selectOption({ label: 'Zoohandlung Meier' })
await page.getByRole('textbox', { name: t.fields.note, exact: true }).fill('Allergie in der Familie')
await page.getByRole('button', { name: t.save }).click()
// Eintrag erscheint in der Liste mit verlinktem Tiernamen.
const card = page.locator('.gerbil-card', { hasText: 'Krümel' })
await expect(card).toBeVisible()
await expect(card).toContainText('Allergie in der Familie')
await expect(card.getByRole('link', { name: 'Krümel' })).toBeVisible()
// Im Mock persistiert (gerbilId + note).
expect(mockDb).not.toBeNull()
const rows = mockDb!.returns
expect(rows.length).toBe(1)
expect(rows[0]).toMatchObject({ gerbilId: 'kruemel', note: 'Allergie in der Familie' })
})
test('Rücknahmen: Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => {
skipUnlessMock()
// Einen Eintrag über die UI anlegen (Fixture erstellt die DB frisch pro Test).
await page.goto('/ruecknahmen')
await page.getByRole('button', { name: t.newButton }).click()
await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' })
await page.getByRole('button', { name: t.save }).click()
await expect(page.locator('.gerbil-card', { hasText: 'Krümel' })).toBeVisible()
// Löschen bestätigen.
page.on('dialog', (d) => d.accept())
await page.locator('.gerbil-card', { hasText: 'Krümel' }).getByRole('button', { name: t.delete }).click()
await expect(page.getByText(t.empty)).toBeVisible()
expect(mockDb!.returns.length).toBe(0)
})

View File

@@ -111,6 +111,33 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
const ref = page.locator('.pedigree-card--sibling')
await expect(ref).toBeVisible()
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
await expect(ref).toContainText(t.siblingPairing.refHint)
// Mini-Legende ergänzt den Geschwister-Hinweis.
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.

View File

@@ -0,0 +1,192 @@
/**
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
* Erreichbar über Hilfe → „Meine Tickets". Drei gefilterte Verlinkungen (Filter-Chips):
* „Rückfragen" (im Dialog), „Offen", „Geschlossen" (zeigt den Changelog/fixNote).
* Pro Ticket: gelöst markieren / wieder öffnen, Nachricht bearbeiten, antworten, löschen,
* Frage/Antwort-Verlauf (thread). Das interne `agentContext` wird NIE gerendert.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tt = de.feedback.tickets
/** In die gewünschte gefilterte Ansicht wechseln (Filter-Chip klicken). */
async function selectView(page: import('@playwright/test').Page, label: string) {
await page.locator('.tickets-filter').filter({ hasText: label }).click()
}
test.describe('Meine Tickets', () => {
test.beforeEach(() => skipUnlessMock())
test('über Hilfe erreichbar, Default-Ansicht „Offen" zeigt offenes Ticket mit Badge', async ({
page,
}) => {
await gotoSection(page, de.nav.help)
await page.getByRole('link', { name: tt.link }).click()
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
// Default = „Offen": offenes Ticket + dessen Nachricht sichtbar.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
})
test('drei gefilterte Verlinkungen filtern korrekt, Anzahl-Badges stimmen', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Filter-Chips vorhanden, jeweils mit Anzahl-Badge (1 pro Gruppe in den Seed-Daten).
const dialog = page.locator('.tickets-filter').filter({ hasText: tt.filters.dialog })
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
await expect(open.locator('.tickets-filter__count')).toHaveText('1')
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
// „Offen": nur das offene Ticket.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toHaveCount(0)
// „Rückfragen": nur das Dialog-Ticket (NeedsInfo).
await selectView(page, tt.filters.dialog)
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toBeVisible()
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toHaveCount(0)
// „Geschlossen": nur das gelöste Ticket.
await selectView(page, tt.filters.closed)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
})
test('geschlossenes Ticket zeigt den Changelog (fixNote) + Datum', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.getByText(tt.changelogLabel)).toBeVisible()
await expect(
card.getByText('Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.'),
).toBeVisible()
// Datum „Gelöst am" in der Meta-Leiste.
await expect(card.getByText(tt.resolvedLabel)).toBeVisible()
})
test('agentContext wird NIE in der Züchterin-UI gerendert (in allen Ansichten)', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
// In jeder Ansicht prüfen: der interne Kontext-Text taucht nirgends auf.
for (const view of [tt.filters.open, tt.filters.dialog, tt.filters.closed]) {
await selectView(page, view)
await expect(page.getByText('GEHEIM-AGENT-CONTEXT', { exact: false })).toHaveCount(0)
}
})
test('Verlauf (thread) früherer Runden wird angezeigt', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card.getByText(tt.threadLabel)).toBeVisible()
// Frühere Runde (Frage + Antwort) sichtbar.
await expect(card.getByText('Wurde der Wurf im Mai oder Juni geboren?')).toBeVisible()
await expect(card.getByText('Im Juni.')).toBeVisible()
// Aktuelle offene Rückfrage ebenfalls sichtbar.
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
})
test('Ticket als gelöst markieren', async ({ page }) => {
await page.goto('/hilfe/tickets')
const openCard = page
.locator('.ticket-card')
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
await expect(openCard.getByText(tt.statusOpen, { exact: true })).toBeVisible()
await openCard.getByRole('button', { name: tt.markResolved }).click()
// Das Ticket verlässt die „Offen"-Ansicht — die Ansicht weicht automatisch auf
// eine nicht-leere Gruppe aus. Das gelöste Ticket findet sich unter „Geschlossen".
await selectView(page, tt.filters.closed)
const resolvedCard = page
.locator('.ticket-card')
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
await expect(resolvedCard.locator('.ticket-badge--resolved')).toBeVisible()
await expect(resolvedCard.getByRole('button', { name: tt.reopen })).toBeVisible()
})
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
await page.goto('/hilfe/tickets')
// „Offen"-Ansicht (Default): das offene Ticket über den Datensatz-Namen adressieren.
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await card.getByRole('button', { name: tt.edit }).click()
const textarea = card.locator('textarea')
await textarea.fill('Korrigierte Beschreibung des Fehlers.')
await card.getByRole('button', { name: tt.save }).click()
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
})
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card).toBeVisible()
// Rückfrage ist sichtbar; Status-Badge „Rückfrage offen".
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible()
// Antworten.
await card.locator('.ticket-card__answer-form textarea').fill('Korrekt ist der 2. Juni.')
await card.getByRole('button', { name: tt.answerButton }).click()
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet". Das Ticket verlässt die
// Rückfragen-Liste und steht jetzt unter „Offen" (Ansicht wechselt automatisch dorthin,
// da die Rückfragen-Gruppe nun leer ist).
await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible()
await expect(card.locator('.ticket-badge--answered')).toBeVisible()
await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible()
const openFilter = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
await expect(openFilter).toHaveClass(/tickets-filter--active/)
})
test('Rückfrage rendert Markdown und ist (offen) nicht bearbeitbar', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card).toBeVisible()
// Markdown: **fett** → <strong>, „- "-Aufzählung → <li>.
await expect(card.locator('.ticket-card__question-text strong').first()).toBeVisible()
await expect(card.locator('.ticket-card__question-text li')).toHaveCount(2)
// Offene Rückfrage (NeedsInfo): kein „Bearbeiten"-Button, dafür ein Antwort-Feld.
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
})
test('Ticket löschen', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card).toBeVisible()
acceptNextDialog(page)
await card.getByRole('button', { name: tt.delete }).click()
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0)
})
})

Some files were not shown because too many files have changed in this diff Show More